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 标签不是:
它不是信标。 它自身没有电源,在手机靠近(几厘米内)之前不会做任何事。
默认状态下并不安全。其标识符任何人用低成本硬件就能读取和复制。后文会专门讲这一点,因为把标签用作“钥匙”是最直观的场景,也最容易做砸。
你其实早已见过它
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 会将其嵌入每个构建包中。三者必须完全匹配,否则应用无法在设备上运行。
基于此,实际的操作流程如下,每一步都至关重要:
一个付费的 Apple 开发者账号。
在 developer.apple.com 注册显式 App ID,而非通配符 App ID。
在该 App ID 上勾选读取 NFC 标签能力。
重新生成预置描述文件(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_modules 由 package.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 靠近标签以写入”,效果远好于直接复用读取时的文案。
上面提示条里的第二行文字,正是上文 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 系列的特征,这说明标签确如其声称的那样。
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 这种情况保留了原始的 tnf、type 和 payload。这样一来,即使遇到无法识别的记录,至少还能以十六进制转储的形式展示出来。那种默默丢弃未知内容的读取器,比那个告诉你“我不认识这是什么,以下是原始字节”的读取器更糟糕。
一个平台间的差异隐藏解码器内部。记录的 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,不需要服务器,在飞机上也能用。代价是体积大得多。
两种我都实现了,让用户自己选,因为数字比任何文案都更有说服力。
同样是联系信息,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 特有设计看似随意、直到你在其他平台看到时才恍然大悟的典型例子。
四个步骤在这一屏全部完成:先询问标签,拿到的是实测数据而非估算值,然后执行写入,最后读回逐字节确认无误。
检查点:
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 无法报告标签容量。我把这个结论写进了代码、平台对比文档以及应用界面,并围绕补齐这一短板规划了原生模块。最终交付的代码如下:
那行斜体文字是一个错误的结论,却被当作事实呈现给用户。上方卡片里的每一个数字都建立在这个假设之上。
这个结论是错的。ndefHandler.getNdefStatus() 对应 CoreNFC 的 queryNDEFStatus,它能同时返回读写状态和真实容量,就在我已经打开的那个会话里。这一能力离那段运行了数周的代码仅一步之遥。
看看这个错误长什么样:
我观察到的:
getTag()函数返回两个字段,没有 size。这是事实。我推断的:iOS 无法告知标签能容纳多少字节。这推不出这个结论。
一个函数没有回答某个问题,并不代表平台无法回答。我试了一扇门,发现锁着,就断定整栋楼进不去。
在这个项目中,我的原则是:任何内容在真机上验证之前,都不能被记录为事实。我遵循了这条原则来处理我测量的数据,却忘记了对基于测量得出的结论同样适用。
未经核实的结论和未经核实的数字一样危险,甚至更难发现,因为它借用了下方真实测量数据的可信度。
因此,应用现在会询问标签,只有在标签尚未应答时才进行假设。当它做假设时,每次都会明确说明:
此标签容量不足
需 202 字节,假设 NTAG213 容量为 137 字节。超出 65 字节。
请缩短配置文件或改为写入链接。
标签尚未报告尺寸,故此处假设 NTAG213。
写入数据时,标签会报告其真实容量。
每当数值是假设得出时,"assuming"(假设)一词就会出现。这种设计上的刻意重复是为了达到一个目的:一旦应用停止强调这是假设,用户就会开始相信这是实际测量值。
以下是标签响应后同一张卡片的变化:
用"此标签报告"(This tag reports)代替"假设 NTAG213 的"(assuming an NTAG213's)。相同的卡片,相同的布局,改变的那个词恰恰指明了这个数字是来自芯片还是来自我的假设。
让问题更难发现的 Bug
我的预检逻辑在消息无法容纳时,抛出了库自身的 TagSizeTooSmall 错误。因此,当写入失败时,报错内容是 TagSizeTooSmall,这与 CoreNFC 如果拒绝了写入会产生的一模一样。
那个开发者面板里有两点关键信息。NfcError.TagSizeTooSmall 是库的类,由我在咨询标签之前的代码抛出。而 message: "" (empty) 是背后的缺陷:类名承载了全部含义,而本应显示在界面上的字符串却什么也没承载。
我的拒绝和标签的拒绝变成了同一个字符串。我试图回答的那个问题(平台是否报告了容量?)恰恰被这个错误抹去了。
永远不要在你的逻辑中抛出依赖项的错误类型。 这会将"我们拒绝了"和"他们拒绝了”合并为一个信号,而当你需要区分它们以排查故障时,你会后悔这样做。
一旦使用专门携带标签自身数值的错误类型,答案便一目了然:
WritePreflightError: too-big
tag reported status 2, 137 bytes
needed 202 bytes
就在那里。平台一直都在报告容量。
同一屏幕,同一芯片,修复前后对比:
第一张截图中 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:正确的芯片就在你手里,却被静默拒绝了。
绿色按钮本身不结束任何东西。它启动一次扫描,而扫描这个动作才结束会话。按钮下方故意设计得更安静,为“够不到标签”的场景留了出口。
2. 计时器只正计,不倒计。
倒计时会让人躺在沙发上硬等到结束,因为不管做没做事,会话都会按时结束。正计时才测量真实发生的事。
3. 破碎的会话是记录,不是阻止。
而真正有趣的设计从这里开始。
它实际能强制什么
它拦不住 TikTok。要在 iOS 上真正阻止其他应用,需要 com.apple.developer.family-controls 这个 特权 entitlement。Apple 会逐个审核,只授予核心用途是数字健康或家长控制的应用,即便在 TestFlight 上也必须有。Foqos 拥有该权限。如果你照本手册做,大概率没有,而承诺拥有会是无法兑现的承诺。
因此,它强制执行的唯一一件事就是记录本身。
设计者留了一个逃生口,因为一款没有退路的承诺工具,第一次让你在机场卡住时你八成就会把它卸载掉。使用这个逃生口会留下永久的、显眼的痕迹:本次会话将被标记为“提前结束”。这个标记永久保留,每次打开该标签页你都会看到它。
而历史记录才是那个保险箱,不是笔记或凭证,而是记录本身,因为在这款应用里,它才是唯一需要防御用户自己破坏的数据。这种保护机制是非对称的:
| 读取记录 | 随时可读。查看记录本身就是目的 |
| 新增记录 | 必须完整经历一次会话 |
| 清空记录 | 需要 NFC 标签 |
如果你能在晚上 11 点窝在沙发上随手抹掉记录,那这记录就毫无意义。删除记录的代价与积累记录的代价完全一致:那趟得走完的路。
“提前结束”是一个固定列,无论你有没有这类记录。这正是设计意图所在。它常驻在连续记录旁边,这样你在动用逃生口之前,就能提前看到它的代价。而底部的“清空记录”是一个默认折叠的控制项:应用里唯一要求出示 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 里明明有 NDEF 和 TAG,会话依然拒绝启动。
问题出在 轮询选项(polling options)上,即你告诉 CoreNFC 在打开会话时要监听哪些射频标准。每个标准都有独立的 Entitlement 门槛。我申请了 [.iso14443, .iso15693, .iso18092],其中最后一个是 FeliCa,主要在日本使用,它额外需要 com.apple.developer.nfc.readersession.felica.systemcodes 权限。缺少该权限时,整个会话启动失败,而不仅仅是该轮询模式失效,且错误信息中绝不会出现 “FeliCa” 字样。
解决方法是只申请你能签署的权限:
pollingOption: [.iso14443, .iso15693],
.iso14443 覆盖了 NTAG 和 MIFARE,足以满足标签项目的所有需求。我当初加上第三个选项是为了“让非预期标签产生更好的错误信息”,结果反而导致会话无法启动。
这与前文提到的通配符配置文件错误是同一教训,但方向相反:那里,缺失的 Entitlement 表现为代码签名失败;这里,一个根本用不上的 Entitlement 却引发了运行时错误。
