← 文章 / 编程开发
freeCodeCamp 3小时前 · 2026-09-20 12:26:53 · 2 阅读

iOS NFC 开发指南:React Native 中读取、写入与锁定 NFC 标签

把 iPhone 凑近贴纸,奇迹就发生了:名片直接存入通讯录,专注时段结束,或者门自动打开。这枚芯片成本约二十便士,容量大约一百三十字节。

读取它只需两个函数调用。但获取执行这两次调用的权限,耗时却长得多。更棘手的是,CoreNFC 随后还会要求你再次争取这项权限。

第一道坎来自苹果。你需要一个付费开发者账号,在网页端注册 App ID,勾选相应功能,并重新生成配置文件。任何一步出错,构建都会因代码签名错误而失败,且错误信息中绝口不提“NFC”。

第二道坎来自 CoreNFC 自身,这是没人会提前警告你的陷阱。它基于会话 API,涉及委托回调、四层嵌套的异步步骤,以及一套会在静默中惩罚你的规则。

把会话存放在错误的变量里,系统弹窗就会毫无报错地消失。让成功的读取操作两次解析同一个 Promise,结果就会被失败覆盖。多请求一个轮询选项,整个会话就会拒绝启动,且不会指明是哪个选项出了问题。

本手册将带你跨越这两道坎。你会手写一个 NDEF 解码器,这在看到那些流行库如何处理表情符号时,会觉得并非多余。你会向标签写入数据,并发现名片其实装不下。你会构建一个专注计时器,除非走出房间去另一个空间,否则无法停止。你会彻底移除 NFC 依赖,转而使用 Swift 编写的原生模块。最后,你会永久锁定标签,这是全书中唯一不可逆的操作。

在构建过程中,我有两个结论最终被证明是错误的,且两者都带“已废弃”横幅保留在代码仓库中。这里也会保留它们,因为我是如何犯错的过程,比替代方案更有参考价值。

这是一份 iOS 手册。主要章节中的所有内容均在真机上构建、运行并验证。Android 版将单独成书,若你等不及,文末有预览:涵盖此处的所有 Kotlin 对应实现,以及使两大平台值得对比的架构差异。

以下内容均源自同一个项目:TapCard。该项目位于 GitHub,并带有标记的检查点,你可以随时检出并运行任意阶段的代码。

目录

前提条件

要跟着做,你需要:

  • 一台物理 iPhone,型号需为 iPhone 7 或更新款。Core NFC 仅限 iPhone 使用。苹果文档明确列出 iPhone 7 及后续机型,一位苹果工程师在开发者论坛上也直言不讳:“目前,CoreNFC 功能仅支持具备 NFC 能力的 iPhone。”iPad 并不支持。

  • 付费的 Apple Developer 账户。免费描述文件不支持 NFC。这不是软性要求,而是硬性规定,无法绕过。

  • NTAG213 标签。购买 20 枚只需几英镑。务必在编写代码前先买好。

  • Node 20+ 和 Xcode。全程需具备 TypeScript 的实际操作能力,后三分之一章节需掌握 Swift。

  • 可选,仅用于 Android 预览:Android Studio 和 API 36 SDK。

我使用的版本如下:

软件包或工具 版本
Expo SDK 57.0.20
React Native 0.86.3
React 19.2.3
TypeScript 6.0.3
react-native-nfc-manager 3.17.2(最终被移除)
Xcode 26.6
JDK 17 (Zulu),仅用于 Android 预览

表中有且仅有一行信息至关重要,我稍后会详细阐述:NFC 库之所以列入,恰恰是因为它最终会被删除。完成后的应用完全不依赖任何第三方 NFC 库

NFC 标签的本质

NFC 标签是一枚芯片,配备微型天线,没有电池。你的手机通过空中感应为其供电,作为回报,它返回几十字节的少量数据。这就是该设备的全部构成。

这些数据几乎总是采用 NDEF 格式,即 NFC 数据交换格式。NDEF 使得一个应用写入的标签能够被其他任何应用读取。NDEF 消息是一组记录,每条记录包含类型和有效载荷。

本手册涉及的标签是 NTAG213,也就是你在网上购买的贴纸标签。它们拥有 144 字节的用户存储区。但请注意,这并非你可实际使用的容量,而这一细节至关重要。

需要明确两点,NFC 标签不是:

  1. 它不是信标。 它自身没有电源,在手机靠近(几厘米内)之前不会做任何事。

  2. 默认状态下并不安全。其标识符任何人用低成本硬件就能读取和复制。后文会专门讲这一点,因为把标签用作“钥匙”是最直观的场景,也最容易做砸。

你其实早已见过它

NFC 早就装在你口袋里了。有必要把“能做什么”和“不能做什么”分清楚,因为对最终用户来说体验是一样的。

以下是五个可以去实地验证的用例,每个都有官方文档:

在哪见过 发生了什么 来源
手机自带的“快捷指令” 扫一下标签,触发一段个人自动化 在快捷指令中设置触发器
丢失的 AirTag 任何人用 NFC 手机碰一下,就会打开一个页面显示失主信息 在查找网络中标记物品为丢失
任天堂 amiibo 把手办贴到控制器上:部分游戏会读取信息,部分还会把角色写回手办 amiibo 常见问题
英国签证申请 “UK Immigration: ID Check” 应用读取护照内的芯片 使用说明
iPhone 的 Tap to Pay 店铺无需终端设备,直接用 iPhone 完成非接触式刷卡收款 Tap to Pay on iPhone

前三个就是这本手册要带你做的东西:芯片里存几个字节,读取器读出来,软件再行动。amiibo 是最完整的例子,因为它不仅读,还会把数据写回手办——这正是你即将实现的另一半。

第一行有个细节值得停下来看,因为它后面还会提到。Apple 自带的 NFC 自动化(每台 iPhone 都有)对扫到的标签是这样说明的:

“除了唯一的标识符,NFC 标签里的内容一律被忽略。”

Apple 的这个功能完全无视本手册教你写入的所有内容,只看序列号。它能带来什么、不能带来什么,后文有一节专门讨论。

