NFC 冷启动问题修复原理
本文说明本项目为什么曾经需要碰卡两次,以及如何通过 Android 启动入口、Intent 读取和页面结果交接,让第一次碰卡即可唤起应用、获取 UID 并提交打卡。
前端项目代码链接 :https://github.com/bigxinyuan222/NFC-TAG
1. 修复结论
这次修复贯通了两段流程:Android 把带卡片数据的 NFC Intent 交给正确的启动入口;应用启动后立即读取这个 Intent,再持续监听后续碰卡。
原来的现象是:APP 未打开时,第一次碰卡只能启动 APP;APP 打开后再碰一次,才取得 UID。第二次能够读到,说明前台读卡流程可以工作,但首次启动时的 NFC 数据交接不完整。
修复后的关键变化如下:
| 环节 | 原来存在的问题 | 现在的处理 |
|---|---|---|
| 卡片内容匹配 | 现场卡片写的是包名启动记录,仅处理mycheckin://不匹配 | 增加 AAR 对应的 NDEF 过滤规则 |
| 原生启动入口 | 测试时曾把 NFC 入口挂到未对外导出的内部 Activity,出现无法拉起 APP | 使用io.dcloud.PandoraEntry正常启动入口 |
| 冷启动读取 | 只等后续事件会漏掉已放进启动 Intent 的卡片 | onLaunch注册监听后,立即主动读取当前 Intent |
| 初始化时序 | 初次读取时,桥接或卡片数据可能尚未就绪 | 保留数据,每隔 100ms 重试,最多再试 5 次 |
| 重复通知 | 同一次启动可能被多次通知 | 消费标记、UID 防抖、提交锁共同防重 |
| 结果展示 | 上传可能早于页面就绪,只有事件通知容易漏掉弹窗 | 先保存结果,再由页面一次性消费并显示动画 |
2. 现场卡片实际写入了什么
NFC Tools 中的“启动应用”写入的是 Android Application Record,简称 AAR:
NDEF 记录类型:External / android.com:pkg 记录内容:uni.app.UNI35FA817 整条记录长度:36 字节 本次实机卡片 UID:5310C716430001现场记录的复现数据保存在 nfc-package-record.json。包名用于选择要启动的应用,硬件 UID 用于本项目的卡片校验,两者分别从不同位置取得。
Android 对 External 类型记录使用vnd.android.nfc://ext/<记录类型>进行 Intent 匹配,所以本卡片对应vnd.android.nfc://ext/android.com:pkg。AAR 还允许系统按包名选择应用,因此“APP 被打开”本身不足以证明具体 Activity 已收到本次 NFC 数据。Android 官方说明
本次排查据卡片内容、入口配置和复现现象,定位到首次启动的分发链路。没有改写卡片,也没有用包名或协议链接代替 UID。
3. 第一步:修正 Android 原生启动入口
修改位置:AndroidManifest.xml。现有 NFC 权限继续由 manifest.json 配置,根目录清单补充 Activity 的 NFC 入口:
<?xml version="1.0" encoding="utf-8"?><manifestxmlns:android="http://schemas.android.com/apk/res/android"package="uni.app.UNI35FA817"><application><activityandroid:name="io.dcloud.PandoraEntry"android:exported="true"><intent-filter><actionandroid:name="android.nfc.action.NDEF_DISCOVERED"/><categoryandroid:name="android.intent.category.DEFAULT"/><dataandroid:scheme="vnd.android.nfc"android:host="ext"android:path="/android.com:pkg"/></intent-filter><intent-filter><actionandroid:name="android.nfc.action.NDEF_DISCOVERED"/><categoryandroid:name="android.intent.category.DEFAULT"/><dataandroid:scheme="mycheckin"/></intent-filter><intent-filter><actionandroid:name="android.nfc.action.TAG_DISCOVERED"/><categoryandroid:name="android.intent.category.DEFAULT"/></intent-filter></activity></application></manifest>三个过滤器分别负责现场包名启动卡片、mycheckin协议卡片,以及走到TAG_DISCOVERED分发阶段的卡片。TAG_DISCOVERED是兜底入口,不能替代现场 AAR 的精确匹配。
为什么选择 PandoraEntry
本次排查的 DCloud 运行时启动链路是:
系统 NFC Intent → io.dcloud.PandoraEntry → 初始化应用运行时 → 把原 NFC Intent 转交给 io.dcloud.PandoraEntryActivity → JS 通过 runtimeMainActivity().getIntent() 读取曾经将过滤器直接配置到内部的io.dcloud.PandoraEntryActivity。但现场安装的1.4.1APK 中,该 Activity 最终没有对外导出,ADB 发起同类启动请求时报出:
Permission Denial ... not exported这对应后来“碰卡有反应,但打不开 APP”的故障。改用正常入口PandoraEntry后,同类冷启动请求返回Status: ok。正常入口还承担运行时初始化,因此保留它原有的启动和 Intent 转交流程。
这个结论来自本项目实际打包产物和启动排查。后续升级 HBuilderX 时,仍应检查最终 APK 合并后的清单。
4. 第二步:冷启动主动读取,后续碰卡持续监听
4.1 在 onLaunch 接住第一次碰卡
修改位置:App.vue 的 onLaunch 和 listenForNfcIntents。
冷启动时,NFC 数据可能已经存在于 Activity 的启动 Intent 中。后面才注册的newintent监听,并不等于能重新收到这次启动事件。因此初始化时执行两个动作:
// listenForNfcIntents 的核心节选currentHandler=handler unhandledIntentHandler=onUnhandledIntentif(!intentListenerRegistered){plus.globalEvent.addEventListener('newintent',handleNewIntent)intentListenerRegistered=true}handleNewIntent()// 注册后主动读取当前启动 IntentApp.vue在onLaunch调用该方法:冷启动或后台唤起走handleBackgroundNfc;前台碰卡走handleNfcTag。两个入口共用同一套原生 UID 读取逻辑。
这里的background是按收到事件时前台调度是否开启进行分类,用来选择处理路径;它不表示 APP 进程被关闭后仍有 JS 持续运行。冷启动仍由 Android NFC 分发负责唤起应用。
4.2 优先读取 EXTRA_ID,再读取 Tag.getId()
修改位置:readCurrentNfcIntent。核心节选如下,省略了异常处理和消费标记检查:
constactivity=plus.android.runtimeMainActivity()constNfcAdapter=plus.android.importClass('android.nfc.NfcAdapter')constintent=activity.getIntent()if(!intent)returnnullplus.android.importClass(intent)letuid=bytesToUid(intent.getByteArrayExtra(NfcAdapter.EXTRA_ID))if(!uid){consttag=intent.getParcelableExtra(NfcAdapter.EXTRA_TAG)if(!tag)returnnullplus.android.importClass(tag)uid=bytesToUid(tag.getId())}if(!uid)returnnullclearNfcExtras(activity,intent,NfcAdapter)return{uid}EXTRA_ID直接提供字节数组,可减少对Tag对象反序列化的依赖;没有它时继续尝试EXTRA_TAG。这两个字段的含义可见 NfcAdapter 官方参考。
读取时以原生 extra 为依据,没有限定“action 必须等于NDEF_DISCOVERED”。这样即使运行时包装了 action,只要卡片数据仍在,也有机会取得 UID。
字节转字符串使用 bytesToUid:
uid+=(Number(bytes[index])&0xff).toString(16).padStart(2,'0')& 0xff还原 Java 有符号 byte,padStart(2, '0')保留前导零,最后统一转为大写。例如53:10:C7:16:43:00:01转为5310C716430001,不反转字节顺序。
4.3 读取失败时短暂重试,成功后再消费
修改位置:dispatchCurrentTag 和 clearNfcExtras。
启动时立即读一次;如果桥接异常、Intent 暂未到位或 UID 暂时为空,则每隔 100ms 重试,最多再试 5 次。一次读取加五次重试,最长约等待 500ms。
读取失败时保留原 Intent;取得有效 UID 后,才移除EXTRA_TAG、EXTRA_ID、EXTRA_NDEF_MESSAGES,并设置:
intent.putExtra('mycheckin.nfc.CONSUMED',true)activity.setIntent(intent)启动链接和其他参数继续保留。后续通知如果再次读到这个已消费的 Intent,就直接结束,避免把同一次碰卡错误地降级为“请再次碰卡”。
重试只能处理数据到达时序,不能补出系统没有传来的 UID。重试后仍只有普通mycheckin://链接时,现有降级流程会要求再次碰卡验证。
4.4 前台开启调度,后台保留监听
生命周期由 App.vue 管理:
| 时机 | 操作 | 作用 |
|---|---|---|
onLaunch | 注册唯一的newintent监听,并读取当前 Intent | 处理第一次启动携带的卡片 |
onShow | startNfcScan()开启前台调度 | 处理 APP 已打开时的碰卡 |
onHide | stopNfcScan()关闭前台调度,保留事件监听 | 继续接收后台唤起时的新 Intent |
前台使用可变PendingIntent,让系统填入卡片数据;本实现对 API 31 及以上设置FLAG_MUTABLE,同时保留FLAG_UPDATE_CURRENT和FLAG_ACTIVITY_SINGLE_TOP。Android 前台调度官方说明
5. 第三步:一次碰卡只触发一次提交
防重复分成三层,分别处理不同情况:
| 机制 | 处理的问题 |
|---|---|
| Intent 消费标记 | 同一个启动 Intent 被冷启动、后续通知再次读取 |
| 同 UID 1 秒防抖 | 卡片贴在手机上时连续触发多个扫描通知 |
| 全局提交锁 | 前后台入口交叠,或上一次打卡请求仍在处理中 |
提交锁正常情况下在请求结束时释放,30 秒是兜底释放时间,并不要求用户每次打卡等待 30 秒。它属于客户端防重措施,不能当作服务端永久幂等保证。
冷启动、后台唤起时,由 handleBackgroundNfc 直接调用打卡接口;前台碰卡先暂存标签,再由打卡页消费并提交。两条路径都先检查登录状态和现有时间限制。
修复后的完整流程如下:
6. 成功弹窗为什么也需要结果交接
读到 UID、服务器确认成功、页面显示成功,是三个不同的阶段。
api/checkin.js 把实际 UID、打卡点和设备标识提交至POST /api/checkin。当前成功条件为 HTTP200且业务码code === 0。页面据此决定显示成功或失败。
冷启动时,接口可能先于页面返回。如果只发一个页面尚未订阅的事件,成功提示就会丢失。1.4.5的处理是:
App.vue先用setPendingCheckinResult保存已完成请求的结果。- 当前已在打卡页时,发送
checkin-result-ready通知;其他页面则返回打卡页。 - 页面在
onShow和收到通知时,都调用showPendingCheckinResult。 - 结果读取后立即移除,超过一分钟失效,退出登录也会清除。
具体代码见 session.js 的结果交接函数 和 打卡页的结果消费方法。这里传递的是已提交请求的结果,不把它重新当成待提交的标签,因此不会为了弹窗再上传一次。
原有猫咪结果组件 checkin-result/index.vue 已接回这条流程,成功时展示庆祝动画、星光、确认勾、打卡时间和一次轻振;失败时展示实际原因。页面内提示不依赖 Push 模块,避免之前“打包时未添加 push 模块”的运行时弹窗。
7. 验证结果与复现方法
已保存的实机证据
证据见 2026-11-04 实机验证记录。该记录位于本地构建目录,可能不随 Git 提交,因此关键结果列在下表:
| 检查 | 实际结果 |
|---|---|
| 手机 | vivo V2502A,Android 16 |
1.4.2 / 102首次读卡验证 | 碰卡前进程不存在;第一次碰卡启动新进程,取得5310C716430001 |
1.4.3 / 103上传验证 | 冷启动碰卡后,HTTP200、业务码0、success: true |
| 服务器流水 | 总数从87增至88;新增 ID171,时间2026-11-04 15:21:37 |
| 成功 POST 次数 | 观察到1次 |
| 钉钉状态 | dingtalkSync: pending,当时未验证最终回执 |
以上证明首次 UID 读取和本应用服务器的打卡流水上传已通过实机验证。钉钉排队状态需要与最终同步结果区分。
1.4.5 / 105已通过 30 项回归、4 种手机尺寸/状态预览,以及最终 APK 的签名、版本、原生入口和编译资源检查;截至本文整理时,该版本尚未覆盖安装并再次实机碰卡。构建检查见 1.4.5 校验记录。
本地回归与清单检查
在根目录下运行:
node--test tests/nfc-checkin.test.cjs tests/checkin-window.test.cjs powershell-NoProfile-File tests/check-nfc-manifest.ps1回归覆盖冷启动已有 Intent、Intent 延迟到达、仅有EXTRA_ID、桥接短暂失败、前后台切换、重复通知、请求失败,以及接口先返回或页面先加载两种结果交接顺序。时间窗测试调用现有函数校验原边界。
手机连接并允许 USB 调试后,可检查已安装 APK 的三类入口与版本:
powershell-NoProfile-File tests/check-nfc-device.ps1-AdbPath'D:\Hbuilderx\HBuilderX\plugins\launcher-tools\tools\adbs\adb.exe'该脚本应确认 AAR、mycheckin和 Tag 三类请求均匹配对外导出的io.dcloud.PandoraEntry,且手机版本与源码一致。它只查询配置,不模拟实际卡片。
实际验收时,登录后回到桌面,退出 NFC Tools,并确认应用进程已结束、不是仅切到后台,也不是被设置为系统强行停止状态。保持手机解锁,在允许时段碰实体卡一次并移开,核对启动、接口结果和服务器新增流水;再分别检查后台唤起、前台碰卡与连续通知。
正式包的上传和远端流水摘要同时写入 Android 日志,可通过以下命令查看:
&'D:\Hbuilderx\HBuilderX\plugins\launcher-tools\tools\adbs\adb.exe'logcat-s NfcCheckin:I日志中的 UID、打卡点、HTTP 状态、业务码与远端流水用于串联读卡到上传的过程;现有诊断日志不输出登录 Token。
为什么必须完整打包
NFC 启动入口属于原生清单。修改根目录AndroidManifest.xml后,需要重新云打包或生成包含该配置的自定义基座;热更新页面资源无法更新手机已安装 APK 的 Activity 入口。
覆盖安装继续使用相同包名与签名,并检查最终 APK 和已安装版本。ADB 能唤起 Activity 只能证明入口可达,真实的 Tag/UID 数据仍需用实体卡验证。
8. 后续维护时保留的关键点
- AAR 的 NDEF 过滤器继续挂在本项目正常启动入口
PandoraEntry,升级运行时后检查合并产物。 onLaunch同时执行“注册监听”和“主动读取”;onHide只停止前台调度,保留newintent监听。- 取得有效 UID 后再消费 NFC extra,保留已消费标记,读取失败时允许短暂重试。
- 冷启动/后台与前台各自提交一次;已完成请求的结果只用于展示。
本次改动的核心是让第一次碰卡产生的原生数据,从系统启动入口一直传到 JS 提交逻辑;重试、防重和结果交接则分别处理初始化时序、重复通知与页面加载时序。