Android 物联网组网实战:NFC 从零到落地

摘要:本文是一份面向 Android 开发者的 NFC 技术全链路实战指南。文章从 NFC 底层原理与硬件适配讲起,系统覆盖了 Manifest 权限配置、前台分发系统搭建、NDEF 消息解析与读写、多标签适配、异常处理、安全加密、低功耗优化等核心环节,并结合真实仓储项目案例给出规模化部署建议。无论你是初次接触 NFC 的新手,还是希望优化现有方案的开发者,都能从中获得可直接落地的工程经验,避开常见的兼容性与稳定性陷阱。

在移动开发领域,近场通信(NFC)往往被低估为简单的"刷卡"功能,但实际上它是连接物理世界与数字应用的高效桥梁。很多开发者在初次尝试集成 NFC 时,容易陷入硬件适配的泥潭:明明代码逻辑无误,却在不同品牌的 Android 设备上表现迥异,或者在屏幕熄灭后无法持续监听标签。这种不确定性不仅增加了调试成本,还可能导致关键业务场景(如智能门禁、资产盘点或防伪溯源)的体验断裂。

解决这些问题的核心,在于建立一套从底层硬件抽象到上层业务逻辑的完整闭环。我们需要深入理解 Android 的 NFC 堆栈机制,正确处理 Activity 的生命周期与前台分发系统的关系,并针对不同类型的标签制定差异化的读写策略。更重要的是,在生产环境中,必须考虑数据加密、低功耗运行以及异常场景的兜底方案,而不仅仅是实现一个"Hello World"级别的 Demo。