关于上表还有两点要说明。护照那一行确实是 NFC,但不是 NDEF:生物识别芯片走的是同一段无线电波上的 ISO 7816 协议,需要通过 NFCISO7816Tag 和另一套 entitlement 访问,还叠加了自己的加密体系。天线相同,世界完全不同,这里不展开。最后一行是你无法实现的那一半,也就是本节剩下的内容。

除了这五个场景,凡是需要让实体物品对手机"说一句短话"的地方,这个模式都会反复出现:博物馆的展品标签、会议胸牌、瓶身上的防伪封签、公交海报、餐厅桌牌,还有人们贴在办公桌上用来触发某个快捷流程的小贴纸。

在 iOS 上做不到的是另一半:让手机扮演一张卡。比如 Apple Pay、Google Wallet 里的银行卡,或 Apple Wallet 里的酒店房卡。这些都运行在 Secure Element 上——一块独立的防篡改芯片,自行保存卡片凭证并直接响应终端。iOS 上没有任何第三方 API 可以访问它。不是受限,而是根本没有。后文有一节会详细讲它封闭到什么程度。

所以当有人提到"NFC"时,可能指的是其中任何一半。本手册讲的是你能真正写代码的那一半,也会精确解释另一半为什么是封闭的。

为什么模拟器走不通

别跳过这一节,它会改变你的开发方式。

iOS Simulator 和 Android 模拟器上都没有 NFC。不是部分支持,也不是藏在某个开关后面。硬件根本不存在,API 会直接报告不可用。你开发的每一个功能,都得拿实体芯片贴实体手机来测试。

由此带来的后果不只是麻烦。你无法写一个自动测试来证明标签被读取过,也无法在 CI 里做演示。手机要是在另一个房间,你就被卡住了。我的芯片在邮寄途中丢失时,项目整整停摆了两周。

这个应用会永久显示这条信息,因为总有人问起:

import * as Device from 'expo-device';

// NFC hardware does not exist on the iOS Simulator or the Android emulator,
// and isSupported() is not reliable there. Bail out early and explicitly.
if (!Device.isDevice) return false;

所以,把代码拆成两部分。

第一部分负责与 NFC 硬件交互:开启会话、读取标签、写入数据、关闭会话。这部分唯一的测试方法,就是把芯片贴在手机上。

其他所有逻辑都只是数据处理:把标签上的字节转换为 URL,把联系人信息转换为要写回的字节,检查消息是否超长,把原生错误码转成用户能看懂的文案,或者判断轻点屏幕是应该开始还是结束专注模式。

这些都不需要 NFC。其中大部分甚至不需要 React Native。它们就是纯函数:输入值,输出值。

按这种方式编写,你可以在笔记本电脑上测试,房间里连部手机都不用有。在本项目中,这部分代码约为 2240 行 TypeScript,包含 293 个测试,耗时约一秒。真正调用 CoreNFC 的代码则被我压缩到了极致。

这个项目曾三次因硬件问题停滞,包括那漫长的两周等待邮寄。但每次停滞时,仍有大量功能可以开发,因为其中大部分功能根本不需要标签。这是代码库中最明智的决定,而我并非有意为之。是硬件限制替我做出了这个选择。

检查点: 执行 git checkout step-0-scaffold。应用在双平台上启动,但尚无其他功能。

iOS 协议机制

有 3 个 Apple 术语构成了这套机制的核心,建议在开始步骤之前先厘清它们,因为后续的报错信息会默认你已掌握这些概念。

App ID 是应用在 Apple 服务器上的注册身份,需与配置中的 bundleIdentifier 保持一致。Entitlement(权限)是应用中声明“我计划使用此功能”的配置项,NFC 便是其一。Provisioning Profile(描述文件)是将上述两者与开发者账号绑定的签名文档,Xcode 会将其嵌入每个构建包中。三者必须完全匹配,否则应用无法在设备上运行。

基于此,实际的操作流程如下,每一步都至关重要:

  1. 一个付费的 Apple 开发者账号。

  2. 在 developer.apple.com 注册显式 App ID,而非通配符 App ID。

  3. 在该 App ID 上勾选读取 NFC 标签能力。

  4. 重新生成预置描述文件(Provisioning Profile)以包含此能力。

然后,在 app.json 中配置:

{
  "expo": {
    "ios": {
      "bundleIdentifier": "com.yourname.tapcard",
      "infoPlist": {
        "NFCReaderUsageDescription": "TapCard 使用 NFC 读取并向标签写入您的卡片信息。"
      },
      "entitlements": {
        "com.apple.developer.nfc.readersession.formats": ["NDEF", "TAG"]
      }
    }
  }
}

若跳过第 2 步或第 3 步,构建将失败并报出以下错误:

Provisioning Profile "iOS Team Provisioning Profile: *" does not support
the NFC Tag Reading capability.

该错误指向了错误的机器。 Apple 禁止在通配符 App ID 上授予特殊能力,若没有显式匹配,Xcode 会静默回退到通配符配置。

该报错指责你本地的预置描述文件,但缺失的另一半在于 Apple 服务器——你尚未填写的那个网页表单。错误信息中毫无提示你需要打开浏览器。

在那里还有一个次生陷阱:App ID 在所有 Apple 账户间全局唯一,而非仅针对团队唯一。我第一次选的名称已被某陌生人占用,第二次选的也被占用。我不得不重命名应用标识符两次。

这些重命名过程并不痛苦,这值得花点篇幅介绍该项目的架构。Expo 的连续原生生成(Continuous Native Generation)机制意味着 ios/android/ 文件夹根本不会保留在代码仓库中。它们由 expo prebuild 根据 app.json 随时重新生成,原理同 node_modulespackage.json 生成。因此,重命名应用只需修改两行 JSON 并运行一次 prebuild --clean,该命令会重建 iOS 工程、Android 包及整个 Kotlin 源码树。无需对 Xcode 进行“外科手术”式修改。

作为参照,Android 上等效操作仅在 Manifest 中添加一行代码,无需账户、无需控制台、且零成本:

<uses-permission android:name="android.permission.NFC" />

这种对比并非抱怨,而是值得尽早内化的认知,因为它揭示了在此平台上时间将流向何处:不在于代码本身(代码很短),而在于围绕代码的事务性工作。

