iOS 11 之后苹果开放了 Core NFC 框架,让 iPhone 具备了读取 NFC 标签的能力,但直到 iOS 13 才真正支持类似 ISO7816、ISO15693 这类智能卡协议。也就是说,现在的 iPhone 不仅可以读取普通的 NDEF 标签,还能与护照芯片、电子身份证、公交卡这类符合国际标准的智能卡片进行 APDU 指令级别的交互。这篇文章围绕 NFC VAS(Value Added Services)相关的读取场景,讲清楚权限配置、会话管理、数据解析的完整链路。

Core NFC 支持的会话类型与适用场景
Core NFC 提供三种会话:NFCNDEFReaderSession、NFCTagReaderSession 和 NFCVASReaderSession。很多开发者一上来就用 NDEF 会话,结果发现读取护照时永远失败,原因在于护照芯片遵循的是 ISO 7816 标准,走的是 APDU 指令通道,根本不会返回 NDEF 格式的数据。
NFCTagReaderSession 是最灵活的一种,它通过 NFCTagReaderSession.polling 配置要轮询的标签类型,支持的选项包括 ISO14443、ISO15693、FeliCa 等。当检测到标签后,系统会回调 didDetect tags: 方法,你拿到 NFCISO7816Tag 对象后,就可以发送自定义的 APDU 命令了。读取护照芯片和电子身份证,用的就是这条路径。
NFCVASReaderSession 则是专门为 Apple 定义的 VAS 协议设计的,主要服务于商家会员卡、优惠券等场景,卡片端会响应带有商户 ID 的请求,返回签名的凭证数据。要注意的是,VAS 会话需要商户在 Apple 后台注册 merchant ID,普通开发者账号如果不接入该体系,用得不多,但在一些刷卡设备联调的场景里仍会遇到,理解它的握手流程对排查问题很有帮助。
import CoreNFC
class PassportReader: NSObject, NFCTagReaderSessionDelegate {
var session: NFCTagReaderSession?
func startReading() {
guard NFCTagReaderSession.readingAvailable else {
print("该设备不支持 NFC 读取")
return
}
session = NFCTagReaderSession(
pollingOption: [.iso14443, .iso15693],
delegate: self
)
session?.alertMessage = "请将护照靠近手机背面"
session?.begin()
}
func tagReaderSessionDidBecomeActive(_ session: NFCTagReaderSession) {
// 会话激活,等待标签进入射频场
}
func tagReaderSession(_ session: NFCTagReaderSession,
didDetect tags: [NFCTag]) {
guard case let .iso7816(tag) = tags.first else {
session.invalidate(errorMessage: "未识别到 ISO7816 标签")
return
}
// 连接标签后即可发送 APDU
session.connect(to: tags.first!) { error in
if let error = error {
session.invalidate(errorMessage: error.localizedDescription)
}
}
}
func tagReaderSession(_ session: NFCTagReaderSession,
didInvalidateWithError error: Error) {
// 会话失效,处理错误
}
}权限配置:entitlements 与 Info.plist 缺一不可
Core NFC 的权限配置是新手最容易翻车的地方。首先需要在开发者账号里为 App ID 勾选 Near Field Communication Tag Reading 能力,然后在工程中添加对应的 entitlements 文件。Xcode 会在文件中生成一个包含若干十六进制值的数组,这些值代表允许访问的标签类型,比如 A000000308 表示 FeliCa 系统码,0000000000 表示通用 ISO7816。
其次,Info.plist 中必须添加 NFCReaderUsageDescription 键,填写用户能看懂的描述文案,比如“靠近证件读取信息”。这段文案会在系统弹出的 NFC 面板上展示,如果缺失,App 启动 NFC 会话时会直接崩溃,这也是提审被拒的常见原因。苹果对 NFC 功能的审核比较严格,如果 App 的用途描述与实际功能不符,很容易被 4.2 最小功能条款打回。
还有一个细节:读取护照需要在 entitlements 中声明 eMRTD 相关的支持,并且 App 的实际用途要能自圆其说。苹果对个人证件类数据的采集审核非常谨慎,如果是企业内部应用或者特定行业的合规应用(如边检、酒店入住核验),审核通过率会高一些。上线前建议先在 TestFlight 上做真机验证,模拟器是不支持 NFC 的。
读取护照芯片:BAC 认证与 APDU 交互流程
护照芯片(eMRTD)的数据并不是随便就能读的。ICAO 9303 标准规定,读取护照数据前必须先通过 BAC(Basic Access Control)认证。简单说,你需要从护照机读区(MRZ)获取护照号、出生日期、有效期这三组信息,用它们派生出加密密钥,与芯片完成双向认证后,才能建立安全通道读取数据。
整个交互过程是一连串的 APDU 指令:先发 SELECT 命令选择 eMRTD 应用(AID 为 A0000002471001),然后依次执行认证流程,最后才能读 DG1(MRZ 数据)、DG2(照片)等数据组。用 Core NFC 实现时,核心 API 是 NFCISO7816Tag 的 sendCommand 方法,把每一步的指令字节拼装成 NFCISO7816APDU 对象发出去。
func selectEMRTD(on tag: NFCISO7816Tag,
completion: @escaping (Data?) -> Void) {
// 选择 eMRTD 应用的 APDU
let aid: [UInt8] = [0xA0, 0x00, 0x00, 0x02, 0x47, 0x10, 0x01]
let apdu = NFCISO7816APDU(
instructionClass: 0x00,
instructionCode: 0xA4, // SELECT
p1Parameter: 0x04,
p2Parameter: 0x0C,
data: Data(aid),
expectedResponseLength: 0
)
tag.sendCommand(apdu: apdu) { response, sw1, sw2, error in
if let error = error {
print("发送失败: \(error.localizedDescription)")
completion(nil)
return
}
// 状态码 9000 表示指令执行成功
if sw1 == 0x90 && sw2 == 0x00 {
completion(response)
} else {
print("卡片返回异常状态: \(String(sw1, radix: 16)) \(String(sw2, radix: 16))")
completion(nil)
}
}
}BAC 认证涉及 3DES 加密和密钥派生,纯手写工作量不小,社区里有现成的 Swift 实现可以参考,比如把 MRZ 解析和密钥计算封装好的开源库。值得注意的是,部分新版护照已经开始采用 PACE 认证替代 BAC,安全性更高,协议实现也复杂不少,选型时要看目标地区的证件版本。
读取公交卡:FeliCa 与 ISO14443 的差异处理
公交卡的读取又是另一套思路。日本的交通卡(Suica、ICOCA)基于 FeliCa 技术,而国内大部分城市的公交卡基于 ISO 14443 协议,卡片内部是符合 ISO 7816 的文件系统或者自定义指令集。Core NFC 对这两种卡都提供了支持,通过 pollingOption 里的 .iso14443 和 .felica 选项分别轮询。
读 FeliCa 卡时拿到的是 NFCFeliCaTag 对象,核心操作是 Read Without Encryption 指令,需要指定服务和区块号。以读取 Suica 余额为例,先轮询拿到卡 IDm,再向特定的服务码发起读取请求,返回的数据里按官方文档定义的偏移量解析出余额字段。国内公交卡多数走 NFCISO7816Tag,指令集由各城市一卡通公司定义,比如北京一卡通需要先 SELECT 特定应用再读余额文件,这些指令文档一般不公开,需要与卡公司合作获取。
func readFeliCaBalance(tag: NFCFeliCaTag,
completion: @escaping (Int) -> Void) {
// Suica 余额读取示例,服务码以官方文档为准
let serviceCode: [UInt8] = [0x00, 0x8B] // 示例服务码
let blockList: [UInt8] = [0x80, 0x00] // 读取第 0 块
var command = Data([0x06]) // Read Without Encryption
command.append(tag.currentIDm ?? Data())
command.append(contentsOf: [0x01]) // 服务数量
command.append(contentsOf: serviceCode)
command.append(contentsOf: [0x01]) // 块数量
command.append(contentsOf: blockList)
tag.sendFeliCaCommand(commandPacket: command) { response, error in
guard let data = response, error == nil else {
completion(-1)
return
}
// 按协议偏移解析余额,具体偏移随卡片类型变化
let offset = 11
if data.count > offset + 2 {
let balance = Int(data[data.startIndex + offset]) << 8
| Int(data[data.startIndex + offset + 1])
completion(balance)
} else {
completion(-1)
}
}
}实测中还要注意天线功率问题:iPhone 的 NFC 天线位于手机背面上部,贴卡角度不对会导致读一半断开,尤其是读取照片这种大数据块时,中途断连就得重来。建议把大文件分块读取,每块控制在合理大小,并在会话超时前保持卡片贴紧。另外 iOS 14 之后 Core NFC 增加了标签更新写入能力,部分支持余额充值的城市卡也可以走同一套 API 完成圈存操作。
常见问题与调试建议
调试 Core NFC 最麻烦的是错误信息不直观。常见的错误码包括 NFCReaderError.securityViolation,这通常是 entitlements 里的标签类型声明和实际轮询的标签不匹配导致的;readerSessionInvalidationErrorSessionTimeout 则是标签检测超时,系统默认给 60 秒,用户没贴卡就自动失效。可以在 alertMessage 里给用户明确的引导提示,减少超时概率。
另一个高频问题是读不到 Mifare 卡。Mifare 系列在 Core NFC 中的支持情况分版本:Mifare DESFire 走 ISO7816 没问题,Mifare Classic 因为其私有加密协议,苹果只允许读取 UID,访问数据区块的支持非常有限。如果你的业务依赖 Mifare Classic 的扇区数据,建议评估改用 DESFire 卡片或者切换到 Android 平台配合专用读卡器。
最后提醒一点,涉及证件和支付数据的 App 一定要注意合规。读取护照芯片涉及个人敏感信息,需在隐私政策中明确说明数据用途;公交卡余额查询如果只是展示类功能,审核相对宽松,但一旦涉及充值扣款,就需要对接正规的支付渠道并走金融类目审核。把技术方案和合规设计提前规划好,能省掉很多返工。