把 iPhone 靠近贴纸时,就会触发某些动作:名片会自动保存到通讯录,专注时段结束,或门被打开。芯片成本约二十便士,可容纳约一百三十字节。
读取一次需要两次函数调用。获得进行这两次调用的权限需要更长的时间。随后,CoreNFC 会要求你再次获得该权限。
第一道关卡来自 Apple。需要付费的开发者账号、在网页门户注册的 App ID、在其上勾选的能力、以及重新生成的配置描述文件。只要其中任一项出错,编译就会因代码签名错误而失败,而该错误信息根本不会提到 “NFC”。
第二道关卡是 CoreNFC 自带的,而且这是几乎没有人会提前警告你的。它是一个基于会话的 API,包含 delegate 回调、四层嵌套的异步步骤,以及一套会悄悄惩罚你的规则。
如果把会话保存在错误的变量中,系统表单会直接消失,且不会报任何错误。让一次成功的读取两次完成你的 promise,结果会被替换为失败。如果请求的轮询选项过多,整个会话将拒绝启动,而不会指出是哪个选项造成的。
在本手册中,你将克服这两道关卡。你将手动编写一个 NDEF 解码器——听起来像是过度设计,直到你看到流行库的实现实际上对表情符号做了什么。你将向标签写入数据,发现你的名片放不下。你将构建一个专注计时器,除非走到另一个房间否则无法停止。你将彻底删除 NFC 依赖,并用 Swift 原生模块替换它。最后,你将永久锁定一个标签——这是本书中唯一不可逆转的操作。
在这次构建过程中,我得出的两个结论后来被证明是错误的,而且它们仍然存在于仓库中,标有“已被取代”的横幅。它们也出现在这里,因为我弄错它们的过程比取代它们的内容更具参考价值。
这是一本 iOS 手册。主章节中的所有内容均在真实 iPhone 上构建、运行并验证。Android 有自己的手册;如果你等不及,文末提供了一个预览:这里所有内容的 Kotlin 对应版本,并附有使两个平台值得比较的架构差异。
下面的所有内容均来源于同一个项目 TapCard,该项目托管在 GitHub 上并带有标记的检查点,你可以随时检出对应阶段的应用并运行它。
目录
先决条件
要跟随学习,您需要:
一部实体 iPhone,型号 7 或更新。Core NFC 仅适用于 iPhone。Apple 的文档列出了 iPhone 7 及以后机型,且一位 Apple 工程师在开发者论坛直接说明:“目前,CoreNFC 功能仅在具备 NFC 能力的 iPhone 上可用。” iPad 不支持此功能。
付费的 Apple 开发者账户。 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 消息是一系列 record,每条 record 包含类型和有效载荷。
本手册中的标签是 NTAG213,也就是你在线购买的贴纸所具备的类型。它们拥有 144 字节的用户存储空间。这 不是 你实际可使用的数量,结果发现这一点至关重要。
标签不具备的两点是:
它不是一个信标。 它自身没有电源,并且在手机靠近几厘米之前不会有任何作用。
它默认不安全。 其标识符可以被任何人读取,且可用廉价硬件复制。后文会有专门章节讨论这一点,因为标签作为钥匙的显而易见的用途正是最容易出错的地方。
你已经见过的 NFC 应用
NFC 已经在你的口袋里,区分你能构建的功能和不能构建的功能是很有价值的,因为对用户来说它们看起来完全一样。
以下是五个可供查看的使用案例,每个均由提供该功能的公司文档。
| 出现场景 | 发生的事情 | 参考来源 |
|---|---|---|
| 在自己手机上的快捷指令 | 扫描标签来启动个人自动化 | 在快捷指令中设置触发器 |
| 丢失的 AirTag | 任何人用 NFC 手机轻触它,页面会弹出所有者信息 | 在查找我的中将物品标记为已丢失 |
| Nintendo amiibo | 将人偶轻触控制器;有些游戏会读取它,有些游戏会把你的角色写回到它上面 | amiibo 常见问题 |
| 英国签证申请 | “UK Immigration: ID Check” 应用会读取您护照内部的芯片 | 使用该应用 |
| 在 iPhone 上使用 Tap to Pay | 商店无需任何终端即可接受非接触式卡片支付 | 在 iPhone 上使用 Tap to Pay |
前三个案例正是本手册所构建的内容。一个芯片存放几个字节,读取器读取这些数据,软件据此执行操作。amiibo 是其中最完整的例子,因为有些游戏也会写入人偶,这正是您即将构建的后半部分。
第一行的这一点值得停下来思考,因为它以后会再次出现。苹果内置于每部 iPhone 的 NFC 自动化功能对您扫描的标签是这样说明的:
“除了唯一标识符外,NFC 标签的其他内容会被忽略。”
苹果的此功能会忽略本手册教您编写的所有内容,仅依赖序列号。后文的章节将说明这到底能给您带来什么,又不能带来什么。
接下来我们来看看该表格的两个注意事项。护照行使用 NFC,但它不是 NDEF 格式:生物识别芯片通过相同的无线电使用 ISO 7816 协议,借助 NFCISO7816Tag 和另一项权限,并在其上加载了自己的加密机制。天线相同,但所属领域不同,这超出了本文的讨论范围。此外,表格的最后一行是您无法构建的部分,也就是本节的剩余内容。
除了这五个用例之外,这种模式在任何需要实体物品向手机发送一句话的场景中都会重复出现,比如打开展览页面的博物馆标签、会议胸牌、瓶子上的产品认证封条、交通海报、餐厅桌面标记,以及人们贴在桌子上以启动例程的小贴纸。
在 iOS 上你无法构建的另一半是:你的手机 假装成 一张卡。比如 Apple Pay、Google Wallet 中的你的银行卡,或 Apple Wallet 中的酒店房卡。这些功能依赖于 Secure Element,一个独立的防篡改芯片,用于存储卡片凭证并自行响应终端。在 iOS 上根本没有第三方 API 可用。不是受限:而是完全缺失。后文会详细说明这一点有多封闭。
所以当有人提到 “NFC” 时,他们可能指的是这两种情况中的任意一种。本手册讲的是你实际上可以编写代码的那一半,并将精确解释另一半为何被封锁。
为什么没有模拟器路径
不要跳过这一节,因为它会改变你的工作方式。
NFC 在 iOS 模拟器或 Android 模拟器上根本不存在。 它既没有部分支持,也没有被某个开关隐藏。硬件根本不存在,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。它们只是普通函数:输入值,输出值。
这样编写后,你可以在笔记本电脑上进行测试,房间里甚至不需要手机。在这个项目中,这相当于约 2,240 行 TypeScript 代码以及 293 个在约一秒内完成的测试。实际调用 CoreNFC 的代码我尽量保持精简。
该项目因硬件问题三次停滞,其中包括等待邮寄的两周。每次仍有大量工作可做,因为大部分功能根本不需要标签。这是代码库中的最佳决策,我并非刻意如此,而是约束条件促使我这样做。
检查点:
git checkout step-0-scaffold。该应用在两个平台上均可启动,除此之外不做其他事情。
iOS 合约是如何工作的
这里的工作全部由三个 Apple 术语完成,在进行步骤之前值得弄清楚,因为错误消息默认你已经了解它们。
App ID 是你的应用在 Apple 服务器上注册的身份,对应你配置中的 bundleIdentifier。权限(entitlement) 是应用中的一行声明,表示“我打算使用此功能”,NFC 就是其中之一。配置文件(provisioning profile) 是一份签名文件,将上述两者与你的开发者账号绑定在一起,Xcode 会将其嵌入每次构建中。三者必须保持一致,否则设备上将无法运行。
有了这些基础,下面是实际的操作序列,每一步都很重要:
付费的 Apple 开发者账户。
在 developer.apple.com 注册的 明确的 App ID,而非通配符。
在该 App ID 上勾选 NFC 标签读取。
重新生成配置文件以包含该功能。
然后,在 app.json 中:
{
"expo": {
"ios": {
"bundleIdentifier": "com.yourname.tapcard",
"infoPlist": {
"NFCReaderUsageDescription": "TapCard uses NFC to read and write your card to a tag."
},
"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 禁止在 wildcard App ID 上使用特殊功能,而当没有显式匹配时,Xcode 会悄悄回退到一个。
该消息指责你的本地配置文件,但缺失的一半在 Apple 的服务器上,在于你尚未填写的网页表单中。其中没有任何内容暗示打开浏览器。
在这里的第二个陷阱:App IDs 在所有 Apple 账户中全局唯一,而不仅仅是 per-team**.. 我的第一选择被一个陌生人占用了,我的第二选择也是如此。我两次重命名了应用的标识符。
这些重命名过程毫无痛苦,而且值得一提的是这个项目是如何设置的。Expo 的 Continuous Native Generation 意味着 ios/ 和 android/ 文件夹根本不会被保存在仓库中。它们会在你需要时,通过 expo prebuild 从 app.json 重新生成,就像 node_modules 从 package.json 重新生成一样。因此,重命名应用只需两行 JSON 和一个 prebuild --clean 命令,这将重建 iOS 项目、Android 包以及整个 Kotlin 源码树。无需进行 Xcode 手术。
就规模而言,Android 的等价做法是在清单文件中只需一行,且不需要账号、门户或费用:
这种对比并不是抱怨。尽早内化这一点很有价值,因为它能告诉你在这个平台上时间会花在哪里:不是在代码上(代码很短),而是在与之相关的文书工作中。
| Android | iOS | |
|---|---|---|
| 读取一个标签 | 一个清单行 | App ID + 功能 + 付费账户 + 配置文件 |
| 费用 | 免费 | $99/yr,或当地等价费用 |
| 故障模式 | 权限缺失 | 一个永不会提及 "NFC" 的代码签名错误 |
如何读取您的第一个标签
读取代码很短。以下是全部内容:
import NfcManager, { NfcTech, type TagEvent } from 'react-native-nfc-manager';
export async function readTagOnce(): Promise {
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,后者会绘制一个系统模态表单。你无法重新设计它。你的应用此时不在屏幕上。
在 Android 上,根本没有任何绘制。调度保持沉默。如果你的应用没有告诉用户去点击标签,那就没有人会这么做。
因此,扫描界面是条件性的,这种模式在整个项目中反复出现:
{
scanning && (
{Platform.OS === 'android'
? 'Hold a tag against the back of the phone.'
: 'Waiting for the system NFC sheet…'}
{/* Android draws no system UI, so the app must offer its own way out. */}
{Platform.OS === 'android' && }
);
}
该取消按钮仅在 Android 上存在,因为 iOS 的系统表单本身已经有一个。
会让你措手不及的三件事
1. 天线位置不同
在 iPhone 上:顶部边缘,靠近摄像头。在 Android 上:背面中部。"将标签靠近手机" 这一提示缺乏可操作性,若使用错误的一端,用户会觉得你的应用出了故障。我最终在每个平台上画了一个标注正确位置的小手机轮廓。
2. 表单并未显示你认为的那个字符串
我原本以为它会显示 app.json 中的 NFCReaderUsageDescription。其实不然。它显示的是每次 requestTechnology() 调用所传入的 alertMessage。使用说明是一个隐私清单字符串:它是必需的,没有它就不会启动会话,且永不会向用户展示。
两个字符串配置正确且拼写无误,我猜错哪个字符串会出现。我是通过拿起手机才发现这一点。这也意味着文案 可以随每次扫描而不同。"将你的 iPhone 靠近标签以写入" 比重复使用读取的文案更好。
第二行正是上面 requestTechnology 调用传入的 alertMessage,逐字相同。app.json 中的使用说明在屏幕上根本不可见。
3. 在 iOS 上,空白标签会被读作错误
全新的 NTAG213 已格式化为 NDEF 但内容为空,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。已配置权限,屏幕上显示原始 NDEF 转储。
NDEF 记录实际上是什么
标签并不存储 URL。它存储的是字节,而你的应用得到的正是这些字节。本节内容讲的是如何将这些字节转换为 https://example.com 并再转回去,这实际上比其名声所暗示的要小得多。
你所使用的单位是 记录,而一个记录由三部分组成:
payload: 内容的原始字节
type: 该载荷是什么,例如 URI 或某段文本
TNF,即类型名称格式:三位比特,说明如何读取类型字段本身
这就是人们容易出错的第三项。可以把 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%。
文本记录
文本需要 URI 不具备的两项信息:语言是什么以及编码方式。这两项信息都被压缩到了第一个字节。
02 65 6e 48 69
│ └─┬─┘ └─┬─┘
│ "en" "Hi"
└─ status byte
将第一个字节读作一个数字。 这里是 2,这也是语言代码的长度。于是接下来的两个字节就是语言:65 6e,即 "en"。其余部分是文本:48 69,即 "Hi"。
编码藏在同一个字节里。如果它的值是 128 或更大,则文本是 UTF-16 而不是 UTF-8。这就是所谓的“状态字节”所表示的全部意思。
这就是完整的格式。如果你自己编写解码器,则屏蔽低六位以获取长度,并测试最高位以确定编码。
我为何编写自己的解码器
你会绕过库有两种原因。要么它不能提供你所需的全部功能,要么你所在的地方更倾向于先在内部构建这类东西,再考虑使用依赖。
第二种情况实际上比开源默认情况下听起来的更常见,而对于 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 位,而表情符号无法容纳在 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)本身没问题,于是错误一直隐藏,直到有人使用表情符号才会暴露。
与此同时,它的 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[] };
为什么使用形状的联合,而不是一个带有许多可选字段的对象? 因为使用可选字段时,每个屏幕都必须猜测哪些字段被填入。在这里,你只需检查一次 kind,TypeScript 就能知道其余信息。在 kind 为 'text' 的记录中,.uri 不存在。于是,尝试读取它的界面会在编译时失败,而不是在用户手机上渲染出 undefined。
unknown 情况仍然携带原始的 tnf、type 和 payload,因此任何无法识别的记录至少可以以十六进制转储的形式展示。一个静默丢弃不理解内容的读者,不如一个说“我不知道这是什么,这里是原始字节”的读者。
平台之间的差异隐藏在解码器内部。记录的 type 字段在 Android 上以原始字节形式到达,而在 iOS 上有时已经是解码好的字符串。因此,同样的 URI 记录在一个平台上表现为 [85],在另一个平台上表现为 'U',其中 85 是字母 U 的字节值。
这一点很重要,因为 [85] === 'U' 仅仅是 false。没有错误也没有警告:你的比较会悄然失败,导致一个完全有效的 URI 记录落入“未知记录”分支。在两个平台上,比较之前请先将 type 转换为字符串。
测试与被替换的内容对抗
这是我最希望人们能够借鉴的技术。测试分为三组。
正确性:将解码器与手工构建的有效载荷进行对比。
一致性:在库是 正确 的情况下,证明我们的结果与其一致。库编码的十二个真实 URI 以及我们解码的结果,再加上全部 36 个前缀索引。这是一种廉价的方式,以确保你手动输入的查找表是可靠的。
偏离 是其中奇怪的一种。当库出错时,编写一个测试来锁定它到底错在哪里:
✓ the library discards the language code; we keep it
✓ the library truncates 4-byte UTF-8; we do not
✓ both handle 3-byte sequences, so only characters above U+FFFF break
✓ the library ignores the UTF-16 flag; we honour it
这看起来是倒退的。你编写的测试之所以能通过,正是因为依赖出现了故障,而这通常是一种异味。
有两件事让它们赢得了位置。每一件都是你自己代码存在原因的书面记录,它位于代码旁边,而不是藏在没人阅读的提交信息里。而且因为它们断言了库的当前行为,它们会在上游有人修复它的那天开始失败。
这种失败正是重点。它不是一个坏掉的测试,而是一种通知:你编写自解码器的原因可能已经不复存在,你可以去检查。如果说“库有 bug”的注释,会在库周围变化时悄然腐烂。而测试则不会。
一个细节决定这些测试是有用还是无用。表情符号测试断言的是 确切的 错误答案,即代码点 0xf600,而不仅仅是说“库与我们的观点不一致”。
如果它仅仅检查是否存在分歧,那么未来版本中出现 不同 的 bug 仍会通过,而你永远不会察觉行为已经改变。锁定特定的错误值意味着任何变化都会被发现,无论上游是修复了 bug 还是用一个新的 bug 替换了它。
检查点:
git checkout step-2-decode。 自解码器,一个显示每个字段和原始字节的 Tag Info 界面。
如何编写标签
现在谈另一方向。有两种选择,它们是相反的权衡,而不仅仅是变化。
URL 记录 它非常小巧,每部手机在未安装任何应用的情况下都能打开它。这是大多数商业 NFC 名片所做的。它还需要另一端有某些东西:一个保持续期的域名、一个保持运行的服务器,或者在有人轻触时的网络连接。
vCard 它是完整的名片。text/vcard 作为 MIME 记录,无需服务器,可在飞机上使用。它也要大得多。
我构建了两者,并让用户选择,因为数据说明比文字更有说服力。

十七字节对比一百八十四字节,用于同一个人的联系信息。这个差距就是全部的权衡,也是为什么选择权摆在用户面前,而不是替他们做决定。
如何编码 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
每行包含一个字段:名称、冒号、值,以及行末的回车和换行符。把这段字符串拼好,你就有了一张可用的名片。
这种格式源自上世纪九十年代,这一点显而易见,而其中有三条规则很容易出错。你的编码器必须正确处理这三条规则。
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 行纯字符串处理代码,无需依赖任何标签即可进行测试。
通过测试却仍存在的转义漏洞
正是这第三条规则埋藏了我最严重的漏洞。我的转义函数包含以下这一行:
.replace(/;/g, '\;') // ← this escapes nothing
vCard 需要在值内的任何分号前加上一个实际的反斜杠。'\;' 看起来像是产生了一个。它实际上没有。JavaScript 没有 \; 转义序列,因此它会悄悄丢掉反斜杠,并返回一个普通的 ';'。那行代码实际上只是把每个分号替换为它自身。
修复方法是使用 '\\;',其中第一个反斜杠转义第二个。
事情变得更糟。我为转义函数编写了一个测试,并断言了 未转义 的输出,因为我仍然相信那行代码有效。测试结果与 bug 一致,这意味着如果面对正确的代码,该测试会失败。
然后第三个测试试图用 not.toContain('N:') 证明某个字段不存在。这永远不可能通过。每个 vCard 都以 BEGIN:VCARD 开头,而 BEGIN: 以 N: 结尾。
十分钟内犯了三个错误,都是同一个误解。
先写测试也救不了我。 测试和代码共享了同样的假设。字符串转义是一个他们通常会这样做的领域。
在写入之前先询问标签
写入操作会替换芯片上的内容。顺序很重要:
1. Query the tag: is it writable, how big is it really?
2. Refuse early: read-only, or genuinely too small
3. Write
4. Read it back: compare with what you sent
步骤 2 是安全属性。 拒绝 在写入之前发生会让标签保持未修改,而写入 过程中发生的失败可能导致标签只写了一半。
步骤 4 存在的原因是,声称成功但实际上未发生的写入是最坏的结果。在同一会话内读取并比较 记录内容,而非原始字节。标签合法地返回的消息帧可能与你发送的不同,但携带的数据完全相同。
所有四个步骤应在 同一会话 中完成,而不是分成四次。在 iOS 上,每次调用 requestTechnology 会在用户面前弹出一个系统表单,若将它们拆分,则一次逻辑操作会产生四张表单和四次点击。Android 不会察觉这种差异,这正是导致 iOS 风格的设计看起来任意的原因——直到你在另一个平台上看到它的表现。

你在同一屏幕上完成了所有四个步骤:询问标签,得到的数字是实际测量而非假设的,写入操作已发出,并且读回验证逐字节匹配。
检查点:
git checkout step-3-write。配置文件编辑器,vCard 编码,带容量检查的写入。
会装得下吗?137,而非 144
这就是项目教会我的地方。
NTAG213 具有 144 字节 的用户内存。这个数字出现在每份规格书中:36 页,每页 4 字节,页码从 4 到 39。我以此为基础构建了容量检查。
一个现实的 vCard(姓名、职称、公司、电话、邮箱、一个链接)的纯文本大小为 184 字节,封装为 NDEF 消息后达到 202 字节,这正是前面几节切换开关所示的数字。
因此,普通名片 无法放置在普通标签上。这是真实的产品限制,而非错误,应用必须明确说明这一点,而不是神秘地失败。
但我的数字是错的。当我终于向真实标签询问其大小时,它回答 137。
144 是芯片的 用户内存。对写入者而言,重要的是最大 NDEF 消息 大小,这比标签自身的书记开销要小。我之前的估计宽松了 七个字节,这种方向会让人误以为他们的名片能放进标签,而实际上不能。
这个错误掩盖了另一个问题。标签不会原始存储你的消息:它会用自身的几个字节书记信息(称为 TLV 帧,用于类型、长度和数值)进行包装。我曾经把这些字节既加到消息中,又与用户内存进行比较,导致重复计算。所有参与的容量数字(Android 的 getMaxSize()、iOS 的状态查询以及合理的假设)实际上已经是扣除帧开销后的消息大小。
更大的错误
比数字错误更糟的是,我对平台的结论。
因为 iOS 的 getTag() 只返回 { id, tech },我得出结论 iOS 无法报告标签容量。我在代码中、平台比较中以及应用自身的 UI 中都写了这一点,并计划开发一个原生模块来弥补这一差距。这里就是实现的结果,已发布:
那条斜体线是错误的结论,被当作事实告诉用户。卡片上方的每个数字都建立在这种被迫的猜测之上。
这是错误的。ndefHandler.getNdefStatus()(即 CoreNFC 的 queryNDEFStatus)既返回读写状态,也返回实际容量,而且是在 我已经打开的会话内部。只需一次调用,即可从已经运行数周的代码中获得。
看看这个错误的形状:
我看到的:函数
getTag()只返回两个字段,没有大小。这一点是正确的。我决定的:iOS 无法告诉你标签能容纳多少字节。这一结论并不成立。
一个函数未能回答问题,并不意味着平台无法回答。我尝试了一扇门,发现它被锁住了,于是得出结论:根本没有进入大楼的途径。
我在该项目中的准则是:未在设备上亲眼看到之前,不得将任何内容写下来当作事实。我对所测量的事物遵循了这一准则。但对根据测量得出的结论,我却忘记了它。
未经验证的结论就像未经验证的数字一样危险,而且更难被发现,因为它借用了其下方真实测量的可信度。
因此,应用现在会询问标签,仅在尚未收到标签响应时才做出假设。每次假设时,它都会明确说明:
Too big for this tag
202 bytes, assuming an NTAG213's 137. It is 65 bytes over.
Shorten the profile or write a link instead.
No tag has reported its size yet, so this assumes an NTAG213.
Tags state their real capacity when you write to them.
每当数字被假设时,单词“assuming”就会出现。这是有意设计的重复:应用一旦停止说出它,读者就会开始相信它测量了某个值。
以下是标签响应后的同一张卡片:
“此标签报告” 而不是 “假设一个 NTAG213 的”。同一张卡片,同一布局,唯一变化的词是说明该数字来自芯片还是来自我的那个词。
导致此问题难以发现的错误
我的预飞行检查在消息无法容纳时抛出了 库自身的 TagSizeTooSmall。因此,当写入失败时,错误显示为 TagSizeTooSmall,这正是 CoreNFC 在 它 拒绝写入时会产生的错误。
该开发者面板中有两个内容。NfcError.TagSizeTooSmall 是库的类,由我的代码在标签被查询之前引发。而 message: "" (empty) 是其背后的缺陷:该类承载了全部含义,而屏幕上打印的字符串则未传达任何信息。
我们的拒绝和标签的拒绝是同一个字符串。我试图回答的唯一问题(平台是否报告了容量?)正是被错误抹去的那个问题。
永远不要在自己的逻辑中抛出依赖的错误类型。 它将“我们拒绝”和“他们拒绝”合并为一个信号,而在出现问题时,你希望能够准确地区分它们。
使用携带标签自身数字的专用错误类型,答案立刻显现:
WritePreflightError: too-big
tag reported status 2, 137 bytes
needed 202 bytes
就在那里。平台一直在报告容量。
同样的屏幕,同样的芯片,前后对比:
第一张图中,“Capacity”下方的每一个字都是我写的,而那个自信的说法是错的。它下方的蓝线更糟:整个原生工作阶段被安排来填补一个实际上不存在的差距。
第二张图读取一个数字,标注为 由标签自身报告,期间平台没有添加任何东西。不过注意未变化的地方:“NDEF type”仍显示“未报告”,因为这项确实不可用。平台无法回答的问题正好摆在它一直能回答的问题上方,这正是我长期相信错误说法的原因。
没有标签就无法结束的专注会话
名片是个很好的演示,但它只能展示标签功能的一半。另一半是将芯片用作 物理条件:在软件执行某项操作之前,现实世界必须满足该条件。
于是该应用有第二个功能:一个必须走到标签前才能停止的专注计时器。
你把芯片放在一个不方便的地方。比如楼下、抽屉里、橱柜后面。轻触它开始会话。要结束会话,你必须回到它身边。
这个想法不是我的。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 分支是一切所依据的规则。接受任何标签,仪式就会变成“拥有一张贴纸”,而不是“去贴纸所在的地方”。
归一化的重要性超出表面。同一块物理芯片在一个读取路径上被报告为 04C4FC91DF2A81,而在另一个路径上被报告为 04:c4:fc:91:df:2a:81。将它们视为不同的标签是一个令人恼火的 bug:正确的芯片就在你手中,却悄然被拒绝。
这个绿色按钮并不是用来结束任何事的。它启动一次扫描,而扫描才是结束会话的关键。它下方故意设得更安静,是离开机场的出口。
2. 计时器只会递增,永不递减。
倒计时会让你在沙发上等待结束,因为无论你是否做了任何事,会话都会结束。而递增计数则衡量实际发生的事情。
3. 中断的会话会被记录,而不是被阻止。
这就是设计变得有趣的地方。
它实际上能够强制执行什么
它无法阻止 TikTok。在 iOS 上真正阻止另一个应用需要 com.apple.developer.family-controls,这是一个 特权权限,Apple 会单独审查,仅授予核心目的为数字健康或家长控制的应用,即使是 TestFlight 也需要它。Foqos 拥有该权限。如果你在遵循此手册,你很可能没有它,而承诺否则的话就是一个你无法兑现的承诺。
因此它只能强制执行它实际上能做的唯一一件事:记录。
有一个逃生口,因为一种没有出路的承诺设备,你在真正被困在机场时会第一次卸载它。使用它会留下永久可见的标记:此会话将被记录为提前结束。该标记是永久的,你每次打开此标签页时都会看到它。
而且 历史记录就是保险库,而不仅仅是笔记或凭证,而是记录本身,因为这是应用中唯一值得保护免受其自身用户侵害的东西。防护是非对称的:
| 读取记录 | 始终免费。重点在于你会查看它 |
| 添加记录 | 只有在经历一次会话时才能添加 |
| 清除记录 | 需要标签 |
你可以在沙发上晚上 11 点擦除的历史记录什么也不记录。擦除它的代价正是获得它所付出的:那次散步。

“Ended early” 是一列,无论你是否有任何记录,这一点才是关键。它永久地位于连击旁边,这样在你使用逃生舱之前就能看到其成本。而“Clear the record”是底部的折叠控件:应用中唯一一个会请求标签的操作。
这里有两个较小的决定,结果证明很重要。运行中的会话在重新启动后仍然存在,因为它被持久化,所以强制退出不是一种无声的退出方式。唯一的退出方式是标签和逃生舱,其中一种会留下痕迹。
并且 中断的会话仍然计入总专注时间,因为你 曾经 专注,直到你不再专注。将其清零会是惩罚性的而不是准确的。
相比之下,连击被设计得严格苛刻:一次中断的会话就会将其重置为零。一个不会丢失的数字不值得查看。
编译器捕获的两个 React 错误
这些都是普通的 React 错误,而不是 NFC 相关的错误,而且我也没有发现它们。React 编译器通过 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 开始既是纯净的,也可以作为哨兵使用:假值表示「尚未滴答」,于是标签会保持为空,而不是显示无意义的内容。
既正确又简洁的做法是推迟一个任务执行:
useEffect(() => {
if (!session) return;
// Deferred rather than called straight away: a synchronous setState inside
// an effect cascades a second render pass. A zero timeout hands it to the
// next task, which updates just as promptly without the cascade.
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 模块中,可以使用一个属性:
// Held for the life of the module, not the scan.
private var readSession: Any?
这是获取 CoreNFC 集成微妙错误的最简单方法,而且没有任何提示。
陷阱 2:你的结果会被会话结束覆盖
你成功读取了标签,解析了 promise,但 JavaScript 收到的是一个错误。
这是因为 成功读取也会使会话失效,因此 didInvalidateWithError 会在 你的完成处理程序之后 触发。如果两条路径都解决了同一个 promise,后者会获胜。
为了修复这个问题,请加锁以确保只执行一次(即 promise 的解决)。一个锁,一个位置:
private func settle(resolving value: [String: Any]) {
lock.lock()
defer { lock.unlock() }
guard let promise else { return } // already settled: do nothing
self.promise = nil
promise.resolve(value)
}
由于有四层嵌套的异步步骤和五条失败路径,这必须被强制执行,而不能仅仅假设。
Gotcha 3: Missing required entitlement(针对您已拥有的权利)
您的权利中包含 NDEF 和 TAG,但会话仍然无法启动。
这是因为 polling options——您在打开会话时告诉 CoreNFC 需要监听的无线电标准列表。每一项都需要对应的权利才能使用。我请求了 [.iso14443, .iso15693, .iso18092],其中最后一项是 FeliCa,这是一种主要在日本使用的标准,此外还需要 com.apple.developer.nfc.readersession.felica.systemcodes 权利。如果没有该密钥而请求它,会导致 整个会话 失败,而不仅仅是该轮询模式,而且错误信息永远不会提到 “FeliCa”。
要解决此问题,仅请求您拥有权限的内容:
pollingOption: [.iso14443, .iso15693],
.iso14443 覆盖了 NTAG 和 MIFARE,这就是标签项目所需的全部内容。我添加了第三条“意外标签会产生更好的错误信息”。它产生了一个无法启动的会话。
这是与之前的通配符配置文件错误同样的教训,只是方向相反:那里,一个 缺失 的权利表现为代码签名失败。在这里,一个我从不需要的权利表现为运行时错误。
陷阱 4:cannot find 'YourClass' in scope 对于存在的文件
您将一个 Swift 文件添加到本地 Expo 模块,构建后,编译器坚持称该类不存在。
这是因为 iOS 依赖的连接方式。CocoaPods 是构建它们的包管理器,每个都会随附一个 podspec,这是一个小文件,列出属于它的源文件。您的配置说“此文件夹中的每个 .swift 文件”,写作 **/*.swift。
陷阱在于:CocoaPods 只在 pod install 运行时展开一次该模式。 它是一个列表,而不是动态规则。您之后创建的文件根本不在 Xcode 构建中,因此编译器说的是实情。就它所知,您的类不存在。
要修复这一点,请记住规则,避免每次因而浪费十分钟:
向现有文件添加 函数 → 重新构建。添加 文件 →
pod install,然后 重新构建。
错误消息中未提及任何一点。
为什么您无法构建 Tap to Pay
这就是两个平台之间的差距不再仅仅是程度问题的地方。在向产品经理承诺任何事情之前,请务必了解这一点。
“Tap to pay” 有两种不同的含义。
首先,有 使用手机支付:Apple Pay,或 Google 钱包中的银行卡。在 iOS 上,安全元件被关闭。没有第三方 API。不受限制,也没有权利:缺失。
其次,有 在手机上接受支付:iPhone 的 Tap to Pay,或 ProximityReader 框架。这确实存在,但您仍然不能直接构建它:
一个 组织级别 开发者帐户,以 账户持有人 的身份申请
分离的 TEST 和 LIVE 权利,分别申请
Apple 根据预定义的标准进行审核,LIVE 批准可能需要 周
您必须通过经过批准的支付服务提供商进行集成:Stripe、Adyen 或 Square
现在 Android。HostApduService 让 任何应用都能模拟一张卡。 无需审批,无需权限,也不需要 PSP。实现 processCommandApdu(),注册你的 AID,即可完成。AID 是 Application Identifier(应用标识符),支付终端会询问该号码,以决定手机上哪个应用应该响应。
有一个真正的门槛:在 CATEGORY_PAYMENT 下注册的 AID 仅在你的应用是默认钱包(Android 15+ 上的 Wallet 角色持有者)或应用在前台并调用 setPreferredService 时才能工作。但 CATEGORY_OTHER(覆盖闭环卡、忠诚度、门禁控制和存储价值)是 开放且始终激活 的。
| iOS | Android | |
|---|---|---|
| 模拟卡片 | 不可能 | HostApduService,无需审批 |
| 支付 AID | 不适用 | 仅限默认钱包 |
| 非支付 AID | 不适用 | 开放 |
| 接受支付 | 权限 + PSP + 周 | 开放 |
与手册中其他所有差异不同,这既不是程度上的差异,也不是形状上的差异。有一个平台根本不提供此功能。 如果你的产品计划涉及让手机模拟一张卡,那么该计划仅适用于 Android,而且现在学习比在冲刺后学习更好。
为什么您可能需要编写自己的原生模块
到目前为止,一切都在 react-native-nfc-manager 上运行。该项目的最后三分之一替换了它,原因在过程中发生了变化。
最初的理由是容量差距:iOS 无法报告容量,因此我们需要自己的原生代码来读取它。 正如你所看到的,该论点已经不复存在。这是一种推断,而不是观察。
与其默默地保持已经失去动力的工作,不如在这里给出真实的版本:有些团队无法使用第三方依赖。 内部专用政策、审计要求,或者只是他们无法在任何有用的时间尺度内将修复合并到的一个包。“我自己该如何构建这个?”这个问题值得按照其自身的条件来回答。
至于这个依赖项,证据已经通过阅读收集完毕。
| 发现 | 位置 |
|---|---|
| 文本解码器丢弃了它刚刚测量的语言代码 | ndef-lib/ndef-text.js |
UTF-16 标志被忽略,有一个未完成的 TODO |
ndef-lib/ndef-text.js |
String.fromCharCode 在 U+FFFF 以上会被截断 |
ndef-lib/util.js |
index.d.ts 是无效的 TypeScript,仅因为打开 skipLibCheck 才能编译 |
index.d.ts |
| 包根在非原生运行时会抛出异常 | src/NativeNfcManager.js |
每个错误类都携带一个空的 message |
src/NfcError.js |
那是导致 Phase 1 出现真实 bug 的最后一个问题:扫描失败时屏幕渲染 完全没有任何内容,因为屏幕执行了 setError(err.message),而 message 是 '',React 将空字符串视为假值。只有类本身保存了该含义。
这些都无法仅通过 JavaScript 修复。
脚手架不再是难点
npx create-expo-module@latest --local --name NfcNative \
--package com.you.nfcnative -p apple android --features Function
有四个文件会被 pod install 自动加入构建过程。不需要修改 Xcode 项目、不需要使用 RCT_EXPORT_METHOD 宏,也不需要手写 JSI——即让 JavaScript 直接调用原生代码的 C++ 层。如果你上次编写 React Native 原生模块还是在桥梁时代,这就是重点。
有两个陷阱,且均未被文档记载:--name 设置 原生模块 的名称,而 目录 来源于位置路径参数(如果没有提供,则会放置在 modules/my-module)。而生成的 podspec 声明了一个 iOS 部署目标,可能会悄悄提升你的应用的最低版本。
实际上携带消息的类型化错误
internal final class NoNfcSettingsException: Exception {
override var reason: String {
"iOS has no NFC setting to open. NFC is available whenever the hardware supports it."
}
}
Expo 的 Exception 为每个异常提供了一个代码 和 一条消息,两者都可以在 JavaScript 中读取。这是对上述静默失败背后缺陷的直接修复。
令我惊讶的是:拥有原生侧并不能免除你处理错误的责任。Expo 会包装你的异常:
FunctionCallException: Calling the 'openNfcSettings' function has failed
→ Caused by: NoNfcSettingsException: iOS has no NFC setting to open. …
这时的 err.message 是框架在自述其内部管线。你写的那句话被排到了原因链的末尾。这正好是库那个问题的镜像:那边消息是空的,这边消息被埋没,但两者破坏的是同一种直觉反应。
下面是这段链在应用里呈现的样子,与 JavaScript 收到的内容完全一致:

在看到一句用户能读懂的话之前,先要经过整整三行框架术语。修复不在 Swift 里,而在界面上:遍历原因链,把最深层的消息放在最前面,其余内容折叠起来,留给真正想看的人。

同样的失败,同样的信息,一点都没丢。Swift 里写的那句话现在排在最前面,需要时点一下就能展开 RAW ERROR CHAIN。
亲自掌控原生端,会让你措手不及的层级也随之改变。
272 个通过的测试都没抓到的 Bug
把应用切换过去之后,取消扫描时屏幕上会出现一张红色的“无法读取标签”卡片,而不是什么都不显示。
我们的 Swift 端抛出的是 UserCancelledException,映射表的键也是按 UserCancelledException 设的。可两边就是对不上,因为 Expo 会自行推导错误码:去掉结尾的 Exception,拆开驼峰命名,全部转成大写,再加上 ERR_ 前缀:
UserCancelledException → ERR_USER_CANCELLED
为什么所有测试都通过了:
const wrapped = (code, message) =>
new Error(`Calling the 'readTag' function has failed → Caused by: ${code}: ${message}`);
没有 code 属性,因为我当时并不知道 Expo 会设置它。代码和它的测试共享着同一个错误假设,于是彼此印证得天衣无缝。
凭空编造的测试夹具,只能证明代码自洽。真正暴露问题,靠的是一根拇指实打实地按下了取消按钮。
修复方案保留了以 Swift 类名为键的映射表,并从中派生出 ERR_ 形式的错误码——这样事实来源只有一个,不会出现两份清单各走各路。现在有一条测试用设备返回的原样错误信息把这个回归问题钉死了,连 code 属性也一并锁定。
检查点:
git checkout step-4-own-module。此时自有模块与原库并存。
切换之前,如何证明两者对等
别凭一腔信任就切换实现。用两套实现读取同一张实体标签,再把结果做个对比。
让这套做法真正有用的设计决策是:“不同”并不是单一结果。我们的读取会额外报告容量和可写性,而库的读取路径没有这些信息——若把这种差异记作不匹配,反倒会严重误导人。
| 状态 | 含义 |
|---|---|
same |
两边都报告了,且一致 |
differs |
两边都报告了,但不一致。唯一真正糟糕的情况 |
native-only |
我们这边知道得更多。这正是切换的理由 |
library-only |
我们丢了信息。同样会阻止切换 |
neither |
无从下结论 |
library-only 会阻止切换,这一点很关键:信息丢失本身就是实打实的问题,哪怕它并不构成矛盾。
在一张真实的 NTAG213 上,结果是:四个字段完全一致,两个字段只有我们的模块能报告出来,冲突为零。切换正是凭这份证据做的决定,而不是凭“新代码看着挺对”的直觉。
配置插件一直在替你做什么
删掉这个依赖,风险最大的部分其实不在代码。
react-native-nfc-manager 自带一个配置插件,iOS 的 NFC entitlement 和 NFCReaderUsageDescription 正是由它生成的。一旦移除这个包,两者会一起消失,而应用会一声不响地失去 NFC,不报任何错误。构建时不会失败,读取时那个系统弹窗只是从此再也不会出现。
所以正确的顺序是:先自己在 app.json 里声明这两项,执行 prebuild,确认输出与插件生成的结果逐字节完全一致,然后移除插件,再验证一遍,然后才移除这个包。
移除一个依赖,就意味着要接过它的构建配置。它导出的代码,只是看得见的那一半。
删除依赖后保留证据
替换该库的理由在于其所依赖的一个已经不存在的包存在缺陷。删除它后,证明这些缺陷的测试将停止运行,一致测试也会随之消失,从而导致一个手动输入的36条查找表未得到验证。
该仓库在 vendor/ 目录下保留一个冻结的副本,采用 MIT 许可证,仅被一个测试文件导入,并且被排除在 ESLint 和 Prettier 之外,因为其价值在于以文档化的方式展示错误,而重新格式化它会破坏它所演示的内容。
✓ every error class is constructed with an empty message
✓ turns U+1F600 into U+F600, a Private Use Area character
✓ discards the language code it just measured
✓ ignores the UTF-16 flag in the status byte
如果未来版本修复了其中任何问题,这些就会失败,这表明需要重新考虑,而不是造成麻烦。
检查点:
git checkout step-5-no-dependency
如何永久锁定标签
在这整个领域中,只有一项操作无法撤销。只有在这里,交互设计出错会毁掉某些实物,这就是为什么它被放在最后。
NFCNDEFTag.writeLock 在 iOS 上,Ndef.makeReadOnly() 在 Android 上。两者都会烧毁芯片的锁定位。标签可以永久读取,但永远不能再写入:不仅不是你的应用,也不是其他任何应用或任何手机。没有撤销,也没有出厂重置。
实际部署会不断这样做。活动徽章、产品认证封印或博物馆标签:任何交给公众的物品都会被锁定,因为可重写的标签意味着任何人都可以重写。
门就是功能
原生调用只有四行代码。所有有趣的事情都发生在这之前。
在此应用中,两次点击可以防止写入,这对写入操作来说是正确的:写入是可逆的,你只需写入其他内容。在这里这显然不够。因此,门借鉴了 GitHub 用于删除仓库的模式:输入对象的名称以证明你知道自己正在删除哪个对象。
它所使用的 status 是标签自身的 NDEF 状态,与写入路径所请求的值相同:既不是 NDEF,也不是可读写,或者已经是只读。
export function lockGate(tagId, status, typed): LockGate {
if (!tagId) return { state: 'no-tag' };
if (status === NDEF_STATUS.READ_ONLY) return { state: 'already-locked' };
if (status === NDEF_STATUS.NOT_SUPPORTED) return { state: 'not-lockable' };
return normaliseTagId(typed) === normaliseTagId(tagId)
? { state: 'armed', tagId }
: { state: 'needs-confirmation', expected: tagId };
}
确认对话框衡量用户的意愿。输入标识符衡量 注意力。值得设计防范的失败情形不是想要锁定标签的人,而是想要锁定标签却手持错误标签的人。
这也是为什么屏幕在你先 读取标签 之前不会启用,并且会显示当前标签上的内容。意外的芯片会在被使用前自行宣布自己。
标识符既显示在屏幕上,也需要手动输入。这看起来是多余的,直到你想到它要防范的失败情形:正确的人,错误的芯片。
注意该函数的顺序:already-locked 在输入确认之前会被检查 之前,这样就没有人能通过输入来“锁定”已经锁定的标签并被告知成功。实际上并未成功。根本没有可做的事情。
"无事可做" 不是 "出了问题"
AlreadyLockedException 与 LockFailedException 有意区分开来,UI 将其渲染为 绿色,且没有锁定按钮。标签处于你所请求的确切状态。将其报告为失败会让人对一个完好的芯片产生警报。
“无事可做,且未更改任何内容”这句话在做实际工作。它告诉你应用并未尝试,这是你想要知道的关于不可重复操作的部分。
这与永不在自己的逻辑中抛出依赖的错误类型是同一原则:两种不同的情况不应合并为一个信号,而你希望将它们区分开来的那一刻,正是出问题的时候。
验证,因为你无法通过重试来查明
两种实现之后都会重新读取状态。该检查在写入路径中也存在,但在这里它的意义不同:
写入失败是可以恢复的:重新写入一次。一个报告成功但实际上未发生的锁定会让标签带着它已受保护的错觉进入世界,而你无法通过重试来查明,因为 重试本身就是破坏性操作。
因此,未验证的锁定不会像未验证的写入那样被报告为软警告。文本说明芯片的状态未知,并在依赖之前请先读取它。
最强的确认随后来自标签本身,而不是应用。再次尝试向其写入时,CoreNFC 本身会在系统表中拒绝,而在你的代码有机会发声之前:
这句话由 iOS 编写,而非此应用。这是唯一真正重要的证明,而且你永远无法收集两次。
独立的类,而非标志
NfcLockSession 复写了写入会话的相当一部分内容:设置、委托以及一次性处理的纪律。这是刻意的。
另一种方案是在写会话上使用 lock: Bool。有时候会破坏标签的布尔参数,正是三个月以后重构时不小心传递的那个值,这是由从未阅读过该文件的人造成的。有两个调用点,两种意图,且从错误位置无法到达任何共享分支。
有时候,对待“这段代码几乎相同”的正确做法就是让它保持几乎相同。
而代码错误时却正确的一个测试
it('refuses a tag that is not NDEF at all', () => {
expect(lockGate(TAG, NDEF_STATUS.NOT_SUPPORTED, TAG).state).toBe('armed');
});
名称说 拒绝。断言说 已武装。它通过了,因为代码确实进行了武装,并且 writeLock 是 NFCNDEFTag 上的一个方法,因此非 NDEF 芯片本不应被提供此方法。
名称是正确的,而代码是错误的。它通过读取一个 通过的 测试的名称旁边的断言发现了这一点,这是唯一能够发现它的方式:一个与错误代码一致的测试在测试运行中是不可见的。
您实际上会如何交付这个?
上面的所有内容是在本地使用 expo run:ios、Xcode、CocoaPods,并且两次磁盘被完全占满。另一种选择是 EAS Build,它值得进行真实的比较,而不仅仅是给出建议。
下面的所有内容已运行:在 EAS 上进行了一次生产构建,此外还进行了一次刻意设置的实验来测试标题声明,而不是假设它。我唯一 没有 做的事情是安装生成的 .ipa。这是一个 App Store 构建,因此不能侧载加载,并且此手册中所有的 NFC 声明仍然依赖于真实手机上的本地构建二进制文件。
EAS 本可以节省的步骤
这个项目最糟糕的下午是这个错误:
Provisioning Profile "iOS Team Provisioning Profile: *" does not support
the NFC Tag Reading capability.
修复过程是手动的,且从错误信息中无法发现:在 Apple 门户网站注册一个显式的 App ID,勾选 NFC 标签读取,然后重新生成配置文件。错误信息中没有任何内容提示需要打开浏览器。
EAS 会自动完成此步骤。 如果你的 entitlements 文件中包含受支持的权限,eas build 会在 Apple 开发者控制台启用匹配的功能,如果已经启用则会跳过。并且 com.apple.developer.nfc.readersession.formats —— 正是该应用声明的键 —— 在受支持列表中按名称存在。
因此,整个手册中最痛苦的手动步骤被我未使用的工具自动化了。
如何证明而非仅仅相信
使用 TapCard 的真实 bundle identifier 运行 eas build 无法演示这一点,而在相信任何人的工作截图之前,了解其原因是值得的:
✔ Bundle identifier registered com.nfccard.tap
✔ Synced capabilities: No updates
No updates,因为该应用 ID 已经启用了 NFC 标签读取功能。我是在浏览器中手动打开的,就在本节所讨论的章节里。这仅表明 EAS 与我之前完成的工作保持一致。许多宣称 “EAS 为你处理一切” 的说法正是基于此输出。
真正的测试需要一个从未使用过的捆绑标识符。于是:一个临时项目,com.nfccard.tap.eastest,基本上只包含该权利,故意与真实应用分开,而不是临时重命名其捆绑标识符——这样做会导致存储的凭据混淆。
✔ Bundle identifier registered com.nfccard.tap.eastest
✔ Synced capabilities: Enabled: NFC Tag Reading
这就是所声称的,已经可以观察到。 一个本来不存在的 App ID,仅靠 entitlements 文件就被注册并赋予了 NFC 功能,而且没有使用浏览器或门户。而且由于同步在凭据步骤执行,该命令正是 eas credentials:configure-build,且未消耗任何构建。
如果你想重复此操作,有一个实际的提示:先运行 真实 的构建。分发证书属于账户范围,且 Apple 对可持有数量有限制,因此先执行临时操作会在你计划删除的应用上消耗一个证书。按此顺序操作,临时应用会提出复用真实证书,并且仅为其自身创建一个随 bundle ID 而定的配置文件。
CLI 实际上做了什么
这种映射并非魔法,也没有被埋藏。它只是一个查找表,而 NFC 在其中有一个条目(eas-cli/build/credentials/ios/appstore/capabilityList.js):
{
name: 'NFC Tag Reading',
entitlement: 'com.apple.developer.nfc.readersession.formats',
capability: CapabilityType.NFC_TAG_READING,
// Technically it seems only `TAG` is allowed, but many apps and packages tell users to add `NDEF` as well.
validateOptions: createValidateStringArrayOptions(['NDEF', 'TAG']),
getSyncOperation: getDefinedValueSyncOperation,
}
再读一遍那条注释,因为它悄悄地说的是你:NDEF 可能不是一个真实的值。 每个教程都告诉你写 ["NDEF", "TAG"],这本手册自己的 app.json 也写 ["NDEF", "TAG"],维护映射表的人显然怀疑只有 TAG 有意义,但仍然接受两者,因为拒绝这对会让所有人出问题。这就是当没有人检查规范时,约定的样子。
getDefinedValueSyncOperation 是另一半。该操作根据权利是否 defined 来触发,这就是为什么同步会双向运行,而不仅仅是添加内容。
还有文档没有告诉你的一个实际细节:同步是由 credentials 步骤触发的,而不是构建步骤。
SetUpTargetBuildCredentials.runAsync()
└─ ensureBundleIdExistsAsync({ entitlements, … })
└─ syncCapabilitiesAsync()
→ "Synced capabilities: Enabled: NFC Tag Reading" (or "No updates")
So eas credentials:configure-build --platform ios 注册 App ID 并同步功能 不消耗构建。如果你只想正确完成 Apple 端的设置,你不必为构建付费即可获得。
随之而来的陷阱
同步是双向的:如果你的应用在远程启用了某项功能,但原生 entitlements 文件中不存在,运行 eas build 会自动 禁用 它。
在门户中手动管理功能 和 使用 EAS 构建的团队会看到 EAS 关闭这些功能。entitlements 文件将成为事实的来源,无论你是否有意。
EXPO_NO_CAPABILITY_SYNC=1 选择退出,但需注意退出意味着远程更改将停止同步,这会后来导致配置文件不匹配。为功能选择一个所有者,并让其负责。
EAS 无法帮助的事情
“使用 EAS” 并不是此项目大多数问题的答案,假装否则就是在兜售东西:
| 问题 | EAS 是否有帮助? |
|---|---|
| App ID 上的 NFC 标签读取功能 | ✅ 自动化 |
| 磁盘已满,发生两次 | ✅ 构建发生在其他地方 |
| 构建后 Gradle 守护进程占用内存 | ✅ 本地没有运行任何东西 |
在添加 Swift 文件后需要 pod install |
✅ 每次构建都是干净的 |
| 已释放的 CoreNFC 会话 | ❌ 代码错误 |
| 两次解决 promise | ❌ 代码错误 |
| FeliCa 轮询 entitlement | ❌ 不是功能,而是 EAS 不管理的关键 |
String.fromCharCode 截断了一个 emoji |
❌ 依赖错误 |
Expo 得到 ERR_USER_CANCELLED |
❌ 错误的假设 |
| 甚至读取标签 | ❌ 没有云端可以替代芯片 |
EAS 消除机器问题,而非 NFC 问题。 此手册中所有实际上与 NFC 相关的发现都将完全相同发生。
权衡
本地构建会占用磁盘、内存和设置。此项目曾两次填满 460 GB 磁盘,有三个后台进程在内存压力下被杀死,并且因过时的 Gradle 守护进程而丢失了一次重新构建。
EAS 会带来排队时间和云项目,并将你的签名凭据放在 Expo 的服务器上。它无法缩短的就是这里真正重要的循环:你仍然需要走到手机前并将芯片贴上去。 一个构建成功的云构建无法告诉你标签是否被读取。
对于拥有可用本地工具链的个人项目,本地在迭代速度上占优。对于团队、CI 流水线,或刚刚看到磁盘在编译过程中达到 100% 的人来说,仅凭能力同步这一特性就已经是一个有力的论据。
Android 侧边抢先看
Android 值得拥有自己的手册,而且它正在得到。本节是为不想等待的读者准备的预览:上面 Swift 对应的 Kotlin 版本,以及导致两平台 NFC 实际上是不同工作而非简单重复的架构差异。
请将其视为设计预览而非已验证的实现。Kotlin 根据文档 API 编译,并镜像已经在硬件上验证的 Swift,因而结构正确;而在手持设备上进行完整验证的内容,则是本手册后续章节所提供的。
真正重要的差异体现在 API 表面而非设备本身,它们决定了你应如何组织代码。
Android 没有会话
iOS 会给你一个会话,而该会话即为模型:你启动它,操作系统会绘制一个表单,它会把标签交给你,随后自行失效。Android 提供的是 读取器模式:一个绑定到前台 Activity 的回调,只要标签靠近就会触发。
adapter.enableReaderMode(activity, ::onTagDiscovered, flags, Bundle())
iOS 会话为你所做的一切都成为你的:
| iOS | Android | |
|---|---|---|
| 扫描界面 | 系统绘制一个表单 | 应用绘制一切 |
| 会话结束 | 读取一个标签后自动结束 | disableReaderMode 在每条退出路径上 |
| 需求 | 屏幕上什么都没有 | 前台 Activity,而不是 Context |
| 回调线程 | 主线程 | 一个 Binder 线程 |
| 取消 | 系统表单提供此功能 | 自行构建 |
最后一行一直延伸到 JavaScript 中。跨平台的 cancelScan() 在 Android 上是真实实现,而在 iOS 上是一个 已文档化的无操作,此时系统表单负责取消,Swift 模块故意根本不实现该函数:
export async function cancelScanNative(): Promise {
if (Platform.OS !== 'android') return;
await NfcNative.cancelScan();
}
另外:记得传入 FLAG_READER_NO_PLATFORM_SOUNDS,否则操作系统会额外播放自己的发现提示音,而此时应用本身已经在告诉用户该怎么做了。
Kotlin 代码,外加一条设计说明
private fun onTagDiscovered(tag: Tag) {
val ndef = Ndef.get(tag) ?: run {
stopReaderMode(); rejectOnce(NotNdefException()); return
}
try {
ndef.connect()
val status = NfcTagInfo.status(ndef)
val capacity = ndef.maxSize
messageToWrite?.let { message ->
// Ask before acting, same order as the Swift: refusing leaves the tag
// untouched, failing partway through a write may not.
if (!ndef.isWritable) { … }
if (message.toByteArray().size > capacity) { … }
ndef.writeNdefMessage(message)
}
// Read back in the same connection. For a write this is verification,
// for a read it is simply the result.
val onTag = ndef.ndefMessage
…
} finally {
runCatching { ndef.close() }
}
}
Android 没有等价于 iOS 的 NFCNDEFStatus,所以状态是 推导得出,而不是直接报告的。
fun status(ndef: Ndef?): Int =
when {
ndef == null -> 1 // not NDEF: signalled by Ndef.get() returning null
ndef.isWritable -> 2 // read-write
else -> 3 // read-only
}
iOS 直接回答这个问题。Android 通过 Ndef.get() 返回 null 表示“非 NDEF”,并在已连接的标签上暴露 isWritable。将两者映射到同一组数字上,使 TypeScript 永不需要知道它在与哪个平台通信,这正是原生模块的全部工作。
如果你移植此代码,以下差异将很重要
| iOS | Android | |
|---|---|---|
| 权限模型 | App ID + 功能 + 付费账户 | 一行清单,免费 |
| 配置错误时的失败 | 从不提及 "NFC" 的代码签名错误 | 权限缺失 |
| 权限粒度 | 每个轮询选项 | 一项权限覆盖全部 |
| 扫描 UI | 系统表单,不可重新样式化 | 无。你需要自己绘制一切 |
| 读取时的容量 | ❌ (需通过状态查询获取) | ✅ getMaxSize() |
| 读取时的可写性 | ❌ (需通过状态查询获取) | ✅ isWritable |
| 天线 | 上边缘 | 背面中央 |
| 卡片模拟 | 不可能 | HostApduService,开放 |
| 永久锁定 | writeLock,不可逆 |
makeReadOnly(),不可逆 |
该表格得出两种模式。
iOS 前置痛苦,Android 后置痛苦。 在读取一个字节之前,需要处理门户、权限和签名;而在运行时,则是读取器模式、Activity 生命周期以及自行构建取消操作。
而且容量差异实际上比看起来要小。 不是 iOS 给出的信息更少。而是 Android 在普通读取时会主动提供此信息,而 iOS 则要求你在会话中提出特定的问题。数据相同,准入成本不同。正如你所见,我在三份文档上都犯了这个错误。
演示仓库
上面的一切都是一个项目,TapCard,它是一个完整的应用,而不是零散代码的堆砌。
| 层 | 位置 | 包含内容 |
|---|---|---|
| App | app/(tabs)/ |
读取、写入、聚焦和个人资料界面,以及 Tag Info 路由 |
| Pure logic | lib/ |
NDEF 解码器和编码器、vCard、容量、错误映射、聚焦规则。约 2,240 行代码,未导入 React Native |
| Native module | modules/nfc-native/ |
约 1,010 行 Swift 代码和约 475 行 Kotlin 代码:会话、类型化异常、标签转换 |
| State | store/ |
Zustand + AsyncStorage:个人资料、最近的标签、聚焦记录 |
| Evidence | vendor/ |
已移除的依赖项,已冻结,附带证明其移除合理的测试 |
| Notes | DEVLOG.md, GOTCHAS.md, PLATFORM-NOTES.md |
每条命令和错误的原始记录,75 个陷阱,以及运行中的对比 |
293 个测试,15 个测试套件,大约一秒钟,且不需要硬件。 这就是保持解码纯粹所带来的回报。
此外,通过这些带标签的检查点,您可以在任何阶段阅读应用程序,而不仅仅是在结束时:
git checkout step-0-scaffold # boots, no NFC
git checkout step-1-first-read # entitlements + raw dump
git checkout step-2-decode # own decoder, Tag Info
git checkout step-3-write # vCard, capacity checks
git checkout step-4-own-module # native module alongside the library
git checkout step-5-no-dependency # library removed
git checkout step-6-android # Kotlin reader mode
克隆仓库,买一包贴纸,查看 step-1-first-read,然后把芯片贴在自己手机上。这个循环是让上面的内容变得具体的最快方式。
开始前你需要知道的
先买好标签。 没有模拟器。我的芯片在邮寄中丢失,项目因而停滞了两周,再巧妙的架构也无法替代一个芯片。
在信任依赖之前先阅读它们。 用 ndef-lib 花二十分钟就发现了三个实际缺陷,并改变了应用的架构。这些问题都不在我会去查找的问题追踪器中。
假设失败会是静默的。 在我记录的七十五个陷阱中,大约有一打根本不会产生任何错误:会话被释放、配置插件被移除、未转义的分号、截断的表情符号、空错误消息以及被 git 忽略的原生模块。在 NFC 工作中,‘没有任何反应’是最常见的症状,因此应相应地进行检测,并在真实表面上进行确认,而不是仅仅相信返回值。
将平台差异放在类型中,而不是放在注释里。 将‘此平台不会告诉我们’建模为一等状态:
type Fact = {
label: string;
value: string | null; // null = the platform did not report it
unavailable?: string; // why, in plain language
};
一个对缺失值和零均渲染为短横线的 UI 一点也教不了人。
不要让结论继承其下方观察的可信度。 这一点代价最高,而且与 NFC 完全无关。
结论
你现在已经完整了解了 iOS 的全景:权限迷宫、手写的解码器(因为流行的那个会丢失数据)、在操作前先询问标签的写入路径、受地理限制的专注计时器、用 Swift 编写的原生模块、在保留证据的情况下删除的依赖,以及被迫输入其名称才能打开的永久锁定标签。
接下来有三个值得探索的方向。
后台标签读取: 在应用关闭的情况下轻触标签。iOS 会弹出用户必须点击的通知,且仅适用于某些记录类型。这是让 NFC 感觉神奇的功能,而决定哪些记录有资格的规则也值得单独讨论。
加密标签: NTAG424 DNA 在每次轻触时对计数器进行签名。如果你希望标签作凭证而非仅作标签,这就是起点。
以及 Android,这是下一本手册。上面的预览是地图,而在真实硬件上的完整演练则是在本文之后的内容。
有趣的部分从来不是 requestTechnology。而是当你出错时,两个平台拒绝告诉你的一切。
来源及进一步阅读
Apple、CoreNFC 和权限:
queryNDEFStatus: 此项目错误地认为不存在的调用CoreNFC 在 iPad 上的可用性: 苹果工程师确认该框架仅限 iPhone。
Android:
Ndef:getMaxSize()、isWritable()
格式规范:
NFC Forum 的NDEF 与 RTD 规范:URI 前缀表和文本记录的状态字节
RFC 2426:vCard 3.0,包括
N字段和折行规则
Expo 与 React Native:
eslint-plugin-react-hooks——正是其中两条规则揪出了专注计时器里的问题:purity和set-state-in-effectEAS Build 的 iOS 能力配置:EAS 会同步哪些授权项,以及
EXPO_NO_CAPABILITY_SYNCexpo-modules-core里的ios/Core/Exceptions/CodedError.swift:ERR_USER_CANCELLED就出自这里
已上线、你可以亲自去验证的 NFC 应用——也就是“你早已见过它”一节所引用的那些:
在“快捷指令”中设置触发条件,Apple。NFC 触发条件要求 iPhone XS 或更新机型,以及 iOS 13.1 或更高版本
amiibo 常见问题,任天堂,介绍了只读手办与可读写手办的区别
使用“英国移民:身份核验”应用,GOV.UK
iPhone 的 Tap to Pay 以及其 支持的地区,Apple
聚焦功能的现有技术:
本项目替换的库:
react-native-nfc-manager: MIT. 这里描述的缺陷是在阅读源代码时在版本 3.17.2 中发现的,并且一个冻结的副本位于演示仓库的vendor/目录中,伴随能够定位它们的测试。