Android iOS
读取一个标签 一行 manifest 配置 App ID + capability + 付费账号 + 描述文件
成本 免费 $99/年(或等值当地货币)
出错方式 缺少权限 代码签名错误,且错误信息绝不会提到 "NFC"

如何读取第一个 NFC 标签

读取代码很短,全部内容如下:

import NfcManager, { NfcTech, type TagEvent } from 'react-native-nfc-manager';

export async function readTagOnce(): Promise<TagEvent | null> {
  await NfcManager.requestTechnology(NfcTech.Ndef, {
    alertMessage: 'Hold your iPhone near the NFC tag.',
  });

  try {
    return await NfcManager.getTag();
  } finally {
    // throwOnError: false because we are already unwinding. A failure to
    // close the session must not mask the original error.
    await NfcManager.cancelTechnologyRequest({ throwOnError: false });
  }
}

只有两次调用,且在两个平台上完全一致。但用户看到的界面却截然不同。

iOS 上,requestTechnology 会把控制权交给 CoreNFC,由系统弹出一个模态面板。你无法改变它的样式,此时你的 App 甚至不在前台显示。

Android 上,什么都不显示,调度完全静默。如果 App 不提示用户去碰一下标签,就没人会这么做。

所以扫描界面必须区分平台处理,这个模式会贯穿整个项目:

{
  scanning && (
    <View style={styles.scanCard}>
      <ActivityIndicator />
      <Text>
        {Platform.OS === 'android'
          ? 'Hold a tag against the back of the phone.'
          : 'Waiting for the system NFC sheet…'}
      </Text>
      {/* Android draws no system UI, so the app must offer its own way out. */}
      {Platform.OS === 'android' && <Button title="Cancel" onPress={handleCancel} />}
    </View>
  );
}

这个 Cancel 按钮只在 Android 上显示,因为 iOS 的系统面板自带取消功能。

三个容易踩的坑

1. NFC 天线位置因机型而异。

在 iPhone 上,感应区位于顶部边缘靠近摄像头处;在 Android 上则位于背部中央。“把标签靠近手机”这种提示缺乏操作性,用户若拿错了位置只会觉得应用出了 bug。我最后针对不同平台画了一个带小圆圈标识的手机轮廓图,把正确位置标出来。

2. 系统弹出的提示条显示的不是你以为的那段文字。

我原以为它会显示 app.json 里的 NFCReaderUsageDescription。实际上不是。系统展示的是每次调用 requestTechnology() 时传入的 alertMessage。那个使用描述其实是隐私清单字符串:必填项,没有它会话无法启动,但永远不会直接展示给用户。

两个字符串配置和拼写都没错,但我对“哪段文字会被显示”的判断仍然错了。直到我亲自拿着手机试了一遍才发现。这也意味着提示文案可以随每次扫描场景动态变化。写操作时用“请将 iPhone 靠近标签以写入”,效果远好于直接复用读取时的文案。

The iOS system NFC sheet on an iPhone 13 Pro, reading "Ready to Scan" above the line "Hold your iPhone near the NFC tag.", with a Cancel button. Behind the sheet the app reports real hardware and a green "NFC ready" banner.

上面提示条里的第二行文字,正是上文 requestTechnology 调用中传入的 alertMessage,一字不差。app.json 里的使用描述根本没有出现在屏幕上。

3. 空标签在 iOS 上会被读成错误。

出厂即 NDEF 格式化的 NTAG213 标签内容为空,而 readNDEF 会将此情形报告为一次失败,而非“空消息”。要区分这两种状态,得看标签 NDEF 的状态字段,而不是靠读取错误来判断。由于你买到的每一张标签初始都是这样,这几乎是你会碰到的第一个坑。

实际返回内容

以下是 iPhone 13 Pro 读取 NTAG213 标签的真实输出:

{
  "id": "04C4FC91DF2A81",
  "tech": "mifare"
}

只有两个字段。这就是 iOS 读取操作的全部返回信息。没有大小、没有技术列表、也没有 NDEF 类型。同一颗芯片,Android 却能返回上述全部内容。

我从这个现象得出了错误结论:把它写进了三篇文档,甚至围绕它规划了一整阶段的工作。错误的具体细节稍后再说,因为这个错误比事实本身更有价值。如果你只想先看纠正结果,可以跳过这段。

那个 id 就是标签的 UID(唯一标识符),它是出厂时烧录在芯片里的序列号,任何询问者都能读取。前缀 04 是 NXP 的制造商代码,而 7 字节的 UID 长度是 NTAG21x 系列的特征,这说明标签确如其声称的那样。

应用在 iPhone 13 Pro 上首次扫描后的“读取”选项卡界面。标签卡片列出了 ID 04C4FC91DF2A81、类型(无)、技术支持类型(无)、最大容量(未知)以及 0 条 NDEF 记录,下方是一个仅包含 id 和 tech 字段的 RAW 块。

Max size 这一行是关键。我之前那个错误结论正是建立在 (unknown) 上,但这其实 并不是平台实际能告诉你的信息

检查点: 执行 git checkout step-1-first-read。此时已配置好 Entitlements,屏幕上显示了原始的 NDEF 数据转储。

深入理解 NDEF 记录

标签存储的并非 URL,而是字节;你的应用获取到的也是字节。本节主要讲如何将这些字节转换为 https://example.com 及其反向过程,这件事远比其名声听起来要简单。

处理的基本单位是记录,一条记录由三部分组成:

  • payload(负载):内容的原始字节

  • type(类型):负载的具体内容,例如 URI 或文本

  • TNF(Type Name Format,类型名称格式):3 位标志,用于指示如何解读类型字段

第三种最容易让人困惑。把 type 想象成标签名,TNF 则是解读该标签名的规则。类型字节 U 代表 “URI”,前提是 TNF 指明“这是 NFC Forum 定义的标准类型”。如果在不同的 TNF 下,同样的字节可能代表 MIME 字符串的开头。

绝大多数实际传输流量由两种记录布局承载,且它们都很小巧。

URI 记录

04 65 78 61 6d 70 6c 65 2e 63 6f 6d
│  └──────── "example.com" ────────┘
└─ prefix index → "https://"