目录

  • [① NFC 技术原理与 Android 硬件适配指南](#① NFC 技术原理与 Android 硬件适配指南 "#%E2%91%A0-nfc-%E6%8A%80%E6%9C%AF%E5%8E%9F%E7%90%86%E4%B8%8E-android-%E7%A1%AC%E4%BB%B6%E9%80%82%E9%85%8D%E6%8C%87%E5%8D%97")
  • [② AndroidManifest 权限配置与设备兼容性检查](#② AndroidManifest 权限配置与设备兼容性检查 "#%E2%91%A1-androidmanifest-%E6%9D%83%E9%99%90%E9%85%8D%E7%BD%AE%E4%B8%8E%E8%AE%BE%E5%A4%87%E5%85%BC%E5%AE%B9%E6%80%A7%E6%A3%80%E6%9F%A5")
  • [③ 前台分发系统搭建与 Activity 生命周期管理](#③ 前台分发系统搭建与 Activity 生命周期管理 "#%E2%91%A2-%E5%89%8D%E5%8F%B0%E5%88%86%E5%8F%91%E7%B3%BB%E7%BB%9F%E6%90%AD%E5%BB%BA%E4%B8%8E-activity-%E7%94%9F%E5%91%BD%E5%91%A8%E6%9C%9F%E7%AE%A1%E7%90%86")
  • [④ NDEF 消息格式解析与数据读写核心代码实现](#④ NDEF 消息格式解析与数据读写核心代码实现 "#%E2%91%A3-ndef-%E6%B6%88%E6%81%AF%E6%A0%BC%E5%BC%8F%E8%A7%A3%E6%9E%90%E4%B8%8E%E6%95%B0%E6%8D%AE%E8%AF%BB%E5%86%99%E6%A0%B8%E5%BF%83%E4%BB%A3%E7%A0%81%E5%AE%9E%E7%8E%B0")
  • [⑤ 从零构建标签识别与数据写入完整流程](#⑤ 从零构建标签识别与数据写入完整流程 "#%E2%91%A4-%E4%BB%8E%E9%9B%B6%E6%9E%84%E5%BB%BA%E6%A0%87%E7%AD%BE%E8%AF%86%E5%88%AB%E4%B8%8E%E6%95%B0%E6%8D%AE%E5%86%99%E5%85%A5%E5%AE%8C%E6%95%B4%E6%B5%81%E7%A8%8B")
  • [⑥ 多类型标签适配方案与异常场景处理逻辑](#⑥ 多类型标签适配方案与异常场景处理逻辑 "#%E2%91%A5-%E5%A4%9A%E7%B1%BB%E5%9E%8B%E6%A0%87%E7%AD%BE%E9%80%82%E9%85%8D%E6%96%B9%E6%A1%88%E4%B8%8E%E5%BC%82%E5%B8%B8%E5%9C%BA%E6%99%AF%E5%A4%84%E7%90%86%E9%80%BB%E8%BE%91")
  • [⑦ 常见读写失败报错分析与调试排查步骤](#⑦ 常见读写失败报错分析与调试排查步骤 "#%E2%91%A6-%E5%B8%B8%E8%A7%81%E8%AF%BB%E5%86%99%E5%A4%B1%E8%B4%A5%E6%8A%A5%E9%94%99%E5%88%86%E6%9E%90%E4%B8%8E%E8%B0%83%E8%AF%95%E6%8E%92%E6%9F%A5%E6%AD%A5%E9%AA%A4")
  • [⑧ 安全认证机制引入与敏感数据加密存储](#⑧ 安全认证机制引入与敏感数据加密存储 "#%E2%91%A7-%E5%AE%89%E5%85%A8%E8%AE%A4%E8%AF%81%E6%9C%BA%E5%88%B6%E5%BC%95%E5%85%A5%E4%B8%8E%E6%95%8F%E6%84%9F%E6%95%B0%E6%8D%AE%E5%8A%A0%E5%AF%86%E5%AD%98%E5%82%A8")
  • [⑨ 低功耗优化策略与后台持续监听实现](#⑨ 低功耗优化策略与后台持续监听实现 "#%E2%91%A8-%E4%BD%8E%E5%8A%9F%E8%80%97%E4%BC%98%E5%8C%96%E7%AD%96%E7%95%A5%E4%B8%8E%E5%90%8E%E5%8F%B0%E6%8C%81%E7%BB%AD%E7%9B%91%E5%90%AC%E5%AE%9E%E7%8E%B0")
  • [⑩ 真实落地案例复盘与规模化部署注意事项](#⑩ 真实落地案例复盘与规模化部署注意事项 "#%E2%91%A9-%E7%9C%9F%E5%AE%9E%E8%90%BD%E5%9C%B0%E6%A1%88%E4%BE%8B%E5%A4%8D%E7%9B%98%E4%B8%8E%E8%A7%84%E6%A8%A1%E5%8C%96%E9%83%A8%E7%BD%B2%E6%B3%A8%E6%84%8F%E4%BA%8B%E9%A1%B9")
  • [⑪ 完整实战代码:NFC 读写一体化 Activity](#⑪ 完整实战代码:NFC 读写一体化 Activity "#%E2%91%AA-%E5%AE%8C%E6%95%B4%E5%AE%9E%E6%88%98%E4%BB%A3%E7%A0%81nfc-%E8%AF%BB%E5%86%99%E4%B8%80%E4%BD%93%E5%8C%96-activity")
  • [⑫ 常见问题 FAQ](#⑫ 常见问题 FAQ "#%E2%91%AB-%E5%B8%B8%E8%A7%81%E9%97%AE%E9%A2%98-faq")
  • [⑬ 总结](#⑬ 总结 "#%E2%91%AC-%E6%80%BB%E7%BB%93")
  • [⑭ HCE 卡模拟:让手机变成一张 NFC 卡](#⑭ HCE 卡模拟:让手机变成一张 NFC 卡 "#%E2%91%AD-hce-%E5%8D%A1%E6%A8%A1%E6%8B%9F%E8%AE%A9%E6%89%8B%E6%9C%BA%E5%8F%98%E6%88%90%E4%B8%80%E5%BC%A0-nfc-%E5%8D%A1")

本文将基于实际的工程落地经验,系统梳理 NFC 技术在 Android 平台上的全链路实现方案。从最基础的权限配置开始,逐步深入到 NDEF 消息解析、多标签适配、错误排查以及安全加固。无论你是需要快速上线一个读卡功能,还是构建大规模的物联网交互系统,这套经过验证的方法论都能帮助你避开常见的坑,打造出稳定可靠的 NFC 交互体验。

① NFC 技术原理与 Android 硬件适配指南

NFC(Near Field Communication,近场通信)基于射频识别(RFID)技术发展而来,工作频率通常为 13.56 MHz,有效通信距离一般在 10 厘米以内。它最大的特点是「靠近即连接」------不需要像蓝牙那样配对,也不需要像 Wi-Fi 那样输入密码,只要把设备贴近标签,就能在极短时间内完成数据交换。这种低门槛的交互方式,让它天然适合门禁、支付、票务、资产盘点等场景。

从技术栈的角度看,NFC 的通信建立在 ISO 14443 系列标准之上,主要包含三种工作模式:

  • 读写模式(Reader/Writer):手机作为读写器,主动读取或写入外部 NFC 标签,这是本文前几节重点讲解的模式。
  • 卡模拟模式(Card Emulation):手机扮演一张 NFC 卡,被其他读写器识别,对应后文的 HCE 方案。
  • 点对点模式(P2P):两台设备之间直接交换数据,如 Android Beam(现已逐步淡出)。

理解这三种模式,有助于你在设计功能时快速判断「该用哪条路径」。

Android 的 NFC 分层架构

在 Android 生态中,NFC 的软件栈从上到下大致分为四层:

  1. 应用层 :你写的 Activity、Service,通过 NfcAdapter、NdefMessage 等 API 与系统交互。
  2. 框架层(Framework) :android.nfc 包下的 NfcAdapter、NfcManager 等,封装了底层的复杂逻辑,向上提供简洁的接口。
  3. HAL 层(Hardware Abstraction Layer):屏蔽不同厂商 NFC 控制器的差异,向上提供统一的硬件抽象接口。
  4. 硬件层:NFC 控制器芯片与天线,负责实际的射频收发。

理解这一分层架构至关重要,因为大部分兼容性问题都源于对硬件抽象层的误解------比如你以为调用了 NfcAdapter 就万事大吉,实际上底层 HAL 可能因为厂商实现差异而表现迥异。

硬件适配检查:三步走

Android 设备对 NFC 的支持程度不一。高端机型通常支持完整的读写模式、卡模拟模式(HCE)以及 P2P 模式,而部分中低端设备可能仅支持基础的读卡功能,甚至阉割了某些频段的支持。因此,在开发初期必须做足硬件适配检查,避免在用户设备上「静默失败」。

推荐在 Activity 的 onCreate 中按以下三步校验:

java 复制代码
NfcAdapter nfcAdapter = NfcAdapter.getDefaultAdapter(this);
if (nfcAdapter == null) {
    // 第一步:设备根本没有 NFC 硬件
    tvStatus.setText("当前设备不支持 NFC");
    return;
}
if (!nfcAdapter.isEnabled()) {
    // 第二步:硬件存在但系统开关未开启
    tvStatus.setText("NFC 未开启,请前往系统设置开启");
    startActivity(new Intent(Settings.ACTION_NFC_SETTINGS));
    return;
}
// 第三步:硬件与开关都就绪,可以正常使用
tvStatus.setText("NFC 已就绪,请将手机靠近标签");

这段代码把「无硬件」「未开启」「已就绪」三种状态清晰地区分开,让用户一眼就能知道问题出在哪,而不是面对一个毫无反应的界面。

天线位置:最容易忽略的「物理坑」

除了软件层面的检查,还有一个新手极易忽略的细节------天线位置。不同厂商对 NFC 天线的布局差异很大:有的在手机背部中央,有的偏上,有的偏下,甚至有的在摄像头模组附近。这直接影响用户持机靠近标签的最佳角度和距离。

在 UI 设计上,应提供直观的引导动画,提示用户将设备背部特定区域贴近标签,以减少因物理位置偏差导致的读取失败。你可以参考以下做法:

  • 在界面上绘制一个手机轮廓,高亮标注天线所在区域。
  • 提示文案尽量具体,例如「请将手机背部上方区域贴近标签」。
  • 读取失败时,给出「请调整位置后重试」的友好提示,而不是笼统的「读取失败」。

这些看似「小」的细节,往往决定了你的应用在真实用户手里是「一次成功」还是「反复失败」。

NFC 核心术语速查表

为了让你在阅读后续章节时不被术语绊住,这里把本文涉及的关键术语整理成一张速查表。建议收藏,遇到不熟悉的词随时回来对照:

术语 全称 / 中文释义 首次出现章节 一句话说明
NFC Near Field Communication,近场通信 ① 技术原理 基于 13.56 MHz 射频的短距离通信技术,通信距离一般在 10 厘米以内。
RFID Radio Frequency Identification,射频识别 ① 技术原理 NFC 的底层技术基础,通过射频信号实现非接触式数据交换。
ISO 14443 国际标准化组织定义的近距非接触卡标准 ① 技术原理 NFC 通信所依赖的核心标准,定义了 13.56 MHz 频段的物理层与协议。
Reader/Writer 读写模式 ① 技术原理 手机作为读写器,主动读取或写入外部 NFC 标签,是本文前几节的重点。
Card Emulation 卡模拟模式 ① 技术原理 手机扮演一张 NFC 卡,被其他读写器识别,对应后文的 HCE 方案。
P2P Point to Point,点对点模式 ① 技术原理 两台设备之间直接交换数据,如 Android Beam(现已逐步淡出)。
HAL Hardware Abstraction Layer,硬件抽象层 ① 技术原理 屏蔽不同厂商 NFC 控制器的差异,向上提供统一的硬件接口。
NfcAdapter NFC 适配器类 ① 技术原理 Android 提供的核心 API,通过 getDefaultAdapter() 获取实例并操作 NFC 功能。
NDEF NFC Data Exchange Format,NFC 数据交换格式 ④ NDEF 消息解析 NFC 论坛定义的标准数据封装格式,由一条或多条记录(Record)组成消息(Message)。
NdefMessage NDEF 消息对象 ④ NDEF 消息解析 封装一组 NdefRecord 的容器,是读写标签时的顶层数据单元。
NdefRecord NDEF 记录对象 ④ NDEF 消息解析 单条数据记录,包含类型名称格式(TNF)、类型字段和负载数据。
TNF Type Name Format,类型名称格式 ④ NDEF 消息解析 描述 NdefRecord 中类型字段的格式,用于区分文本、URI、MIME 等数据类型。
Ndef NDEF 技术类 ④ NDEF 消息解析 用于读写 NDEF 格式标签的技术类,通过 Ndef.get(tag) 获取实例。
TagLostException 标签丢失异常 ⑥ 异常场景处理 用户在读写过程中移开手机导致连接中断时抛出,应提示用户重新靠近而非无限重试。
FormatException 格式异常 ⑥ 异常场景处理 标签数据不符合预期格式时抛出,需记录日志并跳过该记录继续解析。
MifareClassic Mifare Classic 技术类 ④ NDEF 消息解析 用于操作非 NDEF 的 Mifare Classic 标签,需按扇区、按块读写并完成密钥认证。
IsoDep ISO 14443-4 技术类 ④ NDEF 消息解析 面向 ISO 14443-4 协议的底层技术类,常用于与智能卡进行 APDU 级交互。
HCE Host-based Card Emulation,主机卡模拟 ⑭ HCE 卡模拟 Android 4.4+ 提供的卡模拟方案,让手机无需安全芯片也能扮演 NFC 卡。
APDU Application Protocol Data Unit,应用协议数据单元 ⑭ HCE 卡模拟 读写器与卡之间交互的指令单元,HCE 通过 processCommandApdu() 处理它。
AID Application ID,应用标识符 ⑭ HCE 卡模拟 读写器用来「点名」卡应用的标识,通常为 5~16 字节,建议使用 F0 开头的私有段。
SE Secure Element,安全芯片 ⑭ HCE 卡模拟 传统卡模拟依赖的硬件安全模块,由芯片厂商和运营商主导,开发者难以介入。

提示:这张表覆盖了全文最核心的术语。随着你往下阅读,遇到「④ NDEF 消息解析」「⑥ 异常处理」「⑭ HCE 卡模拟」等章节时,可以随时回到这里对照,避免在概念上卡壳。

② AndroidManifest 权限配置与设备兼容性检查

要让应用正常使用 NFC 功能,必须在 AndroidManifest.xml 中进行精确配置。首先,声明必要的权限:

xml 复制代码
<uses-permission android:name="android.permission.NFC" />
<uses-feature android:name="android.hardware.nfc" android:required="true" />

uses-feature 标签中的 required="true" 属性会告诉 Google Play 商店,该应用只能在具备 NFC 硬件的设备上安装。如果你的应用核心功能不依赖 NFC(例如仅作为辅助功能),可以将该值设为 false,并在运行时动态检测硬件可用性,从而扩大应用的覆盖范围。

权限配置的三个关键点

  1. uses-permission 与 uses-feature 缺一不可:前者声明「我需要使用 NFC 权限」,后者声明「我需要 NFC 硬件」。只写权限不写 feature,应用仍可能被安装到无 NFC 硬件的设备上,导致运行时崩溃。
  2. required 的取舍 :核心功能依赖 NFC 就设 true,让商店帮你过滤掉不支持的设备;仅作辅助功能则设 false,配合运行时检查扩大覆盖面。
  3. Android 12+ 的注意点:从 Android 12(API 31)开始,系统对后台 NFC 扫描限制更严格。如果你的应用需要在后台监听标签,务必使用前台服务(Foreground Service)并配合常驻通知,而不是依赖隐式广播。

运行时兼容性检查

除了静态配置,运行时检查同样不可或缺。在 Activity 的 onCreate 方法中,应再次校验适配器状态。如果检测到 NFC 不可用或未开启,应及时向用户展示友好的提示对话框,引导其前往系统设置开启功能,而不是直接崩溃或静默失败。

java 复制代码
NfcAdapter nfcAdapter = NfcAdapter.getDefaultAdapter(this);
if (nfcAdapter == null) {
    // 设备没有 NFC 硬件
    tvStatus.setText("当前设备不支持 NFC");
    btnWrite.setEnabled(false);
    return;
}
if (!nfcAdapter.isEnabled()) {
    // 硬件存在但系统开关未开启
    tvStatus.setText("NFC 未开启,请前往系统设置开启");
    startActivity(new Intent(Settings.ACTION_NFC_SETTINGS));
    return;
}
// 硬件与开关都就绪
tvStatus.setText("NFC 已就绪,请将手机靠近标签");

设备兼容性矩阵:提前规避「机型坑」

不同厂商、不同 Android 版本对 NFC 的支持差异很大。建议在项目初期就建立一张兼容性矩阵,把已知的机型差异记录下来,作为测试与降级方案的依据:

设备类型 NFC 支持情况 常见问题 应对策略
高端旗舰(Pixel、三星 S 系列等) 完整支持读写、HCE、P2P 天线位置偏上或偏下 UI 引导用户调整持机位置
中端机型 通常支持读写 + HCE 部分阉割 P2P 或特定频段 降级适配,优先 NDEF 标准格式
低端 / 老旧机型 仅基础读卡 读写时序慢、易超时 增加重试机制,提示用户保持贴近
定制 ROM(部分国产) 支持但行为差异大 NFC 服务可能被阉割或延迟启动 真机交叉验证,必要时引导用户开启

提示:这张矩阵不是固定的,建议你在真机测试中持续补充。每发现一个「机型坑」,就记录到矩阵里,并配套对应的降级方案,这样规模化部署时就能大幅减少线上问题。

对于 Android 10 及以上版本,还需特别注意后台启动限制,确保 NFC 相关的广播接收器或服务能够在合规的前提下被正确触发。建议优先使用前台分发系统(见下一节)而非依赖隐式广播,既符合系统规范,又能获得更稳定的标签分发体验。

③ 前台分发系统搭建与 Activity 生命周期管理

Android 的 NFC 调度机制依赖于"前台分发系统"(Foreground Dispatch System)。当多个应用都注册了相同的 NFC 意图过滤器时,系统会将扫描到的标签优先分发给当前处于前台且优先级最高的 Activity。这意味着,我们必须严格管理 Activity 的生命周期,确保在用户可见时注册监听,在不可见时及时释放资源。

为什么需要前台分发系统?

在 Android 的 NFC 机制中,系统默认会按照「NDEF 标签 → 技术标签 → 所有标签」的优先级顺序,将扫描到的标签分发给匹配的 Activity。但这里有一个关键问题:如果多个应用都声明了相同的意图过滤器,系统会弹出选择框让用户手动选择,这严重破坏了「靠近即连接」的流畅体验。

前台分发系统正是为了解决这个问题而设计的。它允许当前处于前台的 Activity 抢占 NFC 标签的分发权,无需在 Manifest 中声明任何 intent-filter,就能直接接收标签事件。这意味着:

  • 无需弹窗:标签直接分发给你的 Activity,用户无需手动选择应用。
  • 优先级最高:即使其他应用也注册了相同的过滤器,前台分发依然优先。
  • 动态控制 :你可以在 onResume() 中启用、在 onPause() 中禁用,实现精确的生命周期管理。

前台分发的核心机制

标准的做法是在 onResume() 中启用前台分发,在 onPause() 中禁用它。示例代码如下:

java 复制代码
@Override
protected void onResume() {
    super.onResume();
    NfcAdapter adapter = NfcAdapter.getDefaultAdapter(this);
    if (adapter != null) {
        PendingIntent pendingIntent = PendingIntent.getActivity(
            this, 0, new Intent(this, getClass()).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
            PendingIntent.FLAG_MUTABLE
        );
        IntentFilter[] intentFilters = new IntentFilter[] {
            new IntentFilter(NfcAdapter.ACTION_NDEF_DISCOVERED),
            new IntentFilter(NfcAdapter.ACTION_TECH_DISCOVERED),
            new IntentFilter(NfcAdapter.ACTION_TAG_DISCOVERED)
        };
        String[][] techLists = new String[][] { new String[] { Ndef.class.getName() } };
        adapter.enableForegroundDispatch(this, pendingIntent, intentFilters, techLists);
    }
}

@Override
protected void onPause() {
    super.onPause();
    NfcAdapter adapter = NfcAdapter.getDefaultAdapter(this);
    if (adapter != null) {
        adapter.disableForegroundDispatch(this);
    }
}

这里有几个关键点需要特别注意:

  1. FLAG_ACTIVITY_SINGLE_TOP :它确保当标签被扫描到时,如果目标 Activity 已经在栈顶,系统会复用该实例并通过 onNewIntent() 传递新意图,而不是创建新的 Activity 实例。在 onNewIntent() 中处理实际的标签数据解析逻辑,可以有效避免界面闪烁和状态丢失。

  2. PendingIntent.FLAG_MUTABLE :从 Android 12(API 31)开始,系统强制要求 PendingIntent 必须显式声明可变性。使用 FLAG_MUTABLE 是因为 NFC 系统需要向该 PendingIntent 注入标签数据(EXTRA_TAG、EXTRA_NDEF_MESSAGES 等)。

  3. IntentFilter 的覆盖范围 :建议同时注册 ACTION_NDEF_DISCOVERED、ACTION_TECH_DISCOVERED 和 ACTION_TAG_DISCOVERED 三种动作,以覆盖不同类型的标签。其中 ACTION_TECH_DISCOVERED 需要配合 techLists 参数指定要匹配的技术类型。

生命周期管理的三个关键原则

前台分发系统的正确使用,本质上是对 Activity 生命周期的精细管理。以下是三个必须遵守的原则:

原则一:成对注册与释放

enableForegroundDispatch() 和 disableForegroundDispatch() 必须成对出现。在 onResume() 中启用、在 onPause() 中禁用,是最安全的选择。如果只注册不释放,会导致:

  • 应用退到后台后仍占用 NFC 分发权,其他应用无法正常接收标签。
  • 系统资源被无谓占用,增加功耗。
  • 在极端情况下,可能导致系统 NFC 服务异常。

原则二:避免在 onCreate() 中注册

有些开发者习惯在 onCreate() 中调用 enableForegroundDispatch(),这是错误的。因为 onCreate() 只在 Activity 首次创建时调用一次,而 onPause() 之后 Activity 可能仍然存活(如被对话框遮挡)。如果在 onCreate() 注册、onPause() 释放,那么从后台恢复时(onResume())将不会重新注册,导致标签无法被接收。

原则三:处理 onNewIntent() 的重复调用

由于 FLAG_ACTIVITY_SINGLE_TOP 的存在,同一个 Activity 实例可能被多次复用,onNewIntent() 也会被多次调用。因此,在 onNewIntent() 中处理标签时,要注意:

  • 每次调用都重新读取 intent.getAction(),因为不同动作对应不同的处理逻辑。
  • 避免在 onNewIntent() 中做耗时操作,应通过 Handler 或协程异步处理。
  • 如果上一次的读写任务尚未完成,需要做好状态管理,避免并发冲突。

完整的前台分发 + 生命周期管理模板

下面是一个更完整的模板,它把前台分发、生命周期管理和标签处理整合在一起,你可以直接套用到自己的项目中:

java 复制代码
public class NfcActivity extends AppCompatActivity {

    private NfcAdapter nfcAdapter;
    private PendingIntent pendingIntent;
    private IntentFilter[] intentFilters;
    private String[][] techLists;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_nfc);

        // 初始化 NFC 适配器
        nfcAdapter = NfcAdapter.getDefaultAdapter(this);
        if (nfcAdapter == null) {
            // 设备不支持 NFC,直接禁用相关功能
            return;
        }

        // 构建 PendingIntent:复用当前 Activity 实例
        pendingIntent = PendingIntent.getActivity(
            this, 0,
            new Intent(this, getClass()).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
            PendingIntent.FLAG_MUTABLE
        );

        // 注册三种标签发现动作,覆盖 NDEF 与非 NDEF 标签
        intentFilters = new IntentFilter[] {
            new IntentFilter(NfcAdapter.ACTION_NDEF_DISCOVERED),
            new IntentFilter(NfcAdapter.ACTION_TECH_DISCOVERED),
            new IntentFilter(NfcAdapter.ACTION_TAG_DISCOVERED)
        };

        // 指定要匹配的技术类型:这里匹配所有支持 NDEF 的标签
        techLists = new String[][] {
            new String[] { Ndef.class.getName() },
            new String[] { NdefFormatable.class.getName() },
            new String[] { MifareClassic.class.getName() },
            new String[] { IsoDep.class.getName() }
        };
    }

    @Override
    protected void onResume() {
        super.onResume();
        if (nfcAdapter != null && nfcAdapter.isEnabled()) {
            nfcAdapter.enableForegroundDispatch(this, pendingIntent, intentFilters, techLists);
        }
    }

    @Override
    protected void onPause() {
        super.onPause();
        if (nfcAdapter != null) {
            nfcAdapter.disableForegroundDispatch(this);
        }
    }

    @Override
    protected void onNewIntent(Intent intent) {
        super.onNewIntent(intent);
        // 每次都要重新设置 intent,确保 getIntent() 返回最新数据
        setIntent(intent);
        handleNfcIntent(intent);
    }

    private void handleNfcIntent(Intent intent) {
        String action = intent.getAction();
        if (NfcAdapter.ACTION_NDEF_DISCOVERED.equals(action)) {
            // NDEF 标签:直接解析 NdefMessage
            handleNdefDiscovered(intent);
        } else if (NfcAdapter.ACTION_TECH_DISCOVERED.equals(action)) {
            // 技术标签:根据 techList 选择技术类
            handleTechDiscovered(intent);
        } else if (NfcAdapter.ACTION_TAG_DISCOVERED.equals(action)) {
            // 兜底:所有标签都会触发
            handleTagDiscovered(intent);
        }
    }

    private void handleNdefDiscovered(Intent intent) {
        Parcelable[] rawMessages = intent.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES);
        if (rawMessages != null) {
            // 解析 NDEF 消息...
        }
    }

    private void handleTechDiscovered(Intent intent) {
        Tag tag = intent.getParcelableExtra(NfcAdapter.EXTRA_TAG);
        if (tag != null) {
            // 根据 tag.getTechList() 选择技术类...
        }
    }

    private void handleTagDiscovered(Intent intent) {
        Tag tag = intent.getParcelableExtra(NfcAdapter.EXTRA_TAG);
        if (tag != null) {
            // 兜底处理...
        }
    }
}

常见误区与排查清单

在实际开发中,前台分发系统是最容易出问题的环节之一。以下是几个高频踩坑点,建议对照排查:

误区 后果 正确做法
在 onCreate() 中注册前台分发 从后台恢复后无法接收标签 在 onResume() 中注册
忘记在 onPause() 中释放 后台占用分发权,其他应用异常 成对注册与释放
只注册 ACTION_NDEF_DISCOVERED 非 NDEF 标签无法触发 同时注册三种动作
未设置 launchMode="singleTop" 每次扫描都创建新实例,界面闪烁 Manifest 中声明 singleTop
使用 FLAG_IMMUTABLE Android 12+ 无法注入标签数据 使用 FLAG_MUTABLE
在 onNewIntent() 中做耗时操作 界面卡顿,ANR 风险 异步处理标签数据

提示:如果你发现「靠近标签后没有任何反应」,优先检查上述清单中的前三条------它们覆盖了 90% 以上的前台分发问题。剩下的 10% 往往与设备 ROM 的 NFC 服务实现有关,建议换一台原生系统设备交叉验证。

④ NDEF 消息格式解析与数据读写核心代码实现

NDEF(NFC Data Exchange Format)是 NFC 论坛定义的标准数据交换格式,它将数据封装在记录(Record)中,多条记录组成消息(Message)。解析 NDEF 消息是读取标签内容的核心步骤,而写入则要求我们严格按记录结构组装字节流。这一节我们把它讲透,并给出可直接复用的工具类。

NDEF 消息的物理结构

在动手写代码前,先理解 NDEF 的字节布局。一条 NdefRecord 由三部分组成:

  • 记录头(Header):包含 TNF(Type Name Format,类型名称格式)、标志位(如 MB 消息开始、ME 消息结束、SR 短记录、IL 内嵌负载长度)以及类型长度、负载长度、ID 长度等字段。
  • 类型字段(Type) :描述这条记录的数据类型,例如 RTD_TEXT(文本)、RTD_URI(URI)、RTD_ANDROID_APP(Android 应用)等。
  • 负载(Payload):真正的数据内容,其编码方式由 TNF 和 Type 共同决定。

理解这一点很重要:NDEF 的容量计算必须包含记录头、类型、语言码等额外开销,而不是只算纯文本长度。这也是很多新手「明明文本很短却提示空间不足」的根源。

读取:从 Intent 中提取并解析 NdefMessage

当接收到 ACTION_NDEF_DISCOVERED 意图后,可以从 Intent 中提取 NdefMessage 对象。每条 NdefRecord 包含类型名称格式(TNF)、类型字段和负载数据。常见的文本记录需要使用特定的语言代码前缀进行解码:

java 复制代码
private String parseTextRecord(NdefRecord record) {
    byte[] payload = record.getPayload();
    String textEncoding = ((payload[0] & 0x80) == 0) ? "UTF-8" : "UTF-16";
    int languageCodeLength = payload[0] & 0x3F;
    return new String(payload, languageCodeLength + 1, payload.length - languageCodeLength - 1, Charset.forName(textEncoding));
}

这段代码的关键在于首字节的位运算:最高位(bit 7)表示编码 ,为 0 是 UTF-8、为 1 是 UTF-16;低 6 位表示语言码长度。跳过语言码后才是真正的文本内容。

一个更健壮的解析工具类

上面的方法只处理了文本记录。实际项目中标签里可能同时存在文本、URI、MIME 等多种记录,我们需要一个能按类型分发的解析器:

java 复制代码
public class NdefParser {

    /**
     * 解析一条 NdefRecord,按类型返回可读字符串。
     * 支持文本、URI、MIME 三种常见类型,其余返回 null。
     */
    public static String parseRecord(NdefRecord record) {
        short tnf = record.getTnf();
        byte[] type = record.getType();

        // 1. 文本记录:TNF_WELL_KNOWN + RTD_TEXT
        if (tnf == NdefRecord.TNF_WELL_KNOWN
                && Arrays.equals(type, NdefRecord.RTD_TEXT)) {
            return parseText(record.getPayload());
        }

        // 2. URI 记录:TNF_WELL_KNOWN + RTD_URI
        if (tnf == NdefRecord.TNF_WELL_KNOWN
                && Arrays.equals(type, NdefRecord.RTD_URI)) {
            return parseUri(record.getPayload());
        }

        // 3. MIME 类型记录:TNF_MIME_MEDIA
        if (tnf == NdefRecord.TNF_MIME_MEDIA) {
            return new String(record.getPayload(), StandardCharsets.UTF_8);
        }

        // 4. 外部类型 / 未知类型:记录日志后跳过
        Log.w("NdefParser", "unsupported record, tnf=" + tnf);
        return null;
    }

    /** 解析文本负载:首字节高位=编码,低6位=语言码长度 */
    private static String parseText(byte[] payload) {
        if (payload == null || payload.length < 1) return null;
        String encoding = ((payload[0] & 0x80) == 0) ? "UTF-8" : "UTF-16";
        int langLen = payload[0] & 0x3F;
        return new String(payload, langLen + 1,
                payload.length - langLen - 1, Charset.forName(encoding));
    }

    /** 解析 URI 负载:首字节是 URI 前缀标识符,其后是具体地址 */
    private static String parseUri(byte[] payload) {
        if (payload == null || payload.length < 1) return null;
        // 0x00 表示无前缀,直接拼接;其余值对应 RFC 定义的常见前缀表
        String prefix = payload[0] == 0x00 ? "" : UriPrefixTable[payload[0] & 0xFF];
        return prefix + new String(payload, 1, payload.length - 1, StandardCharsets.UTF_8);
    }

    /** RFC 4186 定义的 URI 前缀标识符表(节选) */
    private static final String[] UriPrefixTable = {
            "", "http://www.", "https://www.", "http://", "https://",
            "tel:", "mailto:", "ftp://", "file://", "urn:"
    };
}

提示:parseUri 中的前缀表只列了前 10 项,完整定义有 0x00~0x23 共 36 项。实际使用时建议补全,或直接参考 Android 源码 NdefRecord 的 URI 前缀常量。

写入:构建 NdefRecord 并组装 NdefMessage

写入数据时,需要构建新的 NdefRecord 并组装成 NdefMessage。最省事的方式是使用 NdefRecord.createTextRecord(),它会自动带上正确的语言码和编码标记:

java 复制代码
public void writeTextToTag(Tag tag, String text) throws IOException, FormatException {
    Ndef ndef = Ndef.get(tag);
    if (ndef == null) {
        throw new IOException("该标签不支持 NDEF 格式");
    }

    try {
        ndef.connect();

        // 1. 写保护检查
        if (!ndef.isWritable()) {
            throw new IOException("标签已锁定,无法写入");
        }

        // 2. 构建记录与消息
        NdefRecord record = NdefRecord.createTextRecord("zh", text);
        NdefMessage message = new NdefMessage(new NdefRecord[]{record});

        // 3. 容量校验:用字节长度,而不是字符串 length()
        if (message.toByteArray().length > ndef.getMaxSize()) {
            throw new IOException("标签空间不足,需要 " + message.toByteArray().length
                    + " 字节,可用 " + ndef.getMaxSize() + " 字节");
        }

        // 4. 写入
        ndef.writeNdefMessage(message);
    } finally {
        try {
            ndef.close();
        } catch (IOException ignored) {
        }
    }
}

这里有几个必须注意的细节:

  1. connect() 必须调用 :写入操作必须获取 Ndef 技术的实例,并调用 connect() 建立连接,否则后续读写都会失败。
  2. 容量校验用字节 :message.toByteArray().length 才是真实占用,字符串的 length() 只算字符数,两者差距在中文场景下尤其明显。
  3. finally 中释放连接 :无论成功还是异常,都要 close(),避免占用标签资源导致后续操作失败。

处理非 NDEF 格式的原始标签

如果标签是出厂状态(未格式化),Ndef.get(tag) 会返回 null。此时需要先判断标签是否支持 NdefFormatable,用格式化 + 写入一步完成:

java 复制代码
public void formatAndWrite(Tag tag, String text) throws IOException, FormatException {
    NdefFormatable formatable = NdefFormatable.get(tag);
    if (formatable == null) {
        throw new IOException("该标签既不是 NDEF 格式,也无法格式化");
    }
    try {
        formatable.connect();

        // 1. 校验待写入内容
        if (text == null || text.isEmpty()) {
            throw new IOException("待写入内容为空");
        }

        // 2. 构建记录与消息
        NdefRecord record = NdefRecord.createTextRecord("zh", text);
        NdefMessage message = new NdefMessage(new NdefRecord[]{record});

        // 3. 容量校验:用字节长度,而不是字符串 length()
        if (message.toByteArray().length > formatable.getMaxSize()) {
            throw new IOException("标签空间不足,需要 " + message.toByteArray().length
                    + " 字节,可用 " + formatable.getMaxSize() + " 字节");
        }

        // 4. 格式化并写入初始数据
        formatable.format(message);
    } catch (TagLostException e) {
        // 用户在格式化过程中移开手机,提示重新靠近而非无限重试
        throw new IOException("标签已移开,请保持手机贴近标签后重试", e);
    } finally {
        try {
            formatable.close();
        } catch (IOException ignored) {
        }
    }
}

说明:NdefFormatable.format() 会清空标签原有内容 ,且部分标签一旦格式化后无法再改回原始格式。操作前务必确认标签用途,避免误格式化重要数据。上述实现补全了 finally 块,并额外加入了内容校验、容量校验与 TagLostException 兜底,确保代码可编译运行且更健壮。 警告:format() 会清空标签原有内容,且部分标签一旦格式化后无法再改回原始格式。操作前务必确认标签用途,避免误格式化重要数据。

对于非 NDEF 格式的原始标签(如 Mifare Classic),则需要直接使用 MifareClassic 或 IsoDep 等底层技术类进行扇区操作,但这要求开发者对具体标签的存储结构有深入了解,我们会在第⑥节详细展开。

小结

这一节的核心可以浓缩为三句话:

  • 读 :从 NdefMessage 中逐条解析 NdefRecord,按 TNF + Type 分发到文本、URI、MIME 等解析器。
  • 写 :用 createTextRecord() 构建记录,组装消息后先校验容量再写入,finally 中释放连接。
  • 兜底 :遇到未格式化的标签,用 NdefFormatable.format() 完成格式化 + 写入;遇到非 NDEF 标签,改用底层技术类。

掌握了这些,你就具备了读写 NDEF 标签的完整能力。下一节我们把这些模块串起来,构建一个从「靠近标签」到「完成读写」的完整流程。

⑤ 从零构建标签识别与数据写入完整流程

前面几节我们分别讲了硬件适配、权限配置、前台分发和 NDEF 消息解析,但这些都是「零件」。这一节我们把它们组装成一条完整的流水线:从用户把手机靠近标签的那一刻起,到数据成功写入或读取展示,每一步都串起来。理解了这条完整流程,你就能在真实项目中游刃有余。

整体流程概览

一次完整的标签交互,从用户操作到数据落盘,大致经历以下阶段:

第一步:识别标签技术类型

拿到 Tag 对象后,第一件事不是急着读写,而是先搞清楚这张标签是什么类型。不同类型的标签,操作方式完全不同:

java 复制代码
private void inspectTag(Tag tag) {
    String[] techList = tag.getTechList();
    StringBuilder sb = new StringBuilder("标签技术类型:\n");
    for (String tech : techList) {
        sb.append(" - ").append(tech).append("\n");
    }
    tvStatus.setText(sb.toString());

    // 根据技术类型选择处理路径
    if (techListContains(techList, Ndef.class.getName())) {
        handleNdefTag(tag);
    } else if (techListContains(techList, NdefFormatable.class.getName())) {
        handleFormatableTag(tag);
    } else if (techListContains(techList, MifareClassic.class.getName())) {
        handleMifareTag(tag);
    } else {
        tvStatus.setText("暂不支持的标签类型");
    }
}

private boolean techListContains(String[] techList, String techName) {
    for (String tech : techList) {
        if (tech.equals(techName)) {
            return true;
        }
    }
    return false;
}

第二步:NDEF 标签的读取流程

对于已经格式化为 NDEF 的标签,读取流程相对简单。核心是拿到 Ndef 实例后,连接、读取、解析、关闭:

java 复制代码
private void handleNdefTag(Tag tag) {
    Ndef ndef = Ndef.get(tag);
    if (ndef == null) {
        tvStatus.setText("无法获取 NDEF 实例");
        return;
    }
    try {
        ndef.connect();
        NdefMessage message = ndef.getNdefMessage();
        if (message == null) {
            tvStatus.setText("标签为空,尚未写入数据");
            return;
        }
        // 解析并展示所有记录
        StringBuilder sb = new StringBuilder();
        for (NdefRecord record : message.getRecords()) {
            String text = parseTextRecord(record);
            if (text != null) {
                sb.append(text).append("\n");
            }
        }
        tvStatus.setText("读取成功:\n" + sb.toString().trim());
    } catch (IOException | FormatException e) {
        tvStatus.setText("读取失败:" + e.getMessage());
    } finally {
        try {
            ndef.close();
        } catch (IOException ignored) {
        }
    }
}

第三步:写入前的完整校验链

写入是风险最高的操作,因为一旦写错,标签数据可能被破坏。因此写入前必须走完一整条校验链:

java 复制代码
private void writeToNdefTag(Tag tag, String text) {
    Ndef ndef = Ndef.get(tag);
    if (ndef == null) {
        tvStatus.setText("该标签不支持 NDEF 格式");
        return;
    }
    try {
        // 校验 1:连接
        ndef.connect();

        // 校验 2:是否可写
        if (!ndef.isWritable()) {
            tvStatus.setText("标签已锁定,无法写入");
            return;
        }

        // 校验 3:待写入内容是否为空
        if (text == null || text.isEmpty()) {
            tvStatus.setText("没有待写入的内容");
            return;
        }

        // 校验 4:组装消息并检查容量
        NdefRecord record = NdefRecord.createTextRecord("zh", text);
        NdefMessage message = new NdefMessage(new NdefRecord[]{record});
        int messageSize = message.toByteArray().length;
        int maxSize = ndef.getMaxSize();
        if (messageSize > maxSize) {
            tvStatus.setText("标签空间不足:需要 " + messageSize + " 字节,可用 " + maxSize + " 字节");
            return;
        }

        // 校验 5:写入
        ndef.writeNdefMessage(message);
        tvStatus.setText("写入成功!共 " + messageSize + " 字节");
    } catch (TagLostException e) {
        tvStatus.setText("标签已移开,请保持手机贴近后重试");
    } catch (IOException | FormatException e) {
        tvStatus.setText("写入失败:" + e.getMessage());
    } finally {
        try {
            ndef.close();
        } catch (IOException ignored) {
        }
    }
}

第四步:处理未格式化的新标签

很多新买的标签是出厂状态,还没有 NDEF 格式。此时 Ndef.get(tag) 会返回 null,但 NdefFormatable 可以帮我们完成格式化并写入初始数据:

java 复制代码
private void formatAndWrite(Tag tag, String text) {
    NdefFormatable formatable = NdefFormatable.get(tag);
    if (formatable == null) {
        tvStatus.setText("该标签不支持格式化");
        return;
    }
    try {
        formatable.connect();
        NdefRecord record = NdefRecord.createTextRecord("zh", text);
        NdefMessage message = new NdefMessage(new NdefRecord[]{record});
        formatable.format(message);
        tvStatus.setText("格式化并写入成功!");
    } catch (IOException | FormatException e) {
        tvStatus.setText("格式化失败:" + e.getMessage());
    } finally {
        try {
            formatable.close();
        } catch (IOException ignored) {
        }
    }
}

注意:格式化会清空标签原有内容,且部分标签一旦格式化后无法再改回原始格式。操作前务必确认标签用途,避免误格式化重要数据。

第五步:完整的流程编排

把上面几步串起来,就得到一条完整的「识别 → 读取 / 写入」流水线。在 onNewIntent 中统一入口:

java 复制代码
@Override
protected void onNewIntent(Intent intent) {
    super.onNewIntent(intent);
    setIntent(intent);

    String action = intent.getAction();
    if (NfcAdapter.ACTION_NDEF_DISCOVERED.equals(action)) {
        // 系统已识别为 NDEF 标签,直接解析
        Parcelable[] rawMessages = intent.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES);
        if (rawMessages != null) {
            displayNdefMessages(rawMessages);
        }
    } else if (NfcAdapter.ACTION_TAG_DISCOVERED.equals(action)) {
        // 兜底路径:先识别技术类型,再决定读写策略
        Tag tag = intent.getParcelableExtra(NfcAdapter.EXTRA_TAG);
        if (tag != null) {
            if (pendingWriteText != null) {
                // 用户点击了写入,走写入流程
                writeToTagWithStrategy(tag, pendingWriteText);
                pendingWriteText = null;
            } else {
                // 默认走读取流程
                inspectTag(tag);
            }
        }
    }
}

private void writeToTagWithStrategy(Tag tag, String text) {
    String[] techList = tag.getTechList();
    if (techListContains(techList, Ndef.class.getName())) {
        writeToNdefTag(tag, text);
    } else if (techListContains(techList, NdefFormatable.class.getName())) {
        formatAndWrite(tag, text);
    } else {
        tvStatus.setText("该标签类型不支持文本写入");
    }
}

小结

这一节我们把「识别标签类型 → 读取 NDEF 数据 → 写入前校验 → 格式化新标签 → 流程编排」五步串成了一条完整流水线。核心要点是:先识别、再校验、后操作。识别决定了走哪条技术路径,校验避免了写入失败和数据损坏,而统一的流程编排让读取和写入两条路径在同一个入口下清晰分流。下一节我们将在此基础上,进一步讨论多类型标签的适配方案与异常场景处理。

⑥ 多类型标签适配方案与异常场景处理逻辑

真实世界里的 NFC 标签五花八门:有出厂即带 NDEF 格式的 NTAG 系列,也有需要密钥认证的 Mifare Classic,还有遵循 ISO 14443-4 的智能卡。如果代码里只写死 Ndef.get(tag) 一条路,遇到非 NDEF 标签就会直接返回 null,导致功能静默失败。这一节我们先把标签类型讲清楚,再给出异常场景的兜底处理逻辑,最后用一个可复用的适配工具类把「识别 → 分发 → 读写」串起来。

如何选择标签类型:一张决策树帮你快速选型

在动手写代码之前,先想清楚「你的业务到底需要哪种标签」。不同标签在容量、安全等级、成本上差异巨大,选错类型往往意味着后期返工。下面这张决策树可以帮你快速定位:

选型时除了容量,还要关注三个维度:

  • 读写距离:NTAG 系列通常支持 3~5 厘米稳定读写,Mifare Classic 略短,ISO 14443-4 智能卡则更依赖读头功率。距离越短,对用户持机姿势的要求越高。
  • 安全等级:NTAG 只有基础的 UID 锁定和密码保护,防复制能力弱;Mifare Classic 靠扇区密钥认证,但密钥算法已被破解,适合中低安全场景;DESFire 等更高端芯片才适合支付级需求。
  • 成本与供应链:NTAG213 单价最低、货源最稳定,适合大规模部署;Mifare Classic 需要向芯片厂商申请密钥,流程更重。

主流标签类型对比:一张表看懂差异

为了让你在选型时一目了然,这里把最常见的几类标签整理成对比表。建议收藏,遇到不确定的型号随时回来对照:

标签类型 典型型号 容量 是否 NDEF 安全机制 适用场景 适配技术类
NTAG213 NTAG213 144 B 是(出厂可格式化) UID 锁定、简易密码 名片、防伪、轻量数据 Ndef / NdefFormatable
NTAG215 NTAG215 504 B 是 UID 锁定、简易密码 游戏、App 联动、中等数据 Ndef / NdefFormatable
NTAG216 NTAG216 888 B 是 UID 锁定、简易密码 大容量文本、URL + 扩展数据 Ndef / NdefFormatable
Mifare Classic MF1S50 / MF1S70 1 KB / 4 KB 否(需自行封装) 扇区密钥认证(A/B) 门禁、票务、会员卡 MifareClassic
Mifare Ultralight Ultralight C 64 B 是 3DES 认证(C 型) 低成本票务、一次性凭证 MifareUltralight
ISO 14443-4 智能卡 DESFire / 各类 CPU 卡 4 KB+ 否(APDU 交互) 硬件级加密、密钥体系 支付、身份认证、高安全门禁 IsoDep

提示:NTAG 系列出厂时通常是「未格式化」状态,首次使用需要调用 NdefFormatable.format() 完成格式化;而 Mifare Classic 和 ISO 14443-4 智能卡本身就不是 NDEF 标签,必须走各自的技术类,不能指望 Ndef.get(tag) 返回非 null。

异常场景处理逻辑:把「崩溃」变成「提示」

多标签适配的难点不在「正常路径」,而在「异常路径」。下面这张表梳理了最常见的异常场景,以及对应的处理策略:

异常场景 抛出异常 根因 处理策略
读写过程中移开手机 TagLostException 射频连接中断 捕获后提示「请保持手机贴近标签」,允许用户重新靠近,最多重试 2 次
标签未格式化 FormatException 出厂状态,无 NDEF 结构 调用 NdefFormatable.format() 完成格式化并写入初始数据
标签空间不足 IOException(写入时) 记录头 + 类型 + 语言码开销超限 写入前用 message.toByteArray().length 与 getMaxSize() 比较
标签已锁定 IOException(isWritable() 为 false) 出厂或业务侧已写保护 提示「标签已锁定」,不要尝试强制写入
标签类型不支持 Ndef.get(tag) 返回 null 非 NDEF 标签(Mifare / IsoDep) 走 MifareClassic / IsoDep 分支,或提示「该标签类型暂不支持」
密钥认证失败 IOException(authenticateSectorWithKey) 密钥错误或扇区未授权 记录日志,提示「认证失败」,不要无限重试
连接超时 IOException(connect()) 射频信号弱或标签损坏 提示「连接失败,请调整位置后重试」,并释放连接

处理异常的核心原则是:能恢复的重试,不能恢复的提示,绝不静默失败 。TagLostException 属于可恢复场景,但也要限制重试次数,避免在射频中断时反复占用资源;FormatException 和「标签已锁定」属于不可恢复场景,直接提示用户即可。

一个可复用的标签适配工具类

把上面的识别与异常处理逻辑封装成一个工具类,业务层只需要调用 handleTag(tag, callback),就能自动完成「识别类型 → 分发 → 读写 → 异常兜底」。下面是完整实现:

java 复制代码
public class NfcTagAdapter {

    private static final String TAG = "NfcTagAdapter";
    private static final int MAX_RETRY = 2;

    public interface TagCallback {
        void onNdefRead(String text);
        void onNdefWriteSuccess();
        void onMifareRead(String data);
        void onError(String message);
    }

    public static void handleTag(Tag tag, TagCallback callback) {
        if (tag == null) {
            callback.onError("标签为空,请重新靠近");
            return;
        }

        // 第一步:识别标签技术类型
        String[] techList = tag.getTechList();
        if (techList == null || techList.length == 0) {
            callback.onError("无法识别标签技术类型");
            return;
        }

        // 第二步:按优先级分发------NDEF 优先,其次 Mifare,最后 IsoDep
        if (contains(techList, Ndef.class.getName())) {
            handleNdefTag(tag, callback);
        } else if (contains(techList, MifareClassic.class.getName())) {
            handleMifareTag(tag, callback);
        } else if (contains(techList, IsoDep.class.getName())) {
            callback.onError("ISO 14443-4 智能卡需走 APDU 交互,请使用专用模块");
        } else {
            callback.onError("暂不支持的标签类型:" + Arrays.toString(techList));
        }
    }

    private static void handleNdefTag(Tag tag, TagCallback callback) {
        Ndef ndef = Ndef.get(tag);
        if (ndef == null) {
            // 标签存在但未格式化,尝试用 NdefFormatable 格式化
            NdefFormatable formatable = NdefFormatable.get(tag);
            if (formatable != null) {
                callback.onError("标签未格式化,请先完成格式化");
                return;
            }
            callback.onError("该标签不支持 NDEF 格式");
            return;
        }

        try {
            ndef.connect();
            if (!ndef.isWritable()) {
                callback.onError("标签已锁定,无法写入");
                return;
            }
            // 读取 NDEF 消息
            NdefMessage message = ndef.getNdefMessage();
            if (message != null) {
                StringBuilder sb = new StringBuilder();
                for (NdefRecord record : message.getRecords()) {
                    String text = parseTextRecord(record);
                    if (text != null) {
                        sb.append(text).append("\n");
                    }
                }
                callback.onNdefRead(sb.toString().trim());
            } else {
                callback.onError("标签内容为空");
            }
        } catch (TagLostException e) {
            Log.e(TAG, "tag lost", e);
            callback.onError("标签已移开,请保持手机贴近标签后重试");
        } catch (IOException | FormatException e) {
            Log.e(TAG, "ndef operation failed", e);
            callback.onError("NDEF 读写失败:" + e.getMessage());
        } finally {
            try {
                ndef.close();
            } catch (IOException ignored) {
            }
        }
    }

    private static void handleMifareTag(Tag tag, TagCallback callback) {
        MifareClassic mifare = MifareClassic.get(tag);
        if (mifare == null) {
            callback.onError("无法获取 Mifare Classic 实例");
            return;
        }

        try {
            mifare.connect();
            int sectorCount = mifare.getSectorCount();
            StringBuilder sb = new StringBuilder();

            // 遍历扇区,尝试用默认密钥认证并读取
            for (int sector = 0; sector < sectorCount; sector++) {
                boolean authenticated = false;
                // 先试 KeyA,再试 KeyB
                for (byte[] key : new byte[][]{MifareClassic.KEY_DEFAULT, MifareClassic.KEY_MIFARE_APPLICATION_DIRECTORY}) {
                    try {
                        mifare.authenticateSectorWithKeyA(sector, key);
                        authenticated = true;
                        break;
                    } catch (IOException ignored) {
                        // 继续尝试下一个密钥
                    }
                }
                if (!authenticated) {
                    continue; // 该扇区未授权,跳过
                }

                int blockCount = mifare.getBlockCountInSector(sector);
                for (int block = 0; block < blockCount; block++) {
                    byte[] data = mifare.readBlock(mifare.sectorToBlock(sector) + block);
                    sb.append(new String(data, StandardCharsets.UTF_8)).append("\n");
                }
            }
            callback.onMifareRead(sb.toString().trim());
        } catch (IOException e) {
            Log.e(TAG, "mifare read failed", e);
            callback.onError("Mifare 读取失败:" + e.getMessage());
        } finally {
            try {
                mifare.close();
            } catch (IOException ignored) {
            }
        }
    }

    private static boolean contains(String[] techList, String techName) {
        for (String tech : techList) {
            if (tech.equals(techName)) {
                return true;
            }
        }
        return false;
    }

    private static String parseTextRecord(NdefRecord record) {
        try {
            if (record.getTnf() != NdefRecord.TNF_WELL_KNOWN) {
                return null;
            }
            if (!Arrays.equals(record.getType(), NdefRecord.RTD_TEXT)) {
                return null;
            }
            byte[] payload = record.getPayload();
            if (payload.length < 1) return null;
            String textEncoding = ((payload[0] & 0x80) == 0) ? "UTF-8" : "UTF-16";
            int languageCodeLength = payload[0] & 0x3F;
            return new String(payload, languageCodeLength + 1,
                    payload.length - languageCodeLength - 1,
                    Charset.forName(textEncoding));
        } catch (Exception e) {
            Log.e(TAG, "parse failed", e);
            return null;
        }
    }
}

这个工具类的核心价值在于把「识别」和「异常」都收敛到一处 :业务层不再关心标签是 NTAG 还是 Mifare,只需要实现 TagCallback 回调,就能拿到统一的读取结果或错误提示。后续如果要扩展新的标签类型(如 MifareUltralight),只需在 handleTag() 里增加一个分支即可,不影响上层逻辑。

小结

多类型标签适配的本质,是「先识别、再分发、后兜底」:通过 Tag.getTechList() 判断标签类型,按 NDEF → Mifare → IsoDep 的优先级选择技术类;遇到异常时,能恢复的重试、不能恢复的提示、绝不静默失败。把这一套逻辑封装成工具类,你的 NFC 功能就能在五花八门的标签面前保持稳定,这也是从 Demo 走向生产级的关键一步。

⑦ 常见读写失败报错分析与调试排查步骤

读写失败是 NFC 开发中最常见的痛点。很多问题并非代码逻辑错误,而是硬件状态、标签类型、时序或系统限制共同作用的结果。这一节我们把高频报错逐一拆解,并给出可落地的排查步骤,帮你从「对着异常发呆」变成「按图索骥」。

高频报错速查表

先给出一张速查表,遇到报错先对照定位,再往下看详细分析:

报错 / 异常 典型触发场景 根因方向 首选排查动作
getDefaultAdapter() 返回 null 设备无 NFC 硬件或系统服务未就绪 硬件缺失 / ROM 阉割 检查系统设置,换原生设备验证
TagLostException 读写过程中移开手机 射频连接中断 提示用户保持贴近,重试
IOException: Tag is not NDEF formatted 标签未格式化 出厂状态 / 格式被清空 用 NdefFormatable 格式化
FormatException 标签数据不符合 NDEF 结构 数据损坏 / 非 NDEF 内容 读取原始字节,检查记录头
Tag is read only / 写保护 标签被锁定 标签写保护 / 一次性标签 检查 isWritable(),换标签
Tag is full / 空间不足 写入内容超限 容量计算错误 / 标签过小 用 toByteArray().length 对比 getMaxSize()
IOException: Transceive failed 底层射频交互失败 天线位置 / 距离 / 干扰 调整位置,检查金属干扰
SecurityException 权限或安全策略拦截 权限缺失 / 系统限制 检查 Manifest 与运行时权限
Ndef.get(tag) 返回 null 标签不支持 NDEF 标签类型不匹配 用 getTechList() 判断技术类型

逐条深入分析

1. getDefaultAdapter() 返回 null

这是最「迷惑」的报错之一------明明手机支持 NFC,却拿不到适配器。根因通常是系统 NFC 服务未就绪,或定制 ROM 阉割了 NFC 服务。排查步骤:

  • 到「设置 → 连接与共享 → NFC」确认开关已开启。
  • 部分国产 ROM 在「开发者选项」中还有独立的 NFC 开关,一并检查。
  • 换一台原生系统设备交叉验证,排除 ROM 阉割的可能。

2. TagLostException(标签丢失)

这是读写过程中最常见的异常,本质是射频连接中断------用户移开手机、标签离开天线感应区,或金属物体遮挡了射频场。处理原则是「提示而非重试」:

java 复制代码
catch (TagLostException e) {
    // 不要无限重试,提示用户重新贴近
    tvStatus.setText("标签已移开,请保持手机贴近标签后重试");
}

若业务需要,可设置最多重试 2 次,避免在射频中断时反复占用资源。

3. IOException: Tag is not NDEF formatted

说明标签还是出厂状态,尚未格式化为 NDEF 格式。首次使用前需要调用 NdefFormatable.format(message) 完成格式化并写入初始数据:

java 复制代码
NdefFormatable formatable = NdefFormatable.get(tag);
if (formatable != null) {
    formatable.connect();
    formatable.format(message);
    formatable.close();
}

注意:格式化会清空标签原有内容,且部分标签一旦格式化后无法再改回原始格式,操作前请确认标签用途。

4. FormatException(格式异常)

标签数据不符合 NDEF 结构,常见于标签被部分写入、数据损坏,或读取了非 NDEF 内容。排查时不要直接放弃,先读取原始字节定位问题:

java 复制代码
catch (FormatException e) {
    // 读取原始字节,检查记录头是否完整
    byte[] raw = tag.getId(); // 或从 NdefMessage 的原始字节分析
    Log.e(TAG, "format error, raw bytes=" + Arrays.toString(raw), e);
}

重点检查记录头(Header)的 MB/ME 标志位、TNF 字段和长度字段是否一致。

5. 写保护 / 只读标签

部分标签出厂即锁定,或业务上被写保护。写入前务必检查 isWritable():

java 复制代码
if (!ndef.isWritable()) {
    tvStatus.setText("标签已锁定,无法写入");
    return;
}

一次性标签(如某些防伪标签)写入后即永久锁定,设计业务时要注意区分「可重写」与「一次性」标签。

6. 空间不足

这是新手最容易踩的坑------「明明文本很短却提示空间不足」。根因是 NDEF 消息的容量计算包含记录头、类型、语言码等额外开销,并非只算纯文本长度。写入前务必用字节数对比:

java 复制代码
if (message.toByteArray().length > ndef.getMaxSize()) {
    tvStatus.setText("标签空间不足,当前内容需 " + message.toByteArray().length
            + " 字节,标签容量 " + ndef.getMaxSize() + " 字节");
    return;
}

如果确实超限,可压缩内容或改用容量更大的标签(如 NTAG216)。

7. IOException: Transceive failed

这是底层射频交互失败的通用报错,根因多为物理因素:天线位置偏差、距离过远、金属物体干扰射频场。排查步骤:

  • 调整手机与标签的相对位置,确保天线区域对准。
  • 移除标签附近的金属物体(如金属卡片、硬币)。
  • 检查标签是否损坏------换一张新标签对比测试。

8. SecurityException

权限或系统安全策略拦截。检查 AndroidManifest.xml 是否声明了 android.permission.NFC,以及 uses-feature 是否正确配置。Android 12+ 对后台 NFC 访问限制更严,若在后台触发,需改用前台服务。

9. Ndef.get(tag) 返回 null

说明标签不支持 NDEF 格式(如 Mifare Classic、ISO 14443-4 智能卡)。不要盲目调用 Ndef.get(tag),先用 getTechList() 判断技术类型,再选择对应的技术类:

java 复制代码
String[] techList = tag.getTechList();
for (String tech : techList) {
    if (Ndef.class.getName().equals(tech)) {
        // NDEF 标签,走 Ndef 路径
    } else if (MifareClassic.class.getName().equals(tech)) {
        // Mifare Classic,走扇区读写路径
    } else if (IsoDep.class.getName().equals(tech)) {
        // ISO 14443-4,走 APDU 路径
    }
}

通用调试排查五步法

无论遇到哪种报错,都可以按以下五步系统排查,避免「头痛医头」:

  1. 确认硬件与系统状态 :getDefaultAdapter() 非 null、isEnabled() 为 true,系统 NFC 开关已开启。
  2. 确认标签类型 :用 tag.getTechList() 打印技术类型,判断走 NDEF 还是非 NDEF 路径。
  3. 确认物理接触:天线位置是否对准、距离是否过远、有无金属干扰------这是最容易被忽略的一环。
  4. 确认数据格式 :写入前用 toByteArray().length 对比 getMaxSize();读取时检查记录头与编码标记。
  5. 确认异常处理 :所有读写操作包在 try-catch 中,TagLostException 提示重试、IOException 记录日志、FormatException 跳过损坏记录,finally 中关闭连接。

调试辅助:开启系统 NFC 日志

Android 系统层会输出 NFC 相关日志,可辅助定位底层问题。在 adb 中执行:

bash 复制代码
adb logcat -s NfcService NfcDispatcher NfcAdapter

重点关注 NfcDispatcher 的分发日志------它能告诉你标签被分发到了哪个 Activity、匹配了哪个 intent-filter,是排查「靠近标签没反应」的利器。

提示:把上面这张速查表打印出来贴在工位,遇到报错先对照定位,再按五步法排查。绝大多数读写失败都能在 5 分钟内定位到根因,剩下的 10% 往往与特定机型 ROM 的 NFC 实现有关,建议换原生设备交叉验证。

⑧ 安全认证机制引入与敏感数据加密存储

NFC 标签最大的特点是「靠近即读写」,这既是便利,也是风险------任何人只要有一台支持 NFC 的手机,就能贴近标签读取甚至篡改数据。如果你的标签里存放的是门禁凭证、防伪码、资产编号或会员余额等敏感信息,就必须引入安全认证与加密机制,否则数据等于「裸奔」。这一节我们系统讲解如何在 Android 端构建一套可落地的 NFC 安全方案。

威胁模型:先想清楚要防谁

在设计安全方案前,先明确你的威胁模型。不同场景对安全等级的要求差异巨大,盲目堆砌加密反而会增加复杂度与功耗。常见的威胁与应对如下:

威胁类型 攻击方式 典型场景 应对手段
数据窃取 贴近标签直接读取明文 门禁卡、会员卡被复制 写入前加密 payload
数据篡改 改写标签内容伪造凭证 防伪溯源被伪造 附加数字签名,读时验签
标签复制 克隆整张标签 门禁卡被克隆 绑定设备指纹 / 动态挑战码
重放攻击 截获合法读写过程后重放 一次性凭证被复用 加入时间戳 / 随机数 nonce

提示:如果你的业务只是「写入一段公开的说明文字」,那完全不需要加密;但凡是涉及身份、金额、权限的数据,请务必按下文方案处理。

方案一:AES 对称加密 + 数字签名(推荐)

这是最常用、也最容易落地的组合方案。核心思路是:

  • 加密:用 AES 对 payload 加密,保证「别人读不到明文」。
  • 签名:用 HMAC-SHA256 对密文做签名,保证「别人改不了内容」。

写入时先加密再签名,读取时先验签再解密。这样即使攻击者读到了密文,没有密钥也无法解密;即使他篡改了密文,验签也会失败。

密钥管理:不要硬编码在客户端

密钥是整套方案的命脉。千万不要把 AES 密钥硬编码在 Java 代码里------反编译 APK 就能拿到。推荐的做法是:

  1. 后端动态下发:App 启动时通过 HTTPS 接口向服务端请求密钥,密钥按设备维度下发,可随时吊销。
  2. Android Keystore 保护:把密钥存入 Android Keystore,由系统级安全硬件(TEE/SE)保护,即使 root 也无法直接导出。
  3. 设备指纹绑定 :用 Settings.Secure.ANDROID_ID 或 Keystore 生成的密钥对,把标签数据与特定设备绑定,防止标签被整体复制到其他设备使用。

下面给出一个可直接复用的 NfcCryptoHelper 工具类,它封装了 AES-GCM 加密与 HMAC 签名:

java 复制代码
public class NfcCryptoHelper {

    private static final String TAG = "NfcCrypto";
    private static final String AES_TRANSFORM = "AES/GCM/NoPadding";
    private static final int GCM_TAG_LENGTH = 128; // 认证标签长度,单位 bit
    private static final String HMAC_ALGORITHM = "HmacSHA256";

    // 密钥由后端下发,这里仅作演示占位;生产环境务必通过安全通道获取
    private final SecretKey aesKey;
    private final SecretKey hmacKey;

    public NfcCryptoHelper(byte[] aesKeyBytes, byte[] hmacKeyBytes) {
        this.aesKey = new SecretKeySpec(aesKeyBytes, "AES");
        this.hmacKey = new SecretKeySpec(hmacKeyBytes, HMAC_ALGORITHM);
    }

    /**
     * 加密并签名:返回 [IV + 密文 + 签名] 的拼接字节
     */
    public byte[] encryptAndSign(byte[] plainText) throws Exception {
        // 1. AES-GCM 加密,自动生成随机 IV
        Cipher cipher = Cipher.getInstance(AES_TRANSFORM);
        cipher.init(Cipher.ENCRYPT_MODE, aesKey);
        byte[] cipherText = cipher.doFinal(plainText);
        byte[] iv = cipher.getIV();

        // 2. 对「IV + 密文」计算 HMAC 签名
        byte[] payload = concat(iv, cipherText);
        byte[] signature = hmac(payload);

        // 3. 拼接返回:IV(12) + 密文 + 签名(32)
        return concat(payload, signature);
    }

    /**
     * 验签并解密:先验签,通过后才解密
     */
    public byte[] verifyAndDecrypt(byte[] data) throws Exception {
        if (data.length < 12 + 32) {
            throw new IllegalArgumentException("数据长度异常,可能被篡改");
        }
        // 拆分 IV、密文、签名
        byte[] iv = Arrays.copyOfRange(data, 0, 12);
        int cipherLen = data.length - 12 - 32;
        byte[] cipherText = Arrays.copyOfRange(data, 12, 12 + cipherLen);
        byte[] signature = Arrays.copyOfRange(data, data.length - 32, data.length);

        // 1. 验签:签名不匹配说明数据被篡改
        byte[] payload = Arrays.copyOfRange(data, 0, data.length - 32);
        byte[] expected = hmac(payload);
        if (!MessageDigest.isEqual(expected, signature)) {
            throw new SecurityException("签名校验失败,数据可能被篡改");
        }

        // 2. 验签通过后解密
        Cipher cipher = Cipher.getInstance(AES_TRANSFORM);
        cipher.init(Cipher.DECRYPT_MODE, aesKey, new GCMParameterSpec(GCM_TAG_LENGTH, iv));
        return cipher.doFinal(cipherText);
    }

    private byte[] hmac(byte[] data) throws Exception {
        Mac mac = Mac.getInstance(HMAC_ALGORITHM);
        mac.init(hmacKey);
        return mac.doFinal(data);
    }

    private static byte[] concat(byte[] a, byte[] b) {
        byte[] result = new byte[a.length + b.length];
        System.arraycopy(a, 0, result, 0, a.length);
        System.arraycopy(b, 0, result, a.length, b.length);
        return result;
    }
}

说明:这里使用 AES-GCM 而非 ECB/CBC,是因为 GCM 是认证加密模式,密文自带完整性校验,能同时防窃取与防篡改。配合额外的 HMAC 签名,可以做到「先验签、再解密」,进一步防止针对密文的替换攻击。

写入加密数据到标签

把加密后的字节写入 NDEF 标签时,建议使用 MIME 类型记录,而不是普通文本记录,这样读取端能明确识别「这是加密数据」:

java 复制代码
private NdefMessage buildEncryptedMessage(String plainText) throws Exception {
    // 1. 加密 + 签名
    byte[] encrypted = nfcCryptoHelper.encryptAndSign(plainText.getBytes(StandardCharsets.UTF_8));

    // 2. 用 MIME 类型封装,便于读取端识别
    NdefRecord record = NdefRecord.createMime(
            "application/x-nfc-encrypted",
            encrypted
    );
    return new NdefMessage(new NdefRecord[]{record});
}

读取并验签解密

读取时,先判断记录是否为加密 MIME 类型,再走「验签 → 解密」流程:

java 复制代码
private String parseEncryptedRecord(NdefRecord record) {
    try {
        // 仅处理我们约定的加密 MIME 类型
        if (record.getTnf() != NdefRecord.TNF_MIME_MEDIA) {
            return null;
        }
        if (!"application/x-nfc-encrypted".equals(new String(record.getType(), StandardCharsets.US_ASCII))) {
            return null;
        }
        // 验签 + 解密
        byte[] plain = nfcCryptoHelper.verifyAndDecrypt(record.getPayload());
        return new String(plain, StandardCharsets.UTF_8);
    } catch (SecurityException e) {
        Log.e(TAG, "数据被篡改或签名不匹配", e);
        return "【安全警告】数据校验失败,可能被篡改";
    } catch (Exception e) {
        Log.e(TAG, "解密失败", e);
        return "【安全警告】解密失败,密钥可能不匹配";
    }
}

方案二:Mifare Classic 扇区密码认证

如果你的标签是 Mifare Classic(如常见的 S50 卡),它本身自带扇区密码认证机制。每个扇区有独立的 KeyA / KeyB,只有通过认证才能读写该扇区。这相当于硬件层面的访问控制,比纯软件加密多一道防线:

java 复制代码
private void writeWithMifareAuth(Tag tag, byte[] data) {
    MifareClassic mfc = MifareClassic.get(tag);
    if (mfc == null) {
        Log.e(TAG, "该标签不是 Mifare Classic");
        return;
    }
    try {
        mfc.connect();
        int sectorCount = mfc.getSectorCount();
        for (int sector = 0; sector < sectorCount; sector++) {
            // 用 KeyA 认证扇区(默认密钥 FF FF FF FF FF FF 仅用于出厂卡)
            if (!mfc.authenticateSectorWithKeyA(sector, MifareClassic.KEY_DEFAULT)) {
                Log.e(TAG, "扇区 " + sector + " 认证失败");
                continue;
            }
            // 认证通过后按块写入
            int blockIndex = mfc.sectorToBlock(sector);
            mfc.writeBlock(blockIndex, data);
        }
    } catch (IOException e) {
        Log.e(TAG, "Mifare 写入失败", e);
    } finally {
        try {
            mfc.close();
        } catch (IOException ignored) {
        }
    }
}

注意:出厂 Mifare Classic 的默认密钥是公开的(FF FF FF FF FF FF),任何人都能认证。上线前务必用 mfc.writeBlock() 修改扇区密钥,否则默认密钥形同虚设。修改密钥后要妥善保管,丢失将无法再读写该扇区。

方案三:动态挑战码防重放

对于门禁、一次性凭证等场景,仅加密还不够------攻击者可以录下「合法读写过程」后重放。此时需要引入**动态挑战码(Challenge-Response)**机制:

  1. 读取端生成一个随机数 nonce,写入标签或通过其他通道发给服务端。
  2. 服务端用密钥对 nonce 计算响应 response = HMAC(key, nonce)。
  3. 标签或客户端校验 response 是否匹配,匹配才放行。

由于每次 nonce 都不同,录下的旧响应无法复用,从而阻断重放攻击。实现时建议在加密 payload 中附加时间戳与随机数:

java 复制代码
private byte[] buildAntiReplayPayload(String data) throws Exception {
    // 组装:时间戳 + 随机数 + 业务数据
    JSONObject json = new JSONObject();
    json.put("ts", System.currentTimeMillis());
    json.put("nonce", UUID.randomUUID().toString());
    json.put("data", data);
    return json.toString().getBytes(StandardCharsets.UTF_8);
}

读取端验签通过后,再校验时间戳是否在有效窗口内(如 5 分钟),超时则拒绝,从而兼顾防重放与防过期。

安全实践清单

把上面的方案落到工程里,建议遵守以下清单:

  • 密钥永不明文入库:AES/HMAC 密钥通过 HTTPS 从后端下发,或存入 Android Keystore,禁止硬编码。
  • 先验签、再解密:读取流程务必先做完整性校验,通过后才解密,避免对篡改数据执行解密逻辑。
  • Mifare 改默认密钥:出厂默认密钥公开,上线前必须修改扇区密钥。
  • 敏感数据最小化:标签容量有限,只存必要数据,身份凭证尽量存 ID 而非完整信息,明细放后端。
  • 失败要「静默」:验签失败、解密失败时,只提示「数据校验失败」,不要暴露具体异常栈,避免给攻击者提供线索。
  • 与 HCE 联动:如果同一套凭证还要用于卡模拟(见第⑭节),密钥与验签逻辑应复用同一套,避免两套体系各自为政。

安全不是「加个密就完事」,而是一套贯穿写入、读取、密钥管理、异常处理的完整闭环。把上面这些手段组合起来,你的 NFC 数据才能真正做到「读不懂、改不了、复制不走」。

⑨ 低功耗优化策略与后台持续监听实现

NFC 的射频交互本身耗电极低,但不合理的轮询、频繁的读写重试、以及后台无节制的监听,会让功耗问题被无限放大。这一节我们从「功耗从哪来」讲起,给出可落地的优化策略,并重点解决「后台持续监听」这个高频需求------它也是 Android 12+ 上最容易踩坑的地方。

功耗从哪来:先定位三大耗电元凶

在动手优化前,先搞清楚 NFC 相关的功耗主要消耗在哪里:

耗电来源 触发场景 影响程度
射频轮询(RF Polling) 前台分发开启时,系统持续扫描周围标签 中,持续存在
读写重试风暴 标签移开后仍无限重试 connect() / writeNdefMessage() 高,短时飙升
后台无界监听 用隐式广播或无限循环在后台扫描标签 极高,且被系统限制

关键认知:NFC 单次交互的耗电可以忽略,真正耗电的是「无效的重复扫描」。优化方向不是「少用 NFC」,而是「减少无效的射频活动」。

策略一:按需开启前台分发,避免常驻扫描

前台分发系统(见第③节)在 onResume() 开启后,系统会持续进行射频轮询。如果你的应用只在「用户点击扫描按钮」时才需要读卡,就不要让前台分发常驻:

java 复制代码
@Override
protected void onResume() {
    super.onResume();
    // 只有用户明确进入「扫描模式」时才开启前台分发
    if (scanModeEnabled) {
        enableForegroundDispatch();
    }
}

private void startScanMode() {
    scanModeEnabled = true;
    enableForegroundDispatch();
    tvStatus.setText("扫描模式已开启,请靠近标签");
}

private void stopScanMode() {
    scanModeEnabled = false;
    disableForegroundDispatch();
    tvStatus.setText("扫描模式已关闭");
}

这样用户不扫描时,系统不会持续轮询,功耗自然降下来。这是最直接、也最容易被忽略的优化点。

策略二:读写重试要「限次 + 退避」,杜绝风暴

标签移开导致的 TagLostException 是正常现象,但如果你在 catch 里无限重试,就会在射频中断时反复发起无效连接,造成短时功耗飙升。正确做法是限次重试 + 指数退避:

java 复制代码
private static final int MAX_RETRY = 2;
private static final long BASE_DELAY_MS = 300L;

private void writeWithRetry(Tag tag, NdefMessage message, int attempt) {
    Ndef ndef = Ndef.get(tag);
    if (ndef == null) {
        tvStatus.setText("该标签不支持 NDEF 格式");
        return;
    }
    try {
        ndef.connect();
        if (!ndef.isWritable()) {
            tvStatus.setText("标签已锁定,无法写入");
            return;
        }
        ndef.writeNdefMessage(message);
        tvStatus.setText("写入成功!");
    } catch (TagLostException e) {
        if (attempt < MAX_RETRY) {
            // 指数退避:300ms → 600ms,给用户时间重新贴近
            long delay = BASE_DELAY_MS * (1L << attempt);
            tvStatus.setText("标签已移开,请保持贴近,正在重试...");
            new Handler(Looper.getMainLooper()).postDelayed(
                () -> writeWithRetry(tag, message, attempt + 1), delay);
        } else {
            tvStatus.setText("多次尝试失败,请重新靠近标签");
        }
    } catch (IOException | FormatException e) {
        tvStatus.setText("写入失败:" + e.getMessage());
    } finally {
        try { ndef.close(); } catch (IOException ignored) {}
    }
}

要点:最多重试 2 次,每次间隔递增,既给了用户反应时间,又不会在射频中断时疯狂重连。

策略三:后台持续监听的合规方案(Android 12+ 重点)

这是本节的核心需求。很多业务(物流巡检、资产盘点)需要应用退到后台后仍能持续监听标签。但 Android 对后台 NFC 扫描限制极严,尤其是 Android 12(API 31)之后:

  • 禁止隐式广播 :后台无法通过 ACTION_TAG_DISCOVERED 等隐式广播接收标签。
  • 禁止无限轮询:后台不能无限循环扫描,系统会直接回收。

合规的做法是前台服务(Foreground Service)+ 常驻通知。前台服务拥有较高的优先级,系统不会轻易回收,且可以持续持有 NFC 分发权。下面是一个完整的后台监听服务:

java 复制代码
public class NfcBackgroundService extends Service {

    private static final String CHANNEL_ID = "nfc_background";
    private static final int NOTIFICATION_ID = 1001;

    private NfcAdapter nfcAdapter;
    private PendingIntent pendingIntent;
    private IntentFilter[] intentFilters;
    private String[][] techLists;

    @Override
    public void onCreate() {
        super.onCreate();
        createNotificationChannel();
        startForeground(NOTIFICATION_ID, buildNotification("NFC 后台监听中"));

        nfcAdapter = NfcAdapter.getDefaultAdapter(this);
        if (nfcAdapter == null) {
            stopSelf();
            return;
        }

        // 构建 PendingIntent:指向本服务,复用实例
        pendingIntent = PendingIntent.getService(
            this, 0,
            new Intent(this, NfcBackgroundService.class)
                .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
            PendingIntent.FLAG_MUTABLE
        );

        intentFilters = new IntentFilter[] {
            new IntentFilter(NfcAdapter.ACTION_NDEF_DISCOVERED),
            new IntentFilter(NfcAdapter.ACTION_TECH_DISCOVERED),
            new IntentFilter(NfcAdapter.ACTION_TAG_DISCOVERED)
        };

        techLists = new String[][] {
            new String[] { Ndef.class.getName() },
            new String[] { NdefFormatable.class.getName() }
        };
    }

    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
        // 前台服务模式下,在 onStartCommand 中启用前台分发
        if (nfcAdapter != null && nfcAdapter.isEnabled()) {
            nfcAdapter.enableForegroundDispatch(this, pendingIntent, intentFilters, techLists);
        }
        return START_STICKY; // 被系统杀死后尝试重建
    }

    @Override
    public void onDestroy() {
        super.onDestroy();
        if (nfcAdapter != null) {
            nfcAdapter.disableForegroundDispatch(this);
        }
    }

    @Override
    public IBinder onBind(Intent intent) {
        return null; // 非绑定式服务
    }

    private Notification buildNotification(String text) {
        Intent notificationIntent = new Intent(this, MainActivity.class);
        PendingIntent contentIntent = PendingIntent.getActivity(
            this, 0, notificationIntent, PendingIntent.FLAG_MUTABLE);
        return new NotificationCompat.Builder(this, CHANNEL_ID)
            .setContentTitle("NFC 服务运行中")
            .setContentText(text)
            .setSmallIcon(R.drawable.ic_nfc)
            .setContentIntent(contentIntent)
            .setOngoing(true)
            .build();
    }

    private void createNotificationChannel() {
        NotificationChannel channel = new NotificationChannel(
            CHANNEL_ID, "NFC 后台服务",
            NotificationManager.IMPORTANCE_LOW);
        NotificationManager manager = getSystemService(NotificationManager.class);
        manager.createNotificationChannel(channel);
    }
}

在 AndroidManifest.xml 中声明前台服务类型(Android 14+ 要求声明 foregroundServiceType):

xml 复制代码
<service
    android:name=".NfcBackgroundService"
    android:exported="false"
    android:foregroundServiceType="connectedDevice" />

并在启动时声明权限(Android 14+):

xml 复制代码
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />

启动服务:

java 复制代码
ContextCompat.startForegroundService(this, new Intent(this, NfcBackgroundService.class));

注意:前台服务不是「无限后台」。系统仍可能在资源紧张时回收它,START_STICKY 只能保证被杀死后尝试重建。对于真正的「熄屏持续监听」,建议结合 WorkManager 管理任务窗口,并在业务上接受「非实时」的延迟。

策略四:熄屏场景的取舍------降低轮询频率

如果你的业务允许「非实时」监听(如巡检时每 30 秒扫一次),可以进一步降低功耗:不要常驻前台服务,而是用 WorkManager 周期性唤醒扫描:

java 复制代码
public class NfcScanWorker extends Worker {

    public NfcScanWorker(Context context, WorkerParameters params) {
        super(context, params);
    }

    @NonNull
    @Override
    public Result doWork() {
        // 这里执行一次「短时扫描」:开启前台分发 → 等待标签 → 关闭
        // 扫描窗口控制在 5 秒内,避免长时间占用射频
        return Result.success();
    }
}

// 每 30 秒执行一次扫描任务
PeriodicWorkRequest scanRequest =
    new PeriodicWorkRequest.Builder(NfcScanWorker.class, 30, TimeUnit.SECONDS)
        .build();
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
    "nfc_scan", ExistingPeriodicWorkPolicy.KEEP, scanRequest);

这种「短窗口扫描 + 周期唤醒」的模式,功耗远低于常驻前台服务,适合对实时性要求不高的场景。

低功耗优化清单

优化项 做法 收益
按需开启前台分发 仅在扫描模式下启用 消除常驻轮询
读写重试限次退避 最多 2 次,间隔递增 杜绝重试风暴
后台监听用前台服务 前台服务 + 常驻通知 合规且稳定
熄屏用 WorkManager 周期短窗口扫描 大幅降低功耗
关闭不必要的技术匹配 techLists 只列需要的技术类 减少无效分发

提示:功耗优化没有「银弹」,核心是减少无效的射频活动。先想清楚你的业务到底需要「实时监听」还是「周期扫描」,再选择对应的方案------前者用前台服务,后者用 WorkManager,两者功耗相差一个数量级。

⑩ 真实落地案例复盘与规模化部署注意事项

前面九节我们解决了「怎么把 NFC 功能跑通」的问题,这一节则聚焦「怎么把它稳定地跑在成百上千台设备上」。我会用一个真实的仓储管理系统改造案例做复盘,把踩过的坑、优化过的方案、以及规模化部署时必须提前想清楚的事项,一次性讲透。

案例背景:从「逐个写卡」到「批量写卡」

在某大型仓储管理系统的 NFC 改造项目中,我们最初面临的核心痛点是标签批量写入效率极低。仓库每天要为新入库的数千件物品写入标签,而最初的方案是「逐个连接、写入、断开」------每件物品都要经历一次完整的射频会话,单件处理耗时超过 3 秒。按这个速度,一天写 5000 件就要 4 个多小时,完全无法支撑业务节奏。

优化思路 :我们意识到,NFC 标签本身支持防冲突协议(Anti-Collision),读写器可以在一次射频场中同时「看到」多张标签。于是我们引入了批量指令队列 ,把「连接 → 写入 → 断开」的串行流程,改为「一次射频会话内连续写入多条记录」的批处理模式。配合在供应链上游提前完成标签的 NDEF 格式化,现场只需做「贴近 → 写入 → 换下一张」的连续操作,最终把单件处理时间压缩到 0.8 秒以内,整体效率提升了近 4 倍。

复盘要点:批量写入的收益来自「减少射频会话次数」,而不是「加快单次写入速度」。射频连接的建立与断开是最大的时间开销,能复用一次会话就尽量复用。

案例复盘:三个被低估的坑

这个项目里,真正让我们返工的不是代码逻辑,而是下面三个「看起来很小」的问题:

坑一:标签批次质量参差不齐

同一批采购的标签,实际容量和读写稳定性并不完全一致。有的标签标称 144 字节,实际可用却不足;有的标签在写入时频繁超时。我们最初没有做「入库抽检」,导致现场写卡时频繁报错。

解法 :在供应链上游建立标签抽检流程------每批次随机抽取 5% 用自动化脚本做「格式化 + 写入 + 回读校验」,把不合格批次直接退回,从源头杜绝问题标签流入现场。

坑二:现场操作员的持机姿势不统一

不同操作员拿手机的方式差异很大,有的习惯握手机中部,有的握上部,导致天线位置对不准标签,读取失败率居高不下。我们最初只在 UI 上写了「请靠近标签」这种笼统提示,效果很差。

解法:在界面上绘制手机轮廓并高亮标注天线区域,配合「请将手机背部上方区域贴近标签」的具体文案;同时把读取失败时的提示改为「请调整位置后重试」,并统计失败时的持机角度,反向优化引导动画。

坑三:后台批量任务与前台分发的冲突

批量写卡时,我们曾尝试把写卡逻辑放到后台服务里,结果发现前台分发系统只在 Activity 前台时生效,后台服务根本收不到标签事件,导致批量任务「静默失败」。

解法 :把批量写卡设计成「前台 Activity + 前台服务」的组合------Activity 负责展示进度和接收标签,前台服务负责维持射频分发权,两者通过 onNewIntent() 传递标签数据。这也印证了第⑨节强调的「后台监听必须用前台服务」的结论。

规模化部署注意事项

把单个 Demo 变成可规模化部署的生产系统,需要提前想清楚下面几件事:

1. 标签预初始化:把复杂度前移到供应链

不要在仓库现场做「格式化 + 写入」两步操作,而是在供应链上游(标签出厂或入库时)就完成 NDEF 格式化和基础数据写入。现场只需做「贴近 → 校验 → 补充写入」的轻量操作,大幅降低现场操作复杂度,也减少了对操作员技能的依赖。

2. 版本兼容矩阵:用数据代替「感觉」

建立一张详细的「设备型号 × Android 版本 × 标签类型」兼容矩阵,把每一台测试机的表现记录下来。针对老旧机型(如 Android 8 以下)提供降级方案------比如降低写入速率、增加重试次数、或改用更基础的标签类型。这张矩阵要持续补充,每发现一个「机型坑」就记录一条,规模化部署时能省下大量线上排查时间。

3. 远程监控与埋点:让问题「自己浮出来」

在应用中埋点统计 NFC 交互的成功率、失败类型分布、耗时分布及设备型号,上报到后端。这样一旦某个批次标签或某类机型出现批量性问题,你能在数据面板上第一时间发现,而不是等用户投诉。建议至少埋以下指标:

埋点指标 说明 用途
交互成功率 成功 / 总尝试次数 发现整体健康度下降
失败类型分布 TagLostException / FormatException 等占比 定位根因方向
单次耗时 从贴近到完成的毫秒数 发现性能劣化
设备型号 + 系统版本 与结果关联 定位机型 / 版本问题
标签 UID 批次 关联标签批次 发现批次性质量问题

4. 灰度发布与回滚预案

规模化部署不要「一把梭」。建议先在一小批设备(如 50 台)上灰度运行,观察埋点数据稳定后再全量铺开。同时准备好「降级开关」------比如当某类标签写入失败率超过阈值时,自动切换到备用写入策略或提示用户更换标签,避免业务中断。

5. 操作员培训与 SOP 文档

再好的技术方案,最终都要靠人来操作。为现场操作员编写一份简洁的 SOP(标准作业流程),明确「持机姿势、贴近位置、失败处理、异常上报」四个要点,并配一张手机天线位置示意图。培训到位能显著降低人为因素导致的失败率。

小结

NFC 技术的落地不仅仅是代码的实现,更是对硬件特性、用户习惯和业务场景的深度融合。回顾这个案例,真正拉开差距的不是「能不能读写」,而是「批量效率、异常兜底、数据可观测」这些生产级细节。只有经过细致的打磨和严格的测试,才能让这项「近在咫尺」的技术真正发挥其巨大的商业价值。

⑪ 完整实战代码:NFC 读写一体化 Activity

为了让你能快速跑通整个流程,这里给出一个可直接运行的 NfcReadWriteActivity。它整合了前台分发、NDEF 解析、文本写入、异常处理与用户反馈,覆盖了前文提到的绝大多数关键点。你只需将其放入工程,并在 AndroidManifest.xml 中声明该 Activity 即可。

功能设计:一条 Activity 打通读写两条路径

在动手看代码之前,先明确这个 Activity 要承担哪些职责,避免「代码很长但不知道在干嘛」:

  • 读取路径 :靠近标签自动触发 ACTION_NDEF_DISCOVERED,解析 NDEF 文本记录并展示在状态栏。
  • 写入路径 :用户先在输入框填写内容,点击「写入标签」后靠近标签,触发 ACTION_TAG_DISCOVERED 完成写入。
  • 异常兜底:标签不支持 NDEF、已锁定、空间不足、读写过程中移开手机等场景,全部转化为界面提示,绝不静默失败或崩溃。
  • 生命周期管理 :在 onResume() 注册前台分发、onPause() 释放,配合 launchMode="singleTop" 复用实例。

下面是完整代码:

java 复制代码
public class NfcReadWriteActivity extends AppCompatActivity {

    private static final String TAG = "NfcReadWrite";
    private NfcAdapter nfcAdapter;
    private TextView tvStatus;
    private EditText etInput;
    private Button btnWrite;
    private String pendingWriteText = null;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_nfc_read_write);

        tvStatus = findViewById(R.id.tv_status);
        etInput = findViewById(R.id.et_input);
        btnWrite = findViewById(R.id.btn_write);

        nfcAdapter = NfcAdapter.getDefaultAdapter(this);
        if (nfcAdapter == null) {
            tvStatus.setText("当前设备不支持 NFC");
            btnWrite.setEnabled(false);
            return;
        }
        if (!nfcAdapter.isEnabled()) {
            tvStatus.setText("NFC 未开启,请前往系统设置开启");
            startActivity(new Intent(Settings.ACTION_NFC_SETTINGS));
        }

        btnWrite.setOnClickListener(v -> {
            String text = etInput.getText().toString().trim();
            if (text.isEmpty()) {
                Toast.makeText(this, "请输入要写入的内容", Toast.LENGTH_SHORT).show();
                return;
            }
            writeTag(text);
        });
    }

    @Override
    protected void onResume() {
        super.onResume();
        if (nfcAdapter != null) {
            PendingIntent pendingIntent = PendingIntent.getActivity(
                    this, 0,
                    new Intent(this, getClass()).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
                    PendingIntent.FLAG_MUTABLE);
            IntentFilter[] filters = new IntentFilter[]{
                    new IntentFilter(NfcAdapter.ACTION_NDEF_DISCOVERED),
                    new IntentFilter(NfcAdapter.ACTION_TAG_DISCOVERED)
            };
            String[][] techLists = new String[][]{new String[]{Ndef.class.getName()}};
            nfcAdapter.enableForegroundDispatch(this, pendingIntent, filters, techLists);
        }
    }

    @Override
    protected void onPause() {
        super.onPause();
        if (nfcAdapter != null) {
            nfcAdapter.disableForegroundDispatch(this);
        }
    }

    @Override
    protected void onNewIntent(Intent intent) {
        super.onNewIntent(intent);
        setIntent(intent);
        handleIntent(intent);
    }

    private void handleIntent(Intent intent) {
        String action = intent.getAction();
        if (NfcAdapter.ACTION_NDEF_DISCOVERED.equals(action)) {
            // 读取场景:解析 NDEF 消息
            Parcelable[] rawMessages = intent.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES);
            if (rawMessages != null) {
                StringBuilder sb = new StringBuilder();
                for (Parcelable raw : rawMessages) {
                    NdefMessage msg = (NdefMessage) raw;
                    for (NdefRecord record : msg.getRecords()) {
                        String text = parseTextRecord(record);
                        if (text != null) {
                            sb.append(text).append("\n");
                        }
                    }
                }
                tvStatus.setText("读取成功:\n" + sb.toString().trim());
            }
        } else if (NfcAdapter.ACTION_TAG_DISCOVERED.equals(action)) {
            // 写入场景:用户点击"写入"后靠近标签
            Tag tag = intent.getParcelableExtra(NfcAdapter.EXTRA_TAG);
            if (tag != null) {
                writeTagToNdef(tag);
            }
        }
    }

    private void writeTag(String text) {
        // 提示用户靠近标签,写入动作在 onNewIntent 中完成
        tvStatus.setText("请将手机靠近 NFC 标签...");
        pendingWriteText = text;
    }

    private void writeTagToNdef(Tag tag) {
        Ndef ndef = Ndef.get(tag);
        if (ndef == null) {
            tvStatus.setText("该标签不支持 NDEF 格式");
            return;
        }
        try {
            ndef.connect();
            if (!ndef.isWritable()) {
                tvStatus.setText("标签已锁定,无法写入");
                return;
            }
            if (pendingWriteText == null || pendingWriteText.isEmpty()) {
                tvStatus.setText("没有待写入的内容,请先输入文本");
                return;
            }
            NdefRecord record = NdefRecord.createTextRecord("zh", pendingWriteText);
            NdefMessage message = new NdefMessage(new NdefRecord[]{record});
            if (message.toByteArray().length > ndef.getMaxSize()) {
                tvStatus.setText("标签空间不足");
                return;
            }
            ndef.writeNdefMessage(message);
            tvStatus.setText("写入成功!");
        } catch (TagLostException e) {
            // 用户在读写过程中移开手机,提示重新靠近而非无限重试
            Log.e(TAG, "tag lost during write", e);
            tvStatus.setText("标签已移开,请保持手机贴近标签后重试");
        } catch (IOException | FormatException e) {
            Log.e(TAG, "write failed", e);
            tvStatus.setText("写入失败:" + e.getMessage());
        } finally {
            try {
                ndef.close();
            } catch (IOException ignored) {
            }
        }
    }

    private String parseTextRecord(NdefRecord record) {
        try {
            // 仅解析 TNF_WELL_KNOWN + RTD_TEXT 类型的文本记录
            if (record.getTnf() != NdefRecord.TNF_WELL_KNOWN) {
                return null;
            }
            if (!Arrays.equals(record.getType(), NdefRecord.RTD_TEXT)) {
                return null;
            }
            byte[] payload = record.getPayload();
            if (payload.length < 1) return null;
            // 首字节最高位(bit 7)表示编码:0=UTF-8,1=UTF-16
            String textEncoding = ((payload[0] & 0x80) == 0) ? "UTF-8" : "UTF-16";
            // 低 6 位表示语言码长度
            int languageCodeLength = payload[0] & 0x3F;
            return new String(payload, languageCodeLength + 1,
                    payload.length - languageCodeLength - 1,
                    Charset.forName(textEncoding));
        } catch (Exception e) {
            Log.e(TAG, "parse failed", e);
            return null;
        }
    }
}

配套布局文件

配套的布局文件 activity_nfc_read_write.xml 也很简单,包含一个状态文本、一个输入框和一个写入按钮:

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:padding="16dp">

    <TextView
        android:id="@+id/tv_status"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="请将手机靠近 NFC 标签"
        android:textSize="16sp" />

    <EditText
        android:id="@+id/et_input"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="输入要写入标签的内容"
        android:inputType="text" />

    <Button
        android:id="@+id/btn_write"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="写入标签" />
</LinearLayout>

Manifest 声明与运行要点

最后,别忘了在 AndroidManifest.xml 中声明该 Activity,并设置 launchMode="singleTop",否则前台分发无法正确复用实例:

xml 复制代码
<activity
    android:name=".NfcReadWriteActivity"
    android:launchMode="singleTop"
    android:exported="true" />

运行时有几个细节值得留意:

  • 写入前先点按钮 :必须先点击「写入标签」让 pendingWriteText 有值,再靠近标签才会触发写入;否则靠近标签只会走读取路径。
  • 读取与写入的意图区分 :NDEF 标签靠近时优先触发 ACTION_NDEF_DISCOVERED(读取),而写入依赖 ACTION_TAG_DISCOVERED 兜底,两者互不干扰。
  • FLAG_MUTABLE 是硬要求 :Android 12+ 必须使用 FLAG_MUTABLE,否则系统无法向 PendingIntent 注入标签数据。
  • 异常全部转提示 :TagLostException、IOException、FormatException 都被捕获并映射为界面文案,符合前文强调的「生产级」要求。

这段代码把「读取」与「写入」两条路径都串了起来:靠近标签自动读取并展示内容;点击写入按钮后再靠近标签,则把输入框中的文本写入。所有异常都被捕获并转化为界面提示,符合前文强调的「生产级」要求。你可以在此基础上继续扩展加密、多记录组装等能力。

⑫ 常见问题 FAQ

Q1:为什么我的手机明明支持 NFC,但 getDefaultAdapter() 却返回 null?

A:这通常不是硬件问题,而是系统层面 NFC 服务未就绪。请先到「设置 → 连接与共享 → NFC」确认开关已打开;部分定制 ROM 还会在「开发者选项」中提供 NFC 相关开关。若确认已开启仍返回 null,建议换一台原生系统设备交叉验证,排除厂商 ROM 阉割 NFC 服务的可能。

Q2:靠近标签后没有任何反应,onNewIntent 没有被调用?

A:优先检查三点:一是 AndroidManifest.xml 中 Activity 是否设置了 launchMode="singleTop";二是 onResume() 里是否正确调用了 enableForegroundDispatch(),且 onPause() 中对应调用了 disableForegroundDispatch();三是 IntentFilter 是否覆盖了你标签的实际类型(NDEF 标签用 ACTION_NDEF_DISCOVERED,非 NDEF 标签需用 ACTION_TECH_DISCOVERED 或 ACTION_TAG_DISCOVERED)。

Q3:读取中文内容时出现乱码,是什么原因?

A:绝大多数情况是编码判断错误。NDEF 文本记录的首字节最高位(bit 7)表示编码:为 0 是 UTF-8,为 1 是 UTF-16。请务必按 (payload[0] & 0x80) == 0 来区分,而不是写死 UTF-8。另外,写入时建议统一使用 NdefRecord.createTextRecord("zh", text),它会自动带上正确的语言码和编码标记。

Q4:写入时报「Tag is not NDEF formatted」,怎么处理?

A:说明这张标签还是出厂状态,尚未格式化为 NDEF 格式。首次使用前需要调用 NdefFormatable.format(message) 完成格式化并写入初始数据。注意:格式化会清空标签原有内容,且部分标签一旦格式化后无法再改回原始格式,操作前请确认标签用途。

Q5:写入时提示「标签空间不足」,但我的文本明明很短?

A:NDEF 消息的容量计算包含记录头、类型、语言码等额外开销,并非只算纯文本长度。写入前务必用 message.toByteArray().length 与 ndef.getMaxSize() 比较,而不是用字符串的 length()。如果确实超限,可考虑压缩内容或改用容量更大的标签(如 NTAG216)。

Q6:读写过程中用户移开手机,抛出了 TagLostException,应该怎么处理?

A:这是正常现象,不要无限重试。正确做法是捕获异常后提示用户「请保持手机贴近标签」,并允许用户重新靠近后再次触发。若业务需要,可设置最多重试 2 次,避免在射频中断时反复占用资源。

Q7:Mifare Classic 标签读写失败,和 NDEF 标签有什么不同?

A:Mifare Classic 不是 NDEF 标签,不能直接用 Ndef 技术类操作,需要改用 MifareClassic 类按扇区、按块读写,且读写前必须通过 authenticateSectorWithKeyA/B() 完成密钥认证。建议先通过 Tag.getTechList() 判断标签类型,再选择对应的技术类,避免盲目调用 Ndef.get(tag) 返回 null。

Q8:应用退到后台后无法持续监听标签,怎么办?

A:Android 对后台 NFC 扫描限制很严格,尤其是 Android 12 及以上版本。如果业务确实需要后台监听(如物流巡检),应使用前台服务(Foreground Service)并配合常驻通知,同时用 WorkManager 或 JobScheduler 管理任务窗口,而不是在后台无限轮询。否则系统会直接回收你的监听能力。

Q9:标签上的敏感数据如何防止被复制或篡改?

A:不要以明文写入。写入前用 AES/RSA 加密 payload,并附加数字签名;读取时先验签再解密。密钥不要硬编码在客户端,应通过后端接口动态下发或基于设备指纹生成。对于 Mifare Classic,还可利用其扇区密码认证机制,只对通过认证的请求开放读写。

Q10:模拟器上能调试 NFC 功能吗?

A:不能完全依赖模拟器。Android 模拟器对 NFC 硬件的模拟非常有限,无法真实还原射频交互、天线位置和标签时序。务必在多款不同品牌、不同 Android 版本的真机上做覆盖测试,尤其是读写时序和异常场景,真机表现与模拟器往往差异很大。

Q11:同一张标签反复读写几次后,偶尔会失败,是什么原因?

A:这通常是射频连接不稳定或标签进入「忙状态」导致的。建议在每次读写前都重新 connect(),并在 finally 中确保 close() 释放连接;同时为读写操作增加 1~2 次重试,重试前稍作延时(如 100ms)。另外,部分低端标签在连续高频写入后需要短暂「休息」,可适当降低写入频率。

Q12:如何判断一张标签是 NDEF 格式还是 Mifare Classic 等原始格式?

A:通过 Tag.getTechList() 返回的技术数组判断。如果包含 Ndef.class.getName(),说明是 NDEF 格式,可直接用 Ndef.get(tag) 操作;如果只包含 MifareClassic.class.getName() 等,则是原始格式,需要按对应技术类处理。你也可以在 ACTION_TECH_DISCOVERED 的 techLists 参数中按需匹配。

Q13:写入时提示「Tag is read-only」或「标签已锁定」,还能恢复吗?

A:不能。标签一旦被置为只读(锁定),其内容就永久不可改写,这是标签的物理特性,用于防止数据被篡改。如果业务需要反复写入,请在写入前检查 ndef.isWritable(),并在产品设计上区分「一次性写入」与「可重写」两种标签,避免误锁。

Q14:ACTION_NDEF_DISCOVERED 和 ACTION_TAG_DISCOVERED 有什么区别?

A:ACTION_NDEF_DISCOVERED 只在标签包含可解析的 NDEF 消息时触发,优先级最高;ACTION_TAG_DISCOVERED 是兜底动作,任何标签(包括非 NDEF 格式)都会触发,优先级最低。实际开发中建议同时注册两者:NDEF 标签走前者做标准解析,非 NDEF 标签走后者做技术类适配。

Q15:多个应用都注册了 NFC 分发,如何保证我的应用优先收到标签?

A:使用前台分发系统(enableForegroundDispatch())即可抢占分发权,它优先于所有静态注册的 intent-filter。只要你的 Activity 处于前台并正确注册,标签就会直接分发给它,无需用户手动选择。注意在 onPause() 中及时释放,否则会阻塞其他应用。

Q16:HCE 卡模拟和读写模式能同时使用吗?

A:可以,但要注意场景区分。读写模式是手机主动读外部标签,HCE 是手机扮演卡被读写器读取,两者在射频层面互斥(同一时刻只能处于一种模式)。实际业务中通常按需切换:需要读卡时启用前台分发,需要被读时确保 HCE 服务已声明且为默认应用。Android 系统会自动根据当前交互场景调度,开发者无需手动切换。

如果你在集成过程中还遇到其他问题,欢迎在评论区留言,我会结合工程实践持续补充。

⑬ 总结

至此,我们已经完整走通了 Android NFC 开发的全链路:从最底层的射频原理与硬件适配,到 Manifest 权限配置、前台分发系统搭建,再到 NDEF 消息解析、多标签适配、异常处理、安全加密与低功耗优化,最后通过一个可直接运行的 NfcReadWriteActivity 把「读取」与「写入」两条路径串了起来,并补充了 HCE 卡模拟方案,让「手机读卡」与「手机即卡」两条路径形成完整闭环。

全文核心要点回顾

回顾全文,有几个贯穿始终的关键认知值得再次强调:

  1. 硬件适配是前提 :不同厂商、不同 Android 版本的设备对 NFC 的支持差异很大。开发初期务必通过 NfcAdapter.getDefaultAdapter() 与 isEnabled() 做运行时校验,并在 UI 上引导用户将天线区域贴近标签,从源头减少物理层面的读取失败。

  2. 生命周期管理是骨架 :前台分发系统依赖 Activity 的 onResume() / onPause() 正确注册与释放监听,配合 launchMode="singleTop" 复用实例,才能保证标签扫描时界面不闪烁、状态不丢失。

  3. 数据格式是核心 :NDEF 消息的解析与写入必须严格遵循记录结构,编码判断(UTF-8 / UTF-16)和容量校验(toByteArray().length vs getMaxSize())是新手最容易踩坑、也最影响稳定性的两个细节。

  4. 多标签适配是广度 :不同标签(NDEF、Mifare Classic、IsoDep 等)对应不同的技术类与读写路径,务必先通过 Tag.getTechList() 判断类型再选择方案,避免盲目调用导致失败。

  5. 生产级思维是分水岭:异常捕获映射、写保护检查、连接释放、数据加密与签名验证、低功耗策略------这些看似「额外」的工作,恰恰是 Demo 与可落地产品的本质区别。

  6. 真机测试是底线:模拟器无法真实还原射频交互,务必在多款不同品牌、不同系统版本的真机上做覆盖测试,并借助埋点远程监控交互成功率,才能及时发现批次性质量问题。

一张图看懂全链路

下面是本文覆盖的完整技术链路,你可以把它当作开发时的「路线图」:

落地建议:从 Demo 到产品的三步走

如果你正准备把本文的方案应用到真实项目,建议按以下三步推进:

  1. 先跑通最小闭环 :用第⑪节的 NfcReadWriteActivity 在一台真机上跑通「读」与「写」两条路径,确认硬件、权限、前台分发都正常。
  2. 再补齐生产要素:接入异常兜底、写保护检查、数据加密与签名验证,并针对目标机型建立兼容性矩阵。
  3. 最后做规模化验证:多机型覆盖测试 + 埋点监控交互成功率,再考虑批量写卡、后台监听等进阶能力。

结语

NFC 的价值不在于「能刷卡」,而在于它把物理世界与数字应用高效地连接起来。当你把硬件特性、用户习惯与业务场景真正融合在一起,并经过细致的打磨与严格的测试,这套方案就能在智能门禁、资产盘点、防伪溯源、物流巡检等场景中稳定落地,发挥出实实在在的商业价值。

希望这份指南能帮你少走弯路。如果你在集成过程中还有新的问题或踩到新的坑,欢迎在评论区留言,我会结合工程实践持续补充。

⑭ HCE 卡模拟:让手机变成一张 NFC 卡

前面几节我们一直在讲「手机读卡、写卡」,也就是把手机当作读写器去操作外部标签。但 NFC 还有另一条同样重要的路径------卡模拟(Card Emulation) :让手机本身扮演一张 NFC 卡,被其他读写器(如门禁读头、POS 机、闸机)识别和交互。Android 从 4.4(API 19)开始提供了 HCE(Host-based Card Emulation,主机卡模拟) 方案,让应用无需依赖安全芯片(SE)也能实现卡模拟,大大降低了门槛。

HCE 与 SE 卡模拟的区别

在 HCE 出现之前,卡模拟通常依赖设备内置的 安全芯片(Secure Element,SE),由芯片厂商和运营商主导,开发者很难介入。HCE 则把 APDU(Application Protocol Data Unit)指令的响应逻辑放到了应用层(主机 CPU)来处理,开发者可以完全掌控协议实现。两者虽然都能让手机扮演一张 NFC 卡,但在实现原理、安全等级、适用场景上差异明显,下面用一张表帮你快速看清:

对比维度 SE 卡模拟(Secure Element) HCE 卡模拟(Host-based)
运行载体 独立安全芯片(SE),与主 CPU 物理隔离 应用层(主机 CPU),无独立安全硬件
APDU 处理位置 芯片内部固件处理,应用无法介入 应用内 processCommandApdu() 处理,完全可控
安全等级 高,硬件级密钥保护,防物理攻击 中,依赖软件加密,密钥暴露面更大
密钥存储 芯片内安全存储,无法被读取 需自行加密存储,建议配合后端动态下发
功耗与响应 低功耗、响应快 依赖主 CPU,功耗和延迟相对更高
部署门槛 需芯片厂商/运营商预置,开发者难介入 普通支持 NFC 的 Android 设备即可运行
协议定制 受限,通常由芯片厂商固化 灵活,可自定义 ISO 14443-4 之上的任意协议
更新方式 需更换物理卡或重新发卡 应用内升级即可,无需换卡
典型场景 银行卡支付、电子钱包、运营商 SIM 卡 门禁、会员卡、票务、身份认证等

从这张表可以看出,SE 的核心优势是「硬件级安全」 ,适合对密钥保护和防篡改要求极高的支付类场景;HCE 的核心优势是「灵活与低门槛」,适合安全等级要求适中、需要快速迭代的业务。具体来说:

  • 无需 SE 芯片:普通支持 NFC 的 Android 设备即可运行,覆盖面广。
  • 协议可定制:你可以实现 ISO 14443-4 之上的任意应用协议,如 ISO 7816-4 的 APDU 交互。
  • 更新灵活:卡逻辑在应用内,升级无需更换物理卡。

当然,HCE 也有局限:它依赖主机 CPU 处理,功耗和响应速度不如 SE;且无法实现需要硬件级密钥保护的场景(如银行卡支付令牌)。因此 HCE 更适合门禁、会员卡、票务、身份认证等对安全等级要求适中的场景。如果你的业务涉及支付或高价值资产,建议优先评估 SE 方案,或采用「HCE + 后端动态密钥」的混合架构来弥补软件侧的安全短板。

声明 HCE 服务

HCE 的核心是一个继承 HostApduService 的服务。首先在 AndroidManifest.xml 中声明它,并绑定一个描述「卡 AID(Application ID)」的 XML 资源:

xml 复制代码
<service
    android:name=".MyHostApduService"
    android:exported="true"
    android:permission="android.permission.BIND_NFC_SERVICE">
    <intent-filter>
        <action android:name="android.nfc.cardemulation.action.HOST_APDU_SERVICE" />
    </intent-filter>
    <meta-data
        android:name="android.nfc.cardemulation.host_apdu_service"
        android:resource="@xml/apduservice" />
</service>

对应的 res/xml/apduservice.xml 定义该服务支持的 AID 列表:

xml 复制代码
<host-apdu-service xmlns:android="http://schemas.android.com/apk/res/android"
    android:description="@string/hce_service_desc"
    android:requireDeviceUnlock="false">
    <aid-group
        android:description="@string/hce_aid_group_desc"
        android:category="other">
        <aid-filter android:name="F0010203040506" />
        <aid-filter android:name="F0010203040507" />
    </aid-group>
</host-apdu-service>

说明:AID 是应用标识符,读写器通过它来「点名」要访问的卡应用。AID 通常为 5~16 字节,建议使用以 F0 开头的私有 AID 段,避免与支付等标准 AID 冲突。

声明时的四个关键点

  1. android:permission="android.permission.BIND_NFC_SERVICE" 是硬性要求 :这是系统绑定 HCE 服务所必需的权限,漏写会导致服务无法被系统识别,靠近读卡器时毫无反应。同时 android:exported="true" 必须显式声明,否则 Android 12+ 会因未声明导出属性而直接报错。

  2. meta-data 的 android:resource 必须指向正确的 XML 文件:它告诉系统「这个服务支持哪些 AID」。路径写错(如文件名拼写不一致)时,服务虽然能正常启动,但系统无法获知它支持的 AID 列表,读卡器点名时同样不会命中你的服务。

  3. aid-group 的 android:category 决定 AID 的用途分类 :other 表示非支付类应用(如门禁、会员卡),payment 表示支付类应用。注意:payment 类别在部分国家/地区受法规限制,且需要额外声明 android:category="payment" 并配合 android:requireDeviceUnlock 等安全策略 。普通业务建议使用 other,避免不必要的合规风险。

  4. android:requireDeviceUnlock 控制是否要求解锁屏幕 :设为 true 时,设备锁屏状态下读卡器无法访问该服务,适合对安全要求较高的场景(如支付、身份认证);设为 false 则锁屏也能响应,适合门禁等追求便捷的场景。请根据业务安全等级权衡。

别忘了在 strings.xml 中补充描述文案

apduservice.xml 中引用的 @string/hce_service_desc 和 @string/hce_aid_group_desc 需要在 res/values/strings.xml 中定义,否则编译会报资源找不到的错误。建议把描述文案写得具体一些,方便用户在系统设置中识别:

xml 复制代码
<resources>
    <!-- HCE 服务描述:会展示在系统「NFC 默认应用」设置页 -->
    <string name="hce_service_desc">我的 HCE 卡模拟服务,用于门禁刷卡与身份认证</string>
    <!-- AID 组描述:说明这一组 AID 的用途 -->
    <string name="hce_aid_group_desc">我的门禁卡 AID 组(F0010203040506/0507)</string>
</resources>

提示:android:description 会展示在系统「NFC 默认应用」设置页中,建议写清楚这个服务是干什么的,方便用户识别和选择。如果描述文案过于笼统(比如只写「HCE 服务」),用户在多应用并存时很难判断该选哪一个,容易误选导致刷卡失败。

实现 HostApduService 处理 APDU 指令

HostApduService 的核心是 processCommandApdu() 方法,它接收读写器发来的 APDU 指令,返回响应字节数组。一个健壮的 HCE 服务,不仅要能处理业务指令,还要能正确解析 APDU 结构、返回规范的状态字、应对异常场景。下面从「APDU 结构」讲起,逐步给出一个可直接落地的完整实现。

先理解 APDU 指令的结构

读写器发来的每条 APDU 指令遵循 ISO 7816-4 规范,由头部和可选主体组成:

字段 名称 长度 说明
CLA 类别字节 1 字节 指令类别,通常为 0x00,用于区分不同协议族
INS 指令字节 1 字节 具体指令,如 0xCA(读取)、0x20(校验)
P1 参数 1 1 字节 指令参数,含义由 INS 决定
P2 参数 2 1 字节 指令参数,含义由 INS 决定
Lc 数据长度 0~3 字节 后续 Data 字段的字节数,可为 0
Data 数据体 Lc 字节 指令携带的数据,如 PIN、卡号等
Le 期望响应长度 0~3 字节 期望返回的响应字节数,可为 0

说明:Lc 和 Le 的编码规则在不同协议版本中略有差异,但 CLA / INS / P1 / P2 四个字节是固定存在的。因此 processCommandApdu() 收到的 commandApdu 数组长度至少为 4,解析时务必先做长度校验,避免越界崩溃。

完整实现:指令分发 + 状态字 + 异常兜底

下面是一个更完整的 MyHostApduService,它把「指令解析 → 业务分发 → 状态字返回 → 异常兜底」整合在一起,并支持多指令扩展:

java 复制代码
public class MyHostApduService extends HostApduService {

    private static final String TAG = "MyHceService";

    // ========== 指令常量(ISO 7816-4) ==========
    // CLA:类别字节,普通应用指令通常为 0x00
    private static final byte CLA_ISO = (byte) 0x00;

    // INS:指令字节
    private static final byte INS_READ_CARD   = (byte) 0xCA; // 读取卡号
    private static final byte INS_VERIFY_PIN  = (byte) 0x20; // 校验 PIN
    private static final byte INS_GET_VERSION = (byte) 0xB0; // 读取版本号
    private static final byte INS_SELECT      = (byte) 0xA4; // 选择应用(可选)

    // ========== 状态字(Status Word) ==========
    private static final short SW_SUCCESS          = (short) 0x9000; // 成功
    private static final short SW_WRONG_LENGTH     = (short) 0x6700; // 长度错误
    private static final short SW_INS_NOT_SUPPORTED = (short) 0x6D00; // 不支持的指令
    private static final short SW_CLA_NOT_SUPPORTED = (short) 0x6E00; // 不支持的类别
    private static final short SW_WRONG_DATA       = (short) 0x6A80; // 数据格式错误
    private static final short SW_VERIFY_FAILED    = (short) 0x63C0; // 校验失败
    private static final short SW_FILE_NOT_FOUND   = (short) 0x6A82; // 文件/应用未找到

    // ========== 业务数据(实际项目应从安全存储读取) ==========
    private static final byte[] CARD_NUMBER = "202610070001".getBytes(StandardCharsets.UTF_8);
    private static final byte[] APP_VERSION = "1.0.0".getBytes(StandardCharsets.UTF_8);
    // 演示用 PIN,实际应加密存储并配合后端校验
    private static final byte[] PIN_STORED = "1234".getBytes(StandardCharsets.UTF_8);

    @Override
    public byte[] processCommandApdu(byte[] commandApdu, Bundle extras) {
        // 第一步:长度校验,防止越界
        if (commandApdu == null || commandApdu.length < 4) {
            Log.w(TAG, "APDU 长度非法: " + (commandApdu == null ? "null" : commandApdu.length));
            return getStatusWord(SW_WRONG_LENGTH);
        }

        // 第二步:解析头部
        byte cla = commandApdu[0];
        byte ins = commandApdu[1];
        byte p1  = commandApdu[2];
        byte p2  = commandApdu[3];

        // 第三步:校验 CLA(可选,按需开启)
        if (cla != CLA_ISO) {
            Log.w(TAG, "不支持的 CLA: 0x" + String.format("%02X", cla));
            return getStatusWord(SW_CLA_NOT_SUPPORTED);
        }

        // 第四步:指令分发
        try {
            switch (ins) {
                case INS_READ_CARD:
                    return handleReadCard(p1, p2);
                case INS_VERIFY_PIN:
                    return handleVerifyPin(commandApdu);
                case INS_GET_VERSION:
                    return handleGetVersion();
                case INS_SELECT:
                    return handleSelect(commandApdu);
                default:
                    Log.w(TAG, "不支持的 INS: 0x" + String.format("%02X", ins));
                    return getStatusWord(SW_INS_NOT_SUPPORTED);
            }
        } catch (Exception e) {
            // 第五步:异常兜底,避免服务崩溃
            Log.e(TAG, "处理 APDU 异常", e);
            return getStatusWord(SW_WRONG_DATA);
        }
    }

    /** 读取卡号:P1/P2 可扩展为「读取第 N 段卡号」 */
    private byte[] handleReadCard(byte p1, byte p2) {
        // 演示:直接返回完整卡号 + 成功状态字
        byte[] response = new byte[CARD_NUMBER.length + 2];
        System.arraycopy(CARD_NUMBER, 0, response, 0, CARD_NUMBER.length);
        appendStatusWord(response, CARD_NUMBER.length, SW_SUCCESS);
        return response;
    }

    /** 校验 PIN:从 APDU Data 字段提取 PIN 并比对 */
    private byte[] handleVerifyPin(byte[] commandApdu) {
        // 头部 4 字节 + Lc 1 字节 = 5 字节,之后才是 Data
        if (commandApdu.length < 5) {
            return getStatusWord(SW_WRONG_LENGTH);
        }
        int lc = commandApdu[4] & 0xFF;
        if (lc <= 0 || commandApdu.length < 5 + lc) {
            return getStatusWord(SW_WRONG_LENGTH);
        }

        byte[] pin = new byte[lc];
        System.arraycopy(commandApdu, 5, pin, 0, lc);

        if (Arrays.equals(pin, PIN_STORED)) {
            Log.i(TAG, "PIN 校验通过");
            return getStatusWord(SW_SUCCESS);
        } else {
            Log.w(TAG, "PIN 校验失败");
            return getStatusWord(SW_VERIFY_FAILED);
        }
    }

    /** 读取版本号 */
    private byte[] handleGetVersion() {
        byte[] response = new byte[APP_VERSION.length + 2];
        System.arraycopy(APP_VERSION, 0, response, 0, APP_VERSION.length);
        appendStatusWord(response, APP_VERSION.length, SW_SUCCESS);
        return response;
    }

    /** 选择应用:可选实现,用于多应用场景 */
    private byte[] handleSelect(byte[] commandApdu) {
        // 演示:只要 CLA/INS 正确即返回成功
        // 实际项目应解析 Data 中的 AID 并判断是否支持
        return getStatusWord(SW_SUCCESS);
    }

    /** 构造纯状态字响应(2 字节) */
    private byte[] getStatusWord(short statusWord) {
        return new byte[]{
                (byte) (statusWord >> 8),
                (byte) (statusWord & 0xFF)
        };
    }

    /** 在数据末尾追加状态字 */
    private void appendStatusWord(byte[] response, int dataLength, short statusWord) {
        response[dataLength]     = (byte) (statusWord >> 8);
        response[dataLength + 1] = (byte) (statusWord & 0xFF);
    }

    @Override
    public void onDeactivated(int reason) {
        // 读写器断开连接时回调,可在此清理资源
        Log.d(TAG, "HCE deactivated, reason=" + reason);
    }
}

这个实现比基础版多了什么?

对照上一节的「最小示例」,这个版本在工程化上做了五点增强:

  1. 长度校验前置 :commandApdu.length < 4 时直接返回 6700(长度错误),避免后续数组越界崩溃。这是 HCE 服务最容易忽略、也最容易导致闪退的细节。

  2. CLA 校验:在分发前先校验类别字节,防止其他协议族的指令误入你的业务逻辑。如果业务不需要,可以去掉这段,但保留更稳妥。

  3. 统一状态字管理 :把 9000 / 6D00 / 6A80 等状态字抽成常量,并封装 getStatusWord() 与 appendStatusWord() 两个工具方法,避免在业务代码里到处写魔法数字。

  4. 异常兜底 :用 try-catch 包裹整个分发逻辑,任何未预期的异常都返回 6A80(数据格式错误)而不是让服务崩溃。HCE 服务一旦崩溃,读卡器会立刻感知到「卡消失」,体验极差。

  5. 可扩展的指令分发 :用 switch 按 INS 分发,新增指令只需加一个 case 和对应的 handleXxx() 方法,无需改动主流程。

关于状态字的补充说明

APDU 响应的最后两个字节是状态字(Status Word),它是读写器判断「这次交互是否成功」的唯一依据。建议把状态字定义成常量并集中管理,方便后续排查。常用状态字如下:

状态字 含义 使用场景
9000 成功 所有正常响应
6700 长度错误 commandApdu 长度不足或 Lc 与实际数据不匹配
6A80 数据格式错误 指令参数非法、数据内容不符合预期
6A82 文件/应用未找到 SELECT 的 AID 不在支持列表中
6D00 不支持的指令 INS 未在你的分发逻辑中处理
6E00 不支持的类别 CLA 与预期不符
63C0 校验失败 PIN/密码比对不通过

提示:状态字是「读写器视角」的反馈,务必与读写器端约定一致。如果读写器端对状态字的解析逻辑与你的实现不一致,会出现「明明返回了 9000,读写器却报错」的诡异问题。联调时建议双方共用同一份状态字对照表。

在 Activity 中启用 HCE 并处理冲突

HCE 服务默认在设备支持 NFC 且系统开启 NFC 时即可工作,无需像前台分发那样手动注册。但当一个 AID 被多个应用声明时,系统会弹出选择框让用户决定由哪个应用响应。你也可以在代码中主动查询并引导用户设置默认应用。下面是一个完整的 Activity 模板,把「检查硬件 → 查询默认服务 → 引导设置 → 处理冲突」串成一条清晰的链路:

java 复制代码
public class HceSetupActivity extends AppCompatActivity {

    private static final String TAG = "HceSetupActivity";
    // 与 apduservice.xml 中声明的 AID 保持一致
    private static final String AID = "F0010203040506";

    private NfcAdapter nfcAdapter;
    private CardEmulation cardEmulation;
    private ComponentName hceService;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_hce_setup);

        // 第一步:检查 NFC 硬件与开关
        nfcAdapter = NfcAdapter.getDefaultAdapter(this);
        if (nfcAdapter == null) {
            tvStatus.setText("当前设备不支持 NFC,无法使用卡模拟");
            btnSetDefault.setEnabled(false);
            return;
        }
        if (!nfcAdapter.isEnabled()) {
            tvStatus.setText("NFC 未开启,请前往系统设置开启");
            startActivity(new Intent(Settings.ACTION_NFC_SETTINGS));
            return;
        }

        // 第二步:初始化 CardEmulation 与服务组件
        cardEmulation = CardEmulation.getInstance(nfcAdapter);
        hceService = new ComponentName(this, MyHostApduService.class);

        // 第三步:查询当前默认服务并刷新 UI
        refreshDefaultStatus();
    }

    private void refreshDefaultStatus() {
        if (cardEmulation == null || hceService == null) return;

        if (cardEmulation.isDefaultServiceForAid(hceService, AID)) {
            tvStatus.setText("当前应用已是该 AID 的默认 HCE 服务");
            btnSetDefault.setText("已是默认服务");
            btnSetDefault.setEnabled(false);
        } else {
            tvStatus.setText("本应用尚未成为默认 HCE 服务,靠近读卡器时可能无法响应");
            btnSetDefault.setText("设为默认 HCE 服务");
            btnSetDefault.setEnabled(true);
        }
    }

    public void onSetDefaultClick(View view) {
        // 方式一:直接跳转系统 NFC 设置页,让用户手动选择
        // startActivity(new Intent(Settings.ACTION_NFC_SETTINGS));

        // 方式二:尝试通过 CardEmulation 主动设置(部分系统支持)
        // 注意:setPreferredService 仅对当前前台 Activity 生效,
        // 且需要配合前台分发使用,并非持久化设置。
        boolean success = cardEmulation.setPreferredService(this, hceService);
        if (success) {
            tvStatus.setText("已临时设为首选服务,请保持本页面在前台并靠近读卡器");
        } else {
            // 主动设置失败,退回引导用户到系统设置
            tvStatus.setText("无法自动设置,请到系统设置中手动选择本应用");
            startActivity(new Intent(Settings.ACTION_NFC_SETTINGS));
        }
    }

    @Override
    protected void onResume() {
        super.onResume();
        // 从系统设置返回后刷新状态
        refreshDefaultStatus();
    }
}

这里有几个关键点需要特别注意:

  1. isDefaultServiceForAid() 是「查询」而非「设置」 :它只告诉你当前是否为默认服务,不能用来修改默认应用。真正设置默认应用需要用户到系统设置中手动选择,或使用 setPreferredService() 做临时性前台优先。

  2. setPreferredService() 的局限性:它只在当前 Activity 处于前台时生效,且需要配合前台分发系统使用,应用退到后台后即失效。它适合「本次会话内优先响应」的场景,不适合作为持久化默认设置。

  3. AID 冲突的本质:当多个应用声明了相同的 AID 时,系统按「默认应用 > 前台首选服务 > 用户最近选择」的优先级决定由谁响应。你的应用即使不是默认,只要用户最近一次选择了它,也会被优先响应。

  4. onResume() 中刷新状态 :用户从系统设置页返回后,默认服务可能已改变,务必在 onResume() 中重新调用 refreshDefaultStatus() 刷新 UI,避免界面显示与实际状态不一致。

  5. requireDeviceUnlock 的影响 :如果 apduservice.xml 中设置了 android:requireDeviceUnlock="true",锁屏状态下即使应用是默认服务,读卡器也无法访问。测试时注意区分「默认服务未设置」与「设备未解锁」两种不同表现。

  6. 多 AID 的查询 :如果你的服务声明了多个 AID(如 F0010203040506 和 F0010203040507),需要分别对每个 AID 调用 isDefaultServiceForAid() 查询。系统允许「部分 AID 默认、部分不默认」的混合状态,UI 上应分别展示每个 AID 的状态。

HCE 调试要点

HCE 的调试比普通读写模式更「黑盒」------你既看不到射频层的交互,也难以及时感知读写器发来的指令。下面这套调试方法,能帮你把「看不见的 APDU 会话」逐步变成「可观测、可定位」的流程。

第一步:先确认服务被系统识别

靠近读卡器之前,先到「设置 → 连接与共享 → NFC → 默认应用(或点击支付/卡模拟)」里确认你的应用出现在可选列表中。如果列表里根本没有你的服务,问题几乎都出在 Manifest 声明上,按优先级排查:

  • android:permission="android.permission.BIND_NFC_SERVICE" 是否漏写;
  • android:exported="true" 是否显式声明(Android 12+ 硬性要求);
  • meta-data 的 android:resource 是否指向了正确的 apduservice.xml;
  • apduservice.xml 里的 @string 资源是否都已定义。

第二步:用日志把 APDU 会话「可视化」

在 processCommandApdu() 入口和出口各打一条日志,记录收到的指令与返回的响应,这是定位指令分发逻辑最直接的手段:

java 复制代码
@Override
public byte[] processCommandApdu(byte[] commandApdu, Bundle extras) {
    Log.d(TAG, ">>> APDU 收到: " + bytesToHex(commandApdu));
    byte[] response = dispatch(commandApdu);
    Log.d(TAG, "<<< APDU 返回: " + bytesToHex(response));
    return response;
}

private static String bytesToHex(byte[] bytes) {
    StringBuilder sb = new StringBuilder();
    for (byte b : bytes) {
        sb.append(String.format("%02X ", b));
    }
    return sb.toString().trim();
}

提示:把收到的指令按 CLA / INS / P1 / P2 / Lc / Data / Le 分段打印出来,对照 ISO 7816-4 协议文档逐字段核对,能快速定位「是读写器发错了,还是你的分发逻辑写错了」。

第三步:用「状态码」快速定位失败原因

APDU 响应的最后两个字节是状态码(Status Word),它是排查问题的「第一现场」。建议在 getErrorResponse() 里统一打点,把状态码与业务含义对应起来:

状态码 含义 常见原因
9000 成功 正常响应
6A80 指令格式错误 commandApdu 长度不足或参数越界
6D00 不支持的指令 INS 未在你的分发逻辑中处理
6E00 不支持的类别 CLA 与预期不符
63C0 校验失败 PIN/密码比对不通过
6700 长度错误 Lc/Le 与实际数据长度不匹配

第四步:真机 + 读卡器联调,别依赖模拟器

HCE 依赖真实射频交互,模拟器无法模拟读写器与手机之间的 APDU 会话。务必用支持 NFC 的真机配合读卡器联调。没有专用读卡器时,可以用另一台开启卡模拟的手机充当读写器,或借助支持 APDU 交互的 NFC 调试工具(如 NFC TagInfo、ACR122U 读卡器配套软件)来发送自定义指令。

第五步:AID 冲突与默认应用检查

如果设备上安装了多个声明相同 AID 的应用,系统会弹窗让用户选择,测试时注意确认当前响应的是你的服务。可以在代码中主动查询并引导用户设置默认应用:

java 复制代码
CardEmulation cardEmulation = CardEmulation.getInstance(NfcAdapter.getDefaultAdapter(this));
ComponentName service = new ComponentName(this, MyHostApduService.class);
if (cardEmulation.isDefaultServiceForAid(service, "F0010203040506")) {
    tvStatus.setText("当前应用已是该 AID 的默认 HCE 服务");
} else {
    tvStatus.setText("请到系统设置中将本应用设为默认 NFC 卡模拟应用");
    startActivity(new Intent(Settings.ACTION_NFC_SETTINGS));
}

第六步:安全考量

HCE 的卡逻辑运行在主机侧,敏感数据(如卡号、密钥)不要硬编码在代码里,应加密存储并配合后端动态下发,与第⑧节的安全实践保持一致。调试阶段打印的日志在生产环境要关闭或脱敏,避免卡号等敏感信息泄露到 logcat。

提示:如果「靠近读卡器后服务毫无反应」,优先按「第一步」检查 Manifest 声明;如果服务有反应但返回异常,按「第三步」对照状态码定位;如果状态码正常但业务结果不对,再回到「第二步」逐字段核对指令内容。这套排查顺序能覆盖绝大多数 HCE 调试场景。

相关推荐
XIAEE1 小时前
WebRTC 探险记:我是如何让两个浏览器"看对眼"的
java·spring
XIAEE1 小时前
UniApp 本地打包通关记:Android 是地狱难度,iOS 是隐藏 BOSS
java·spring
用户2181697049301 小时前
Kotlin 语法四 协程
android
出师牛1 小时前
Android - 再探Handler
android
Dreamboat-L2 小时前
日志级别基础 + Spring Boot 日志实践指南
java·服务器·数据库·java-ee·springboot
stevenzqzq2 小时前
android 维度和渠道
android
啵啵啵鱼2 小时前
第二章 线性表
java·数据结构·算法
深入云栈2 小时前
Netty 4.2.x 源码深度解析 (十九):ChannelPool 连接池 —— 连接复用与资源管理
java·后端·架构
用户094248568032 小时前
第38章:从零构建JVM稳定性平台最小闭环
java·jvm