去年团队接了一个让不少人皱眉头的需求:在搭载OpenHarmony的工业手持机上做NFC巡检功能,要求贴近标签后能读取UID和标签数据,还要能对接后端巡检系统。当时Android和iOS的App已经用React Native写完并在多个项目里跑得很稳,大家第一反应是“OpenHarmony上能不能也跑RN?”调研之后发现,React Native的OpenHarmony适配版已经能支撑常规业务开发,NFC这种系统能力只要写一层原生桥接就可以。这篇文章就把整个落地方案、核心代码,以及调试中遇到的高频坑完整记录下来,给同样准备在OpenHarmony上做RN NFC开发的团队一个参考。
1. 项目背景与整体设计
1.1 需求场景:手持机上的NFC巡检
这个项目最典型的场景是仓库和机房巡检。巡检员拿着OpenHarmony工业手持机,走到一个巡检点,把机器背面的NFC天线区域贴近墙上的标签,机器屏幕立刻弹出这个巡检点的编号、历史巡检记录,以及需要做的检查项。整个过程中,巡检员不需要任何输入操作,读卡就是入口动作。
NFC标签本身并不存太多业务数据,标签里一般就三样东西:
UID:标签唯一序列号,相当于身份证。NDEF消息:标准化的数据块,里面可以放一个文本、一个URL,或者自定义的行业记录。技术类型:ISO14443 Type A/B、Type F(Felica)等,不同设备支持的卡型不同。
工业场景里,我们绝大多数用的是Type A的Mifare Classic或NTAG系列标签。NTAG只读UID,能写NDEF,成本低;Mifare Classic带加密扇区,适合门禁和部分高安全场景。开发时先把这两类都兼容掉,后面省很多事。
读卡动作本身很简单,真正的复杂度在于:RN应用怎么把“系统层发现标签”这个事件接回到JS层,并且保证整个链路稳定、不白屏、不丢事件。
1.2 为什么选React Native而不是原生ArkTS
选型会上主要讨论了三个方案:ArkTS原生开发、RN+OpenHarmony适配版、Flutter。我们的结论很直接,选RN。
| 方案 | 优点 | 缺点 |
|---|---|---|
| ArkTS原生 | 直接调用系统API,性能最好 | 需要单独的ArkTS开发团队,业务代码无法跨端复用 |
| RN + OpenHarmony | 复用现有RN组件和业务逻辑,前端人员上手快 | 系统API需要自己写桥接,版本适配有坑 |
| Flutter | UI一致性好,渲染性能不错 | 团队不熟Dart,NFC能力同样要写原生插件 |
理由其实很朴素:我们已经有20多个页面、几十个业务组件的RN代码,Android和iOS都在跑,这中间包括了登录、任务列表、表单提交、拍照上传。如果OpenHarmony端用ArkTS重新写一遍,等于维护三套代码。而RN的OpenHarmony适配版虽然不是官方主推路线,但核心运行环境已经比较成熟,常见的ScrollView、FlatList、网络请求、图片加载都能正常工作。
NFC的桥接虽然绕不开,但桥接层只需要很小一个面:注册标签监听、读UID、读NDEF、返回事件。把这个面控制好,整体风险就可控。
1.3 整体架构:RN JS层到NFC硬件的调用链
整个链路可以拆成四层:
- JS业务层:React组件,负责页面渲染、用户交互、数据上传。
- RN Runtime:OpenHarmony的RN适配层,提供JS引擎、原生组件映射、TurboModule机制。
- Native桥接层:用ArkTS写的NFC Module,负责调用系统NFC接口,并把结果转成事件或Promise返回给JS。
- 系统NFC能力:OpenHarmony的NFC Kit,包括标签发现、NDEF解析、卡模拟等能力。
事件流动方向是这样:NFC标签贴近设备天线 -> 系统NFC服务发现标签 -> NFC Kit回调Native Module -> Native Module组装数据 -> 通过RN事件机制发给JS -> React组件更新UI。
数据流方向则反过来:JS发起读卡请求 -> 调用Native Module -> 系统开始扫描标签 -> 拿到原始数据返回JS。
设计时有一个原则很重要:Native层只做数据采集和转换,不做业务判断。比如某个UID对应哪个巡检点、要不要提示重复读卡,这类事情放JS层做。原因是业务规则经常变,JS层改完走热更新即可,Native层最好保持稳定。
2. 环境准备与工程创建
2.1 开发环境的一次性搭建
先列一下我当时准备好的环境,照着装就行:
| 组件 | 版本建议 | 说明 |
|---|---|---|
| DevEco Studio | 5.0及以上 | 带OpenHarmony SDK和模拟器 |
| OpenHarmony SDK | API 12及以上 | NFC Kit在API 12里已经比较完整 |
| Node.js | 18或20 LTS | RN CLI依赖 |
| React Native OHOS适配版 | @react-native-oh-tpl/react-native 对应你RN版本 | 0.72或0.73系列都有适配 |
| 真机 | 必须带NFC模块 | 模拟器不支持NFC读卡,别指望模拟器 |
这里提醒一句:OpenHarmony的API版本和RN适配版本之间是有绑定关系的,不是随便取最新就能跑。建议先看@react-native-oh-tpl/react-native的release note,找到对应你RN主版本的适配版本,再装配套的DevEco版本。我一开始就吃了这个亏,装了最新RN 0.74适配包,结果现有代码还能跑,但有个原生依赖就开始报错,后来退回0.73才稳定。
2.2 创建RN工程并接入OpenHarmony平台
用RN CLI初始化一个项目:
npx @react-native-oh-tpl/cli init NfcRnApp cd NfcRnApp这个CLI创建出来的工程结构比普通RN项目复杂,多了一个entry目录,就是OpenHarmony的应用壳工程。里面主要有:
entry/src/main/ets/:ArkTS原生代码,比如我们的NFC桥接模块就放这里。entry/src/main/ets/pages/:HarmonyOS应用入口Ability。App.tsx:RN的业务入口,等于是React组件树的根。
工程创建完先跑一个空页面,确认RN端到端跑通,再开始写NFC。别一上来就叠NFC,否则遇到问题没法判断是RN环境问题还是NFC问题。
2.3 真机联调配置
OpenHarmony手持机连USB调试,先确认hdc list targets能看到设备。然后设置端口反向代理,让设备访问电脑上的Metro服务:
hdc reverse tcp:8081 tcp:8081这个操作对应开发时的React Native经典操作,忘了这一步你在真机上跑Debug版,App能安装但加载不到JS bundle,直接白屏。
Realse包不用Metro,bundle会打包进应用。打包命令一般是:
npm run build:release不同模板可能略有差异,执行前看下package.json里的script。打包完安装到设备上测试。
3. 桥接层设计与原生NFC模块
3.1 桥接层对外接口设计
写桥接前,先把接口定清楚。我在项目里对外暴露了三个能力:
| 方法 | 说明 | 返回 |
|---|---|---|
startScan() | 启动标签监听,持续读卡 | 无 |
stopScan() | 停止标签监听 | 无 |
readOnce() | 单次读卡,读到一张卡后自动停止 | Promise<TagData> |
同时还会通过事件通道下发一个onTagRead事件,里面携带TagData对象。TagData的定义如下:
interface TagData { uid: string; // 标签唯一ID,十六进制字符串 technology: string[]; // 技术支持类型,如 ['NfcA', 'Ndef'] isNdef: boolean; // 是否NDEF标签 ndefText?: string; // NDEF解析出的文本内容 records?: string[]; // 原始NDEF记录列表 }接口这么设计,主要考虑到两种使用场景:巡检页面需要持续读卡,扫一个换一个;而初始化标签或单标签查询时用readOnce就够了。事件和Promise混着用不矛盾,事件适合持续流,Promise适合一次性操作。
3.2 用ArkTS实现NFC原生模块
下面这段是核心中的核心。基于OpenHarmony API 12的NFC Kit,接口命名以你SDK里的.d.ts为准,不同小版本可能微调。
先看最简版的原生模块代码:
// entry/src/main/ets/nfc/NfcReadModule.ets import { tag } from '@kit.NFCKit'; import { TurboModule } from '@rnoh/react-native-openharmony/ts'; export class NfcReadModule extends TurboModule { private onTagNotify = (tagInfo: tag.TagInfo) => { const uid = tagInfo.getUid(); const technologies = tagInfo.getTechnology(); const isNdef = tagInfo.isNdef; let ndefText = ''; if (isNdef) { const ndefTag = tag.getNdefTag(tagInfo); const ndefMsg = ndefTag.getNdefMsg(); ndefText = this.parseNdefMessage(ndefMsg); } const result: TagData = { uid: this.toHexString(uid), technology: technologies, isNdef, ndefText, }; this.emit('onTagRead', result); }; startScan(): void { tag.on('notify', this.onTagNotify); } stopScan(): void { tag.off('notify', this.onTagNotify); } private parseNdefMessage(msg: tag.NdefMessage): string { if (!msg) return ''; const records = msg.getNdefRecords(); let text = ''; for (const record of records) { const payload = record.getPayload(); // NDEF Text Record 的 payload 首字节是状态位,接着是语言码长度,再往后才是 UTF-8 文本 if (payload && payload.length > 0) { const status = payload[0]; const langCodeLen = status & 0x3f; const content = payload.slice(1 + langCodeLen); text += this.decodeUtf8(content); } } return text; } private decodeUtf8(data: Uint8Array): string { // 这里用 TextDecoder 或手动解码,NDEF 标准规定文本编码为 UTF-8 const decoder = util.TextDecoder.create('utf-8'); return decoder.decodeToString(data); } private toHexString(data: Uint8Array): string { return Array.from(data) .map(b => b.toString(16).padStart(2, '0')) .join(':') .toUpperCase(); } }这段代码有几个地方值得注意。
tag.on('notify', callback)是注册标签发现回调。这个监听会在系统检测到NFC标签进入读写器范围时触发,注意回调参数拿到的是TagInfo,不是直接的NDEF数据。设备贴近标签又拿开、再贴近,回调会重新触发,这个行为后面在JS层处理防抖。
tagInfo.isNdef用来判断标签是否支持NDEF。很多Mifare Classic卡不带NDEF格式,但UID一定能读到。工业上有些老标签只有UID,业务仍然可以靠UID绑定,所以读取逻辑里先把UID拿稳,NDEF作为附加信息。
NDEF解析时最容易被坑的是Text Record的payload格式。首字节不是文本内容,它包含状态位和语言码长度,要跳过状态位和语言码才是真正的文本字节。刚开始我没这块经验,直接用toString('utf-8')硬转,解析出来的中文全是乱码。后来对照NDEF规范重新写了解码逻辑才正常。
如果标签里放的是URL而不是文本,解析方式类似,URIRecord有前缀编码表,需要按规范映射成完整URL。项目里大多数标签用文本记录就够了,URL的场景我们现在没深入。
3.3 模块注册与导出
Native模块写完不注册,RN JS层是找不到的。在OpenHarmony的RN工程里,模块注册一般在工程的RNOHCorePackage或者对应的Package列表中完成:
// entry/src/main/ets/rnoh/NfcPackage.ets import { TurboModulePackage } from '@rnoh/react-native-openharmony/ts'; import { NfcReadModule } from '../nfc/NfcReadModule'; export class NfcPackage extends TurboModulePackage { createNativeModules() { return [new NfcReadModule(this.ctx)]; } }然后在入口Ability的RNOHCoreContext配置里把这个Package加进去:
this.rnohCoreContext = new RNOHCoreContext(abilityContext, [ new NfcPackage() ]);具体文件位置和写法以你使用的RN OHOS模板为准,不同小版本可能有差异。但整体思路不变:写下桥接模块,注册进RN运行时。
注册好后,JS侧就可以这样拿:
import { NativeModules } from 'react-native'; const { NfcReadModule } = NativeModules;如果打印NfcReadModule为undefined,基本就是注册没生效,或者Package没加到Context里。
4. RN侧业务实现
4.1 JS层封装可复用的NFC管理器
原生模块暴露的方法比较底层,直接在页面里用会很啰嗦,而且NativeEventEmitter的监听需要手动管理,页面卸载时忘了解绑容易内存泄漏。我在JS层封装了一个NfcManager工具类,把原生调用和事件监听收拢起来:
// src/services/NfcManager.ts import { NativeModules, NativeEventEmitter } from 'react-native'; const { NfcReadModule } = NativeModules; export type NfcTagData = { uid: string; technology: string[]; isNdef: boolean; ndefText?: string; }; class NfcManager { private emitter = new NativeEventEmitter(NfcReadModule); private listener: any = null; startScan(onTag: (tag: NfcTagData) => void) { this.stopScan(); this.listener = this.emitter.addListener('onTagRead', onTag); NfcReadModule.startScan(); } stopScan() { NfcReadModule.stopScan(); if (this.listener) { this.listener.remove(); this.listener = null; } } async readOnce(): Promise<NfcTagData> { return await NfcReadModule.readOnce(); } } export default new NfcManager();封装之后,页面上调用就非常干净。
4.2 扫描页面交互设计
巡检页面最简单可靠的交互状态是:
- 初始状态:显示“请贴近标签”,按钮为“开始扫描”。
- 扫描中:按钮变“停止扫描”,等待标签。
- 读到标签:弹出数据卡片,展示UID和NDEF文本,并自动停止扫描。
- 异常:提示NFC不可用或权限不足,引导去系统设置。
// src/screens/ScanScreen.tsx const [scanning, setScanning] = useState(false); const [lastTag, setLastTag] = useState<NfcTagData | null>(null); const lastReadTime = useRef(0); const handleTag = useCallback((tag: NfcTagData) => { // 同一个标签在短时间内可能被多次触发,做2秒防抖 const now = Date.now(); if (now - lastReadTime.current < 2000 && lastTagRef.current?.uid === tag.uid) { return; } lastReadTime.current = now; setLastTag(tag); NfcManager.stopScan(); setScanning(false); }, []);防抖逻辑一定要加。工业手持机贴近标签的瞬间,系统可能连续回调两三次,不做防抖,巡检页面会弹好几个结果框,非常烦人。
读卡结果展示上,UID用等宽字体显示,并支持整段复制,方便运维排查。NDEF文本字段可以拼接设备编号、巡检点位等业务信息。如果标签内容包含多个record,可以在列表里展开显示。
4.3 数据落地与上报
读到的数据不能只活在页面上,需要存下来并同步到后端。我在项目里用本地缓存加远端上报两段式处理:
const saveTagData = async (tag: NfcTagData) => { // 1. 本地缓存,断网也能记录 const cacheKey = `tag_${tag.uid}_${Date.now()}`; await AsyncStorage.setItem(cacheKey, JSON.stringify(tag)); // 2. 异步上报 fetch('https://api.example.com/tag/read', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ uid: tag.uid, ndefText: tag.ndefText, deviceId: DeviceInfo.deviceId, timestamp: Date.now(), }), }).catch(err => { console.warn('上报失败,稍后重试', err); }); };上报接口用HTTPS,别走明文HTTP。有人在交流群里问“OpenHarmony上能不能搞FTP服务向服务器传数据”,技术上是能,但生产环境我强烈不推荐用FTP做数据上报。FTP是明文协议,设备在弱网环境重建链接也很麻烦。用HTTPS POST或者WebSocket都更稳,服务端接口也好写。如果你们环境特殊只让开21端口,至少也要在这个服务前做内容加密,否则巡检数据很容易被截取。
5. 踩坑实录与问题排查
5.1 React Native启动白屏的排查思路
这个坑我在项目初期整整卡了两天,现象就是App安装到真机上,打开后屏幕一片白,不报错也没有任何提示。
排查步骤一步步来:
- 先看Metro终端有没有输出。如果Metro运行正常,但页面白屏,问题大概率在加载链路。
- 确认端口反向代理有没有配。
hdc reverse tcp:8081 tcp:8081没执行,Debug包加载不到bundle,白屏没商量。 - 看原生侧日志。RN OHOS的容器会打印
loadScript日志,如果一直没出现,说明原生容器没把bundle拉起来。 - Release包也白屏的话,检查
assets里有没有生成bundle文件。打包脚本有时候没把bundle拷进资源目录。
最容易忽略的是React Native版本和RN OHOS适配版本的兼容性。有一次我把RN从0.72升级到0.74,设备上启动直接白屏,错误日志指向Hermes初始化失败。看了release note才发现适配版要搭配特定Hermes版本,升级时不能只升RN主包,react-native-harmony相关依赖得一齐升。
5.2 NFC读不到标签的几种情况
NFC回调完全不触发,先别怀疑代码,按需要排查的顺序来:
- 设备有没有NFC:手持机有高低配,低配版本可能砍了NFC硬件。在设置里查看是否有NFC开关。
- NFC开关打开没有:工业设备有些定制ROM把NFC默认关闭,需要在设置里打开。
- 权限声明:在
module.json5里确认NFC相关权限已声明。注意不同API版本的权限名有调整,直接翻SDK文档复制最新的。 - 应用是否在前台:系统标签notify回调一般只派发给前台应用,RN页面切到后台就收不到,这是正常行为。
- 天线位置:手持机的NFC天线区域通常在机身背面或侧面,有些机器贴了标签在屏幕上方但读不到,可以买个测试卡反复移动位置找天线区。定制硬件选天线时,也要参考圆形天线设计工具的画法和匹配尺寸,线圈中心和标签中心对齐最灵敏。
还有一种情况:设备系统自带了一个NFC读取应用,抢占了标签派发的优先级。解决办法是关掉系统应用,或者在系统设置里把本应用设为默认NFC处理应用。
5.3 常见问题速查表
把项目里反复出现的几个问题整理一下:
| 现象 | 可能原因 | 处理方法 |
|---|---|---|
| 启动白屏,Metro无连接 | 端口反向代理丢失 | 执行hdc reverse tcp:8081 tcp:8081后重开App |
| 启动白屏,原生报Hermes错误 | RN版本和OHOS适配版本不匹配 | 按release note对齐版本,全部依赖一起升 |
| 其它页面正常,NFC回调不触发 | NFC权限未声明或应用不在前台 | 检查module.json5;把页面留在前台再读卡 |
| NDEF文本乱码 | 没解析Text Record的头字节和语言码 | 按NDEF规范跳过状态位和语言码长度 |
| 同一标签连续多次弹出 | 系统重复回调 | JS层加时间戳防抖 |
| UID偶尔变化 | 防碰撞导致不同扇区被读到 | 工业场景用全UID匹配,不接受部分匹配 |
| 读卡卡顿,响应延迟高 | 扫描回调里做了耗时操作 | Native层只采集不解析,业务解析放JS或异步线程 |
其中NDEF乱码这个问题最有代表性。NDEF虽然规范统一,但Tag写的工具五花八门,有些工具写进去的Text Record头字节不规范,你的解析代码就得做兼容。我们后来加了解析失败的兜底,直接把payload原始字节转十六进制字符串返回,至少操作员能看到内容,不至于完全抓瞎。
6. 关于NFC安全与合法使用的几个提醒
6.1 加密门禁卡复制和中继攻击的坑,千万别踩
NFC天然和门禁卡绑定在一起,网上也有一堆相关关键词,比如“NFC解密工具”“NFC Tool破解VIP秘钥”“复制加密门禁卡”“nfc密钥库keys”之类的帖子。这里我必须把安全红线说清楚。
加密门禁卡不是不能复制吗?技术上,部分老旧加密卡确实存在已知密钥库,市面上所谓“NFC密钥库keys”就是把密钥字典下发给工具,逐个试出扇区密钥,然后整体克隆。但这类行为在绝大多数场景下都违法违规。未经授权复制门禁卡,轻则违反物业规定,重则可能涉及非法侵入的法律问题。公司项目里更不能用这种手段,一旦出事产品连带责任跑都跑不掉。
中继攻击是另一个热度不低的词。原理是准备两个设备A和B,A贴近真实门禁卡,B贴近门禁读卡器,中间通过无线或网络桥接信号,就能在卡主不在门禁旁边时远程开门。这已经不是技术问题,是很明确的非法行为,动辄能扯到盗窃。见到任何销售中继设备的,直接拉黑,涉诈嫌疑很大。
我们团队内部定了两条规矩:App里不做密钥注入功能,不做UID复制导出功能。技术方案上,需要在代码层面屏蔽对MifareClassic扇区认证相关API的直接暴露,除非有特别的安全评审。
6.2 批量写入的合法场景和密钥管理
“NFC批量写入”这个热词本身是中性需求,比如给一批空白巡检标签初始化NDEF内容、给商品贴标、给展品做个签到点。批量写入的代码很简单,就是循环调用NDEF写入接口:
const batchWriteTags = async (records: NdefRecord[], tagIds: string[]) => { for (const tagId of tagIds) { try { const tagInfo = tag.getTagInfo(tagId); const ndefTag = tag.getNdefTag(tagInfo); ndefTag.writeNdefMsg(buildNdefMessage(records)); } catch (e) { // 单张失败不影响整体,记录后继续 console.error(`标签 ${tagId} 写入失败`, e); } } };批量写入时最容易忽略两个点:一是写入前先擦除旧数据,否则可能残留上一批内容;二是写入后必须读回校验,写入成功不代表内容正确。我见过一次写入报告全绿结果批量读回时发现全部少了个字节的情况,后来查出来是循环太快,NFC写操作没有完全落盘。
密钥管理方面,凡是涉及加密扇区写入的项目,密钥绝不能硬编码在客户端。哪怕RN JS代码打包混淆过,也能被逆向工具扒出来。密钥应该存在服务端,设备端通过会话凭证临时获取,或者直接放在安全元件SE里。产品在设计阶段就要把这条纳入安全评审,后补会非常痛苦。
6.3 再往前一步:巡检数据自动上报与链路扩展
读卡功能和巡检业务跑通以后,还可以做很多扩展。高频的方向有两个:
一是批量巡检模式。拿着手持机在机房走一圈,连续读十几个标签,全部暂存在本地,巡检结束后统一上报。这个做起来不难,把防抖放开,换成“停止扫描”手动结束,然后批量保存即可。
二是标签初始化工具独立化。把写标签的流程从巡检App里拆出来,做成一个管理端工具。仓库管理员批量写标签、校验标签、导出标签清单,和生产巡检彻底隔离,权限也好控制。
数据上报接口目前我们是HTTPS POST,后续如果标签扫码频率很高,考虑改成批量接口+消息队列,减少一次一条的网络开销。之前有人提过OpenHarmony上自己搭FTP推送文件,我上面说了不建议,有一定网络维护经验的人都知道,在移动设备上做FTP客户端复用率很低,会被弱网、端口限制反复折腾,用HTTP接口才是稳妥路径。
最后再分享一点个人体会:RN和OpenHarmony这套组合,做NFC读卡是可行的,但桥接层一定要当成独立小项目来维护,接口设计稳了,后面所有业务页面都跟着省事。安全那根弦也别松,NFC能力越强,越要克制,不该碰的功能坚决不做,这是做硬件配套软件最基本的职业底线。