→ https://example.com

下方的标注说明了每个字节的作用。第一个字节之后的内容就是普通文本:example.com。而第一个字节 04 是 NFC Forum 规范中一张 36 项表的索引,第 4 项对应 https://

也就是说,协议头只需要一个字节,而不是八个。对于只有 137 字节可用空间的标签来说,这可不是什么微不足道的优化,而是省下了 5% 的预算。

Text 记录

与 URI 不同,Text 需要额外两项信息:语言和编码方式。这两项都被塞进了第一个字节。

02 65 6e 48 69
│  └─┬─┘ └─┬─┘
│   "en"  "Hi"
└─ status byte

把第一个字节当作数字来读。这里是 2,表示语言代码的长度。所以接下来两个字节就是语言:65 6e,即 "en"。之后的所有内容都是正文:48 69,即 "Hi"。

编码方式也藏在同一个字节里。如果它的值大于等于 128,文本就是 UTF-16 而不是 UTF-8。"status byte" 的含义就这么简单。

这就是整个格式了。如果要自己写解码器,只需取低 6 位得到长度,再看最高位判断编码即可。

为什么我要自己写解码器

绕开现成的库不外乎两个原因:要么它无法满足你的全部需求,要么你所在的地方习惯先自己造轮子,再考虑引入依赖。

第二种原因比开源社区默认的情况更常见。就 NDEF 而言,答案很简短:可以,你自己完全写得出来。它就是几百行不含任何平台调用的纯函数,而你刚刚读到的格式就是所需的全部规范。

至于第一个原因,正是这里的实际情况。这个库内置了解码器,我在使用前读了一遍它的源码,二十分钟就改变了整个应用的架构。

它直接丢弃了语言代码。ndef-lib/ndef-text.js 中:

var languageCodeLength = data[0] & 0x3f; // 6 LSBs
// languageCode = data.slice(1, 1 + languageCodeLength),
// utf16 = (data[0] & 0x80) !== 0; // assuming UTF-16BE

// TODO need to deal with UTF in the future

代码测量语言码的长度,用它跳过该字段,而保留语言码的那行代码被注释掉了。decodePayload 只返回纯字符串,调用方根本无从还原语言信息。但记录里既然专门有语言字段,就是为了回答“这段内容是什么语言”这个问题。

那个 TODO 是承重的。UTF-16 文本记录会被直接按 UTF-8 解码,结果夹带大量 NUL 字符。

共享的字节转字符串辅助函数还会截断。ndef-lib/util.js

str += String.fromCharCode(ch);

String.fromCharCode 只保留低 16 位,而 emoji 塞不进 16 位。于是我喂了一个进去:

bytes    : 68 69 20 f0 9f 98 80
expected : hi 😀
library  : "hi " codepoints: 68 69 20 f600     ← U+F600, Private Use Area

U+1F600 变成了 U+F600,一个不可见字符。三字节的序列(如阿拉伯语和 CJK)没问题,所以这个 bug 一直藏着,直到有人用了 emoji。

与此同时,它的 URI 解码器只有八行,而且完全正确。 这种不对称才是有趣之处。这不是个烂库,它只是有两个过时的角落。想知道哪块是新、哪块是旧,只能靠读源码。

于是我手写了解码器,保留该库中与硬件交互的部分。大约 530 行,全是纯 TypeScript,不导入任何 React Native,这意味着它能在 Node 里跑,测试以毫秒计。

它的职责是把一条记录归为几种已知形状之一,这个类型描述的就是这几种形状:

export type NdefView =
  | { kind: 'empty' }
  | { kind: 'uri'; uri: string }
  | { kind: 'text'; text: string; lang: string; encoding: TextEncodingName }
  | { kind: 'mime'; mime: string; text?: string; bytes: number[] }
  | { kind: 'aar'; packageName: string }
  | { kind: 'unknown'; tnf: number; type: string; payload: number[] };

为什么采用联合类型(union of shapes),而不是一个包含大量可选字段的对象?因为如果都用可选字段,每个界面都得去猜哪些被填充了。在这里,只需检查一次 kind,TypeScript 就会明白剩余部分的结构。在 kind'text' 的记录上,根本不存在 .uri 属性。因此,试图读取它的界面会在编译阶段直接报错,而不是向手持手机的某人渲染一个 undefined

unknown 这种情况保留了原始的 tnftypepayload。这样一来,即使遇到无法识别的记录,至少还能以十六进制转储的形式展示出来。那种默默丢弃未知内容的读取器,比那个告诉你“我不认识这是什么,以下是原始字节”的读取器更糟糕。

一个平台间的差异隐藏解码器内部。记录的 type 字段在 Android 上是以原始字节形式到达的,但在 iOS 上有时已经是解码好的字符串。因此,同一条 URI 记录在其中一个平台上呈现为 [85],在另一个平台上则是 'U',其中 85 是字母 U 对应的字节值。

这一点很关键,因为 [85] === 'U' 的结果就是 false。没有报错,也没有警告:你的比较逻辑悄无声息地匹配失败,导致一条完全合法的 URI 记录落入“未知记录”的分支。在两个平台上,都应在比较之前将类型转换为字符串。

对照被替换的目标进行测试

这是我最希望大家借鉴的技巧。测试分为三组。

正确性:将解码器与手工构建的 payload 进行比对。

一致性:在库函数正确的那些地方,证明我们的实现与它完全匹配。包括库编码的 12 个真实 URI 经我们解码,以及全部 36 个前缀索引。这是为手工输入的查找表建立信心的低成本方式。

分歧是最特别的一组。在库函数出现错误的那些地方,编写测试来固定其具体的错误方式:

✓ 库丢弃了语言代码,我们保留了它
✓ 库截断了 4 字节 UTF-8,我们不会
✓ 两者都正确处理 3 字节序列,因此只有高于 U+FFFF 的字符才会出错
✓ 库忽略了 UTF-16 标志,我们遵循它

这看起来很反直觉。你正在编写那些正因为依赖库有缺陷而通过的测试,而通常情况下这是个危险信号。

这类测试能站稳脚跟,靠的是两点。首先,它是一份书面记录,写明你的代码为什么存在,就放在代码旁边,而不是躺在没人看的 commit message 里。其次,因为它断言的是这个库的当前行为,一旦上游哪天把它修好了,测试就会立刻失败。

而失败恰恰是它的价值所在。这不是坏掉的测试,而是一则通知:你写自定义解码器的理由可能已经不存在了,该去看看了。一句“这个库有 bug”的注释会在库不断变化的背景下悄然腐烂,测试却不会。

有一个细节决定了这些测试是有用还是无用。emoji 测试断言的是精确的错误答案,即码点 0xf600,而不是笼统的“这个库和我们的预期不一致”。

如果只检查“不一致”,那么未来的版本哪怕换成了一个不同的 bug,测试照样通过,你也永远察觉不到行为已经变了。把具体的错误值固定下来,意味着任何变化都会暴露出来——无论是上游修好了 bug,还是引入了新 bug。

检查点:git checkout step-2-decode。此时已实现自定义解码器,以及一个展示所有字段和原始字节的 Tag Info 界面。

如何写入标签

接下来是反方向:写入。有两种方案,它们不是简单的变体,而是截然相反的取舍。

URL record 体积小,任何手机无需安装任何 App 就能打开它。大多数商用 NFC 名片用的就是这种方式。但它依赖另一端的东西:一个持续续费的域名、一台持续在线的服务器,或者用户碰卡那一刻有网络连接。

vCard 则是整张名片:以 text/vcard 作为 MIME record,不需要服务器,在飞机上也能用。代价是体积大得多。

两种我都实现了,让用户自己选,因为数字比任何文案都更有说服力。

应用的 Write 标签页。“Write to a tag”标题下并排放着两个按钮,URL 显示 17 字节,vCard 显示 184 字节,下方一行说明:“写入会覆盖标签原有内容,但不会将其锁定。”

同样是联系信息,17 字节对 184 字节。这个差距就是全部取舍所在,也正是把这个选择交给用户、而不是替他们做决定的原因。

如何编码 vCard

vCard 本质上只是文本。以下是应用实际写入标签的内容:
BEGIN:VCARD
VERSION:3.0
N:Doe;Jane;;;
FN:Jane Doe
ORG:Example Ltd
TITLE:Full-stack engineer
TEL;TYPE=CELL:+15550100
EMAIL;TYPE=INTERNET:jane@example.com
URL:https://example.com
END:VCARD
每个字段占一行:一个名称,一个冒号,一个值,以及每行末尾的回车加换行符。构建好这个字符串,你就得到了一张可用的名片。 这种格式源自 20 世纪 90 年代,略显陈旧,其中三条规则很容易出错。你的编码器必须能正确处理所有这三条。

1. 按字节而非字符折叠长行。

任何超过 75 字节的行都需要被折断并在下一行继续,且下一行必须以空格开头。最直观的做法是 line.slice(0, 75),但它是按字符而非字节计数的,这可能会将双字节字符从中间切断,导致标签上留下无效的 UTF-8 序列。相反,应按码点遍历字符串,并在过程中跟踪字节开销。

2. 输出 N 字段,并坦承这是推测。

看那条 N: 行。vCard 3.0 要求将姓名拆分为 Family;Given;Additional;Prefix;Suffix(姓;名;附加名;前缀;后缀),因此仅输出 FN(全名)是不够的。将显示姓名拆解到这些部分是一种猜测,且对于中文和匈牙利语姓名、拥有两个姓氏的西班牙语姓名以及只用单名的人,这种猜测往往是错误的。我取最后一个词,记录说明这是推测,并让 FN(这才是导入工具实际显示的部分)携带完全按输入形式的姓名。

3. 转义值中所有的分号。

那条 N: 行中的分号是结构性的。如果某人的职位是“Engineer; Lagos”,直接写入会将一个字段变成两个,导入工具会将剩余部分误读为姓名的一部分。它必须以 Engineer\; Lagos 的形式到达标签。 正确处理好这三点,整个编码器就只是大约 170 行纯字符串处理代码,即使在身边没有标签的情况下也可以进行测试。

那个通过了测试的转义 Bug

第三条规则正是我最糟糕的 bug 所在。我的转义器中有这样一行代码:
.replace(/;/g, '\;')   // ← 这实际上什么都没转义

vCard 要求值中的任意分号前必须加字面量反斜杠。'\;' 看起来像是能做到这一点,但实际上并没有。JavaScript 中不存在 \; 转义序列,因此解释器会默默丢弃反斜杠,只返回一个普通的 ';'。那行代码实际上是用分号替换了分号。

正确的写法是 '\\;',其中第一个反斜杠用于转义第二个。

问题更糟的是,我为转义器写了一个测试,并断言的是未转义的输出,因为我当时还相信那行代码是有效的。测试结果与 bug 保持一致,这意味着如果代码是正确的,这个测试反而会失败。

第三个测试试图用 not.toContain('N:') 来证明某个字段不存在。这个断言永远无法通过。每个 vCard 都以 BEGIN:VCARD 开头,而 BEGIN: 恰好以 N: 结尾。

十分钟内犯了三个错误,根源都是同一个误解。

即使采用测试驱动开发也无法救我。 测试和代码共享了同一个错误假设。在字符串转义这个领域,二者经常会出现这种情况。

在写入标签之前先询问它

写入操作会替换芯片上的现有内容,因此操作顺序至关重要:

1. 查询标签:       是否可写?实际容量多大?
2. 提前拒绝:       如果只读或容量确实不足
3. 执行写入
4. 读回验证:       与发送的数据进行比对

第 2 步是关键的安全属性。 在写入拒绝可以确保标签保持原样,而在写入过程中失败可能导致标签数据被部分覆盖。

第 4 步的存在,是因为“写入报告成功但实际未生效”是最糟糕的结果。请在同一会话中读回数据,并比对记录内容而非原始字节。标签可能会合法地返回一种消息,其帧结构与你发送的不同,但携带的数据完全一致。

这四步必须在一次会话中完成,而不是分散在四次中。在 iOS 上,每次调用 requestTechnology 都会弹出一个系统确认界面,因此拆分操作意味着用户需要进行四次点击来完成一个逻辑动作。在 Android 上可能察觉不到这种差异,而这正是 iOS 特有设计看似随意、直到你在其他平台看到时才恍然大悟的典型例子。

应用写入成功后的界面。字节分解显示:NDEF 消息 27 字节,Tag 封装(TLV)3 字节,总需求 27 字节,报告容量 137 字节。下方绿色卡片标题为「已写入并验证」,内容为「已写入 27 字节,读回的数据与写入内容完全一致」,同时显示报告容量 137 字节和 NDEF 状态 2。

四个步骤在这一屏全部完成:先询问标签,拿到的是实测数据而非估算值,然后执行写入,最后读回逐字节确认无误。

检查点:git checkout step-3-write。包含 Profile 编辑器、vCard 编码,以及带容量校验的写入。

装得下吗?137 而不是 144

这个项目在这一点上让我学到了东西。

NTAG213 的用户存储空间是 144 字节。每份规格书都是这么写的:36 页、每页 4 字节、第 4 到第 39 页。我的容量校验就是围绕这个数字构建的。

一份真实的 vCard(姓名、职位、公司、电话、邮箱、一个链接)文本有 184 字节,封装成 NDEF 消息后是 202 字节——也就是前几节切换开关上显示的那个数字。

也就是说,普通名片塞不进普通标签。这是真实的产品限制,不是 bug,应用必须明确告知用户,而不是莫名其妙地失败。

然而我的数字是错的。当我最终向真实标签询问容量时,它回答 137

144 是芯片的用户存储。对写入者真正有意义的数字,是 NDEF 消息的最大长度,它比用户存储小,因为要扣除标签自身的管理开销。我此前多算了 7 字节,而且恰好是更危险的方向:会让别人以为名片装得下,实际却装不下。

这个错误背后还藏着第二个错误。标签不会原样存储你的消息,而是会额外加上几个字节的管理信息,也就是 TLV 封装(type、length、value)。我之前既把这些字节加进了消息体积,又拿总数去和用户存储比较——重复计算了。实际上所有涉及的容量数字(Android 的 getMaxSize()、iOS 的状态查询,以及合理的估算值)本身就已经是扣除封装后的消息容量了。

更大的错误

比数字本身更糟糕的,是我对这个平台得出的错误结论。

由于 iOS 的 getTag() 只返回 { id, tech },我推断出 iOS 无法报告标签容量。我把这个结论写进了代码、平台对比文档以及应用界面,并围绕补齐这一短板规划了原生模块。最终交付的代码如下:

应用中的红色错误卡片,内容为“太大,此标签放不下。假设 NTAG213 容量为 144 字节,而当前需 184 字节,超出 40 字节。请缩短配置文件或改为写入 URL。”下方斜体注明:“iOS 不报告标签容量,故此处假设 NTAG213。更大容量的标签可容纳更多数据。”

那行斜体文字是一个错误的结论,却被当作事实呈现给用户。上方卡片里的每一个数字都建立在这个假设之上。

这个结论是错的。ndefHandler.getNdefStatus() 对应 CoreNFC 的 queryNDEFStatus,它能同时返回读写状态和真实容量,就在我已经打开的那个会话里。这一能力离那段运行了数周的代码仅一步之遥。

看看这个错误长什么样:

  • 我观察到的:getTag() 函数返回两个字段,没有 size。这是事实。

  • 我推断的:iOS 无法告知标签能容纳多少字节。这推不出这个结论。

一个函数没有回答某个问题,并不代表平台无法回答。我试了一扇门,发现锁着,就断定整栋楼进不去。

在这个项目中,我的原则是:任何内容在真机上验证之前,都不能被记录为事实。我遵循了这条原则来处理我测量的数据,却忘记了对基于测量得出的结论同样适用。

未经核实的结论和未经核实的数字一样危险,甚至更难发现,因为它借用了下方真实测量数据的可信度。

因此,应用现在会询问标签,只有在标签尚未应答时才进行假设。当它做假设时,每次都会明确说明:

此标签容量不足
需 202 字节,假设 NTAG213 容量为 137 字节。超出 65 字节。
请缩短配置文件或改为写入链接。

标签尚未报告尺寸,故此处假设 NTAG213。
写入数据时,标签会报告其真实容量。

每当数值是假设得出时,"assuming"(假设)一词就会出现。这种设计上的刻意重复是为了达到一个目的:一旦应用停止强调这是假设,用户就会开始相信这是实际测量值。

以下是标签响应后同一张卡片的变化:

一个红色错误卡片,显示"Too big for this tag. 202 of the 137 bytes this tag reports. It is 65bytes over, shorten the profile or write a link instead."(对于此标签过大。该标签报告的容量为 137 字节,但消息需 202 字节。超出 65 字节,请缩短配置文件或改为写入链接。)

用"此标签报告"(This tag reports)代替"假设 NTAG213 的"(assuming an NTAG213's)。相同的卡片,相同的布局,改变的那个词恰恰指明了这个数字是来自芯片还是来自我的假设。

让问题更难发现的 Bug

我的预检逻辑在消息无法容纳时,抛出了库自身的 TagSizeTooSmall 错误。因此,当写入失败时,报错内容是 TagSizeTooSmall,这与 CoreNFC 如果拒绝了写入会产生的一模一样。

应用的写入界面。字节分解显示 NDEF 消息 202 字节,标签帧(TLV)3 字节,总共需要 205 字节,假设容量 144 字节。下方是一个标题为

那个开发者面板里有两点关键信息。NfcError.TagSizeTooSmall 是库的类,由我在咨询标签之前的代码抛出。而 message: "" (empty) 是背后的缺陷:类名承载了全部含义,而本应显示在界面上的字符串却什么也没承载。

我的拒绝和标签的拒绝变成了同一个字符串。我试图回答的那个问题(平台是否报告了容量?)恰恰被这个错误抹去了。

永远不要在你的逻辑中抛出依赖项的错误类型。 这会将"我们拒绝了"和"他们拒绝了”合并为一个信号,而当你需要区分它们以排查故障时,你会后悔这样做。

一旦使用专门携带标签自身数值的错误类型,答案便一目了然:

WritePreflightError: too-big
tag reported status 2, 137 bytes
needed 202 bytes

就在那里。平台一直都在报告容量。

同一屏幕,同一芯片,修复前后对比:

修复前应用的 Tag Info 界面。Capacity 显示"Not reported",附解释"CoreNFC does not expose tag capacity",并注明后续阶段将改为从标签的 capability container 中读取。 应用的 Tag Info 界面。Capacity 显示 137 字节,标注"Reported by the tag itself"。上方 NDEF type 显示"Not reported",注明"CoreNFC does not report the NDEF type name"。下方 Records 1、Writable Yes,芯片识别为 NTAG21x / MIFARE Ultralight,7 字节 UID。

第一张截图中 Capacity 下的每个字都是我写的,而那句言之凿凿的话是错的。它下面那行蓝字更糟:一整个原生子阶段的排期,就为了填一个根本不存在的坑。

第二张截图显示了一个具体数字,标注着由标签自身报告,期间我没有对平台层做任何改动。但请注意什么没有变:"NDEF type" 依然是 "Not reported",因为那一项确实拿不到。平台答不上来的一个问题,就悬在它一直能回答的问题正上方——这也正是我被误导这么久的原因。

一个没有标签就无法结束的专注会话

电子名片是个不错的演示,但它只发挥了 NFC 标签一半的用途。另一半是把芯片当作一种物理条件:现实世界里必须先满足这件事,软件才会执行某个操作。

所以应用有了第二个功能:一个不走到标签前就无法停止的专注计时器。

你把芯片放在麻烦的地方:楼下、抽屉里、柜子深处。轻触它开始会话;想结束会话,就得再走回它那里。

这个点子不是我发明的。Foqos(开源,已上架 App Store)、TapBlok、nfcGuard 和 Focusaur 都殊途同归,而它们之所以趋同,是因为这个洞察其实和 NFC 无关:

真正的阻力是距离,不是动作。轻触一下只要一秒钟,真正让你付出代价的是标签在楼下。标签的位置才是产品本身,NFC 只是让位置变得可执行的载体。

下面是三条规则,每条都只是一行代码。

1. 一个标签,两种含义。

同一个动作,在空闲时开启会话,在专注时结束会话。如果用两个标签,就要维护两个对象、养成两个习惯。所有决策都收敛在一个纯函数里:

export function verdictForTap(
  scannedTagId: string,
  boundTagId: string | null,
  isFocused: boolean
): TapVerdict {
  if (!boundTagId) return { action: 'bind' };

  if (normaliseTagId(scannedTagId) !== normaliseTagId(boundTagId)) {
    return { action: 'wrong-tag', expected: boundTagId };
  }

  return isFocused ? { action: 'end' } : { action: 'start' };
}

那个 wrong-tag 分支是整个规则的核心。如果接受任意标签,仪式就会从“去标签所在的地方”变成“拥有贴纸就行”。

标签 ID 的标准化比看上去更重要。同一个物理芯片,经由一条读取路径报告为 04C4FC91DF2A8104:c4:fc:91:df:2a:81。把它们当成两个不同标签,会引发令人抓狂的 bug:正确的芯片就在你手里,却被静默拒绝了。

The app's Focus tab during a session. A large counter reads "5s focused" above a green "Tap tag tofinish" button, with a quieter underlined link below reading "I cannot reach mytag".

绿色按钮本身不结束任何东西。它启动一次扫描,而扫描这个动作才结束会话。按钮下方故意设计得更安静,为“够不到标签”的场景留了出口。

2. 计时器只正计,不倒计。

倒计时会让人躺在沙发上硬等到结束,因为不管做没做事,会话都会按时结束。正计时才测量真实发生的事。

3. 破碎的会话是记录,不是阻止。

而真正有趣的设计从这里开始。

它实际能强制什么

它拦不住 TikTok。要在 iOS 上真正阻止其他应用,需要 com.apple.developer.family-controls 这个 特权 entitlement。Apple 会逐个审核,只授予核心用途是数字健康或家长控制的应用,即便在 TestFlight 上也必须有。Foqos 拥有该权限。如果你照本手册做,大概率没有,而承诺拥有会是无法兑现的承诺。

因此,它强制执行的唯一一件事就是记录本身

设计者留了一个逃生口,因为一款没有退路的承诺工具,第一次让你在机场卡住时你八成就会把它卸载掉。使用这个逃生口会留下永久的、显眼的痕迹:本次会话将被标记为“提前结束”。这个标记永久保留,每次打开该标签页你都会看到它。

历史记录才是那个保险箱,不是笔记或凭证,而是记录本身,因为在这款应用里,它才是唯一需要防御用户自己破坏的数据。这种保护机制是非对称的:

读取记录 随时可读。查看记录本身就是目的
新增记录 必须完整经历一次会话
清空记录 需要 NFC 标签

如果你能在晚上 11 点窝在沙发上随手抹掉记录,那这记录就毫无意义。删除记录的代价与积累记录的代价完全一致:那趟得走完的路。

The Focus tab between sessions. Three figures across the top read 1m Focused, 2 Streak, 0 Endedearly. Below, a list headed "The record" holds two entries, "4s focused" and "1m focused", bothdated 13 Sep, above a collapsed control labelled "Clear therecord".

“提前结束”是一个固定列,无论你有没有这类记录。这正是设计意图所在。它常驻在连续记录旁边,这样你在动用逃生口之前,就能提前看到它的代价。而底部的“清空记录”是一个默认折叠的控制项:应用里唯一要求出示 NFC 标签的操作。

接下来是两个影响深远的小决策。会话在应用重启后仍然保留,因为它被持久化了,所以强制退出应用不算一种“安静离场”。唯一的两种退出方式是:出示标签,或者使用逃生口,且后者会留下痕迹。

另外,中断的会话也计入总专注时长,因为在你分心之前,你确实处于专注状态。将其清零是惩罚而非精确统计。

相比之下,连续记录(Streak)的设计刻意严格:一次中断的会话会让它重置为零。一个永远不会失去的数字,根本不值得被关注。

编译器捕获的两个 React 缺陷

这两个都是 React 的通用问题,而不是 NFC 特有的,而且都不是我发现的。React Compiler 通过 eslint-plugin-react-hooks 提供了一批 lint 规则,用来标记它无法安全优化的代码,这里就触发了其中两条。就算你不引入编译器,也能用上这些规则。

const [now, setNow] = useState(Date.now()); // ✗ impure during render

这是 purity 规则,文档里恰好点名了这种情况:Date.now()Math.random()crypto.randomUUID() 一起被列为"相同输入却返回不同值"的 API。在渲染期间读取时钟,会让组件每次输出都不一样。

显而易见的修复方式(在 effect 开头设置状态)又会触发另一条规则。set-state-in-effect 说得很直白:"在 effect 里立即设置状态会迫使 React 重启整个渲染流程",产生"一次本可避免的额外渲染"。

purity 文档自己给出的修复方案是惰性初始化,useState(() => Date.now())——如果时钟在组件挂载时就开始走,这确实是正确答案。但这个计时器不是在挂载时启动的,而是在会话开始时才启动,所以挂载时取的时间戳到那时早就过期了,elapsed 会有一帧渲染出负数。

0 开始既满足纯度要求,又是一个可用的哨兵值:falsy 表示"还没有 tick",于是标签什么都不显示,而不是显示乱七八糟的数字。

既正确又干净的版本是推迟一个任务再执行:

useEffect(() => {
  if (!session) return;

  // 推迟而不是直接调用:在 effect 里同步 setState 会级联出
  // 第二次渲染。零延迟的 timeout 把它交给下一个任务,
  // 更新同样及时,却没有级联。
  const first = setTimeout(() => setNow(Date.now()), 0);
  const id = setInterval(() => setNow(Date.now()), 1000);

  return () => {
    clearTimeout(first);
    clearInterval(id);
  };
}, [session]);

为什么你的 NFC 锁并不安全

聚焦功能使用标签的标识符作为关键。大多数 NFC「锁定」应用也是如此。有必要直白地说明它的本质和局限。

标签的 UID 任何人可读,复制起来极其简单。它不是秘密,而是序列号,只要有人询问,它就会被广播出去。

这也正是 Apple 自家 Shortcuts 自动化所依赖的机制,正如文章第一部分所述。这恰恰说明了平台自身如何评估这种保证的强度:它只适合「打开我的台灯」这类场景,绝不会作为身份凭证。一台 20 英镑的设备几秒钟就能克隆它,手机甚至能直接模拟其中一部分。一个建立在「这个 UID 对吗?」之上的门禁系统,不过是表演罢了。

对于专注计时器来说,这完全没问题,值得强调:这里的威胁模型是:在你自己家里,懒于行动的你。克隆自己的标签来省下一次散步,这种投入程度本身就违背了锻炼的初衷。摩擦力才是产品核心价值,安全性不是。

但如果你打算把标签用作真正的凭证(如门禁、支付或设备配对),UID 就是错误的原语,你需要一块支持密码学功能的芯片。

NTAG424 DNA 是常见的解决方案。它不再呈现静态数字,而是基于每次触碰时递增的计数器,使用永不出芯片的密钥计算消息认证码。每次触碰都会产生一个不同的签名值,因此被抓包的签名一秒后就失效了。这就是标识符与认证器的区别。

所以,你需要记住的核心点是:UID 只能回答「这是哪块标签?」,别无其他。如果你的安全机制依赖于答案无法伪造,那就需要一块能签名的芯片,而不是一块只负责宣告身份的芯片。

四个耗费我整个晚上的坑

这些问题乍看像是代码里的 bug,实际上都是平台的特性。

坑 1:系统弹窗消失且无错误提示

你发起扫描。弹窗短暂出现后便消失了。没有错误,没有拒绝,控制台里也什么都没打印。

这是因为 CoreNFC 会话被释放了。Swift 会在没有任何对象持有引用时立即释放对象,这就是自动引用计数(ARC)的工作方式。所以,如果你将会话保存在启动它的那个函数内的局部变量中,当函数返回时,变量消亡,会话也随之销毁。

要修复这个问题,必须将会话保留在生命周期比当前调用更长的对象上。在 Expo 模块中,可以用一个属性:

// 生命周期跟随模块,而非单次扫描。
private var readSession: Any?

这是 CoreNFC 集成中最容易犯、却没有任何错误提示的隐性问题。

坑 2:结果被会话终止事件覆盖

你成功读取了标签并 resolve 了 Promise,但 JavaScript 端收到的却是一个错误。

原因在于 成功读取也会使会话失效,因此 didInvalidateWithError 会在你的 completion handler 之后触发。如果两条路径都试图结算同一个 Promise,后执行的那个会覆盖前者的结果。

解决方法是添加守护机制,确保结算只发生一次。一把锁,一个地方:

private func settle(resolving value: [String: Any]) {
  lock.lock()
  defer { lock.unlock() }

  guard let promise else { return }   // 已结算:不做任何操作
  self.promise = nil
  promise.resolve(value)
}

面对四层嵌套的异步步骤和五条失败路径,这一点必须强制约束,而不能靠假设。

坑 3:明明有 Entitlement,却报 Missing required entitlement

你的 Entitlements 里明明有 NDEFTAG,会话依然拒绝启动。

问题出在 轮询选项(polling options)上,即你告诉 CoreNFC 在打开会话时要监听哪些射频标准。每个标准都有独立的 Entitlement 门槛。我申请了 [.iso14443, .iso15693, .iso18092],其中最后一个是 FeliCa,主要在日本使用,它额外需要 com.apple.developer.nfc.readersession.felica.systemcodes 权限。缺少该权限时,整个会话启动失败,而不仅仅是该轮询模式失效,且错误信息中绝不会出现 “FeliCa” 字样。

解决方法是只申请你能签署的权限:

pollingOption: [.iso14443, .iso15693],

.iso14443 覆盖了 NTAG 和 MIFARE,足以满足标签项目的所有需求。我当初加上第三个选项是为了“让非预期标签产生更好的错误信息”,结果反而导致会话无法启动。

这与前文提到的通配符配置文件错误是同一教训,但方向相反:那里,缺失的 Entitlement 表现为代码签名失败;这里,一个根本用不上的 Entitlement 却引发了运行时错误。

原始来源: freeCodeCamp

评论 (0)