最近在做基于OpenHarmony的移动巡检项目,现场每一台设备上都贴了NFC标签,巡检人员只要把手机App靠近标签,就能把设备编号、上次维保时间、责任人这些信息读出来。团队技术栈一直是React Native,到了鸿蒙生态这里,我第一反应还是用RN——业务层代码如果能复用,后面维护成本会低一个量级。这篇就把整个落地过程拆开讲:从工程搭建、原生NFC能力封装,到RN侧调用和一堆现场踩坑,完整记录如何用React Native开发OpenHarmony应用并读取NFC标签数据。
1. 项目背景与整体技术路线
1.1 为什么选React Native而不是纯ArkTS开发
OpenHarmony的官方原生开发语言是ArkTS,配合ArkUI声明式UI,做单平台应用效率其实不低。但我这个项目有一个硬约束:App不只是跑在鸿蒙设备上,还要同时出Android版本。巡检小组拿的设备既有HarmonyOS NEXT,也有几台老安卓机,如果两套代码分开写,双倍开发量还算小事,真正恶心的是两边的业务逻辑会逐渐漂移,最后变成两个行为不一致的应用。
React Native的价值在这里就很直接了。RN在Android上的生态和稳定性已经经过验证,而社区针对OpenHarmony的适配也已经能支撑真实业务——UI层用RN组件渲染,业务逻辑用TypeScript写,平台差异收口在一层薄薄的原生模块里。这个项目里,NFC读取能力就是典型的平台差异点:Android有现成的第三方库,OpenHarmony没有直接对应的RN库,所以我需要自己写鸿蒙原生桥接模块。
选这条路的前提是团队本来就熟RN,前端逻辑用不上ArkTS的知识,大家不用从零学ArkUI的开发范式。如果项目只针对鸿蒙单一平台、团队又没有RN存量,那直接用ArkTS写会更省事。技术选型没有绝对的对错,关键看团队资产和业务范围,这也是我这次选择RN路线的最核心判断依据。
1.2 NFC读取方案的选型权衡
NFC标签读取在移动端主要分两类场景:一类是读卡器模式(Reader Mode),App主动或被动读取外部标签;另一类是卡模拟(Card Emulation),把设备当成一张NFC卡片使用。这个项目只需要读标签,不需要卡模拟,所以方案确认得很早:走Reader Mode。
在RN生态里,Android侧最常用的库是react-native-nfc-manager,它对NFC的封装比较完整,支持ISO14443A/B、MIFARE等常见卡型,MIFARE Classic这种老标签也能用MifareClassic接口处理。但问题来了:这个库底层依赖Android的NfcAdapterAPI,OpenHarmony上根本没有这套接口,直接把库拿来用会崩。
所以我把OpenHarmony的NFC能力放在原生侧自己封装,然后通过RN的NativeModule机制暴露给JS层。这样做唯一要解决的就是原生API的桥接,业务代码还是统一走RN。实际操作下来这个方案很稳,因为OpenHarmony对NFC标签的支持集中在@ohos.nfc.tag模块,接口并没有那么复杂,用一个ArkTS类就能把标签发现和NDEF解析包起来。
这里有一个重要的设计判断:不要让RN侧感知到标签类型差异。OpenHarmony的NFC标签可能有NFC-A、NFC-B、NFC-F、NDEF等不同类型,如果JS层每个类型都去判断,代码会非常啰嗦。我把原生模块的接口设计成只返回一个统一的“已解析结果”,JD类型的区分在原生侧完成,JS层只用管展示和错误处理。
2. 开发环境与工程搭建
2.1 需要准备的工具链
这个项目不能只靠一套开发工具,RN for OpenHarmony的构建链路比普通RN工程要长一截。我建议把下面这些工具先准备齐,版本匹配问题后面会很影响心情:
- Node.js 18+,RN脚手架和npm包管理依赖它。
- DevEco Studio 5.x,需要带OpenHarmony SDK,这是鸿蒙原生工程的编译环境。
- React Native CLI工具链,用于初始化RN项目和跑Metro打包。
@react-native-oh/cli或者社区提供的RN for OpenHarmony模板,看具体时候的版本。- 一台有NFC功能的OpenHarmony真机,这个没法省,模拟器支不支持NFC不好说,我实测下来真机最稳。
关于版本匹配,我踩过一个教训:RN的0.72、0.73、0.74版本之间,社区对OpenHarmony的适配程度不一样。我这次用的是0.72.x,配合当时稳定的模板版本,整体比较顺利。如果你拿到更新版本的模板,建议先看它的README里写了支持哪个RN版本,别直接用npx拉最新RN版本去配旧模板,那种版本错位问题排查起来非常浪费时间。
2.2 从零创建RN + OpenHarmony工程
创建工程有两种路径:一种是从普通的RN工程改造,另一种是用社区模板直接生成。我建议用社区模板直接生成,因为RN for OpenHarmony的工程结构比普通RN工程多了鸿蒙原生目录和编译配置文件,手工改很容易漏。
我用的是类似这样的初始化命令:
npx @react-native-oh/cli init RNNfcDemo --version 0.72这个命令会生成一个同时包含RN标准目录结构和OpenHarmony工程目录的混合项目。生成之后,项目里会有一个类似harmony/的目录,这才是DevEco Studio要打开的原生工程目录。第一次打开时,DevEco会提示下载依赖、同步项目,这个过程比较慢,耐心等它跑完。
在写业务代码之前,先做一个最基础的验证:把模板自带的Demo页面跑到一个真机上,确认RN的Metro Bundler能在OpenHarmony设备上正常加载。这个验证特别重要,因为RN在鸿蒙上出问题,很多时候症状都堆在启动白屏这一步,如果最基础的链路都没通,后面写NFC代码等于是在空中楼阁上加砖。
验证命令:
npm install npm start然后在DevEco里跑entry模块到真机。页面能弹出来、能看到模板UI,说明RN的基础链路已经通了。
3. 核心实现:NFC标签读取模块
3.1 原生侧:用ArkTS封装NFC能力
NFC读取的核心逻辑全部写在OpenHarmony原生侧。我新建了一个NfcTagReader.ets文件,职责很单一:接收系统回调的标签信息,解析出NDEF内容,返回给上层。
OpenHarmony的NFC标签读取有几个关键对象:TagInfo是系统发现标签后给到应用的基础数据对象,里面带有标签的UID、技术列表这些元数据;NdefTag是从TagInfo中获取到的NDEF能力接口,通过它才能读NDEF消息;NdefMessage就是最终的数据体,里面可能有多条NdefRecord,每条记录又分类型、ID、负载三部分。
我在原生侧做的工作是把这些对象转换成一种足够简单的数据结构。比如NdefRecord的Type Name Format(TNF)、Type字段、Payload字段,我直接打包成一个JSON可序列化的对象,避免RN侧再去和二进制数据打交道。还有一个细节:NFC标签里的Payload往往是二进制字节,直接转字符串可能会乱码,所以我同时返回了Base64编码的Payload,由JS侧按业务需要解码。
一个简化后的原生模块代码长这样:
// NfcTagReader.ets import { nfcController } from '@ohos.nfc.controller'; import { tag } from '@ohos.nfc.tag'; export class NfcTagReader { static parseTagInfo(tagInfo: tag.TagInfo): object { let result = { tagId: Array.from(tagInfo.id), technology: tagInfo.technology, ndefMessage: null }; let ndefTag = tag.getNdefTag(tagInfo); if (ndefTag) { let ndefMessage = ndefTag.getNdefMessage(); if (ndefMessage) { let records = []; for (let i = 0; i < ndefMessage.getRecordCount(); i++) { let record = ndefMessage.getRecord(i); records.push({ tnf: record.getTnf(), type: arrayToHex(record.getType()), payload: bytesToBase64(record.getPayload()) }); } result.ndefMessage = records; } } return result; } }实际代码里还会做一些空值保护——比如有些NFC标签只存了URL,没有标准NDEF消息;还有一些MIFARE标签虽然有数据,但数据格式不是NDEF,这时getNdefTag能拿到对象,但getNdefMessage返回空。这些情况都要在原生侧就兜住,不能抛异常到RN层。
3.2 JS侧:通过NativeModule桥接调用
原生模块封装好之后,就要把它暴露给RN侧。在RN for OpenHarmony里,自定义原生模块需要继承一个CxxTurboModule或者通过框架现有的注册机制导出。我这里采用TurboModule的方式,把NfcTagReader注册成一个叫NfcTagModule的原生模块。
这里要特别提一下OpenHarmony桥接和Android桥接的差异:Android的ReactPackage注册方式在鸿蒙上并不完全适用,社区适配的模板里会给出对应的TurboModule注册入口,具体就是照着模板中已有的SampleTurboModule把它改名为自己的模块即可。这个注册文件的路径一般在鸿蒙工程的entry/src/main/cpp/或者相关的TS接口定义目录下。
RN侧接住原生模块的逻辑:
// NfcModule.ts import { TurboModule } from 'react-native'; export interface NfcTagNativeModule extends TurboModule { readTagInfo(): Promise<Object>; } export default TurboModuleRegistry.getEnforcing<NfcTagNativeModule>('NfcTagModule');然后在业务页面里调用时,我习惯再包一层,把数据转换和错误处理都放在这层,这样页面组件里不会到处都是try/catch:
// useNfcReader.ts import NfcTagModule from './NfcModule'; export async function readNfcTag(): Promise<NfcTagResult> { try { const raw = await NfcTagModule.readTagInfo(); return normalizeNfcResult(raw); } catch (e) { // 这里把原生错误转换成用户能看懂的信息 if (e.code === 'NFC_DISABLED') { return { success: false, message: '请先打开系统NFC开关' }; } return { success: false, message: '未识别到标签,请靠近天线区域' }; } }这一层的意义在于:原生错误码往往是英文或者ArkTS风格的异常,直接抛给用户简直劝退;RN侧统一转换后,后续无论在哪一个页面调用,错误处理逻辑都是同样的表现,不会各写一套。
3.3 UI展示与状态处理
NFC读取的交互状态比普通接口请求要多:要有“等待靠近标签”的提示态、“已识别到标签”的短暂反馈、“解析成功”的结果展示、“解析失败”的错误提示。因为用户必须把手机贴近标签,这个物理动作有不确定性,UI一定要把引导做得足够清楚。
我写了一个巡检页面,整体状态机用一个枚举控制:
type NfcState = | 'idle' // 初始态 | 'discovering' // 已开启监听,等待贴卡 | 'reading' // 已发现标签,正在解析 | 'success' // 解析成功 | 'error'; // 解析失败页面启动后,通过useEffect调用原生模块开启NFC标签监听;用户将标签靠近手机时,notify事件会触发,原生模块解析后通过Promise或者事件回调把结果送回JS。RN侧收到结果后,先判定是成功还是失败,然后切换到对应状态。整个过程保持在几秒内,用户在等待时能看到一个简单的loading状态而不是干等。
为了让巡检人员少拿手机怼标签,我还加了轻微的触控反馈——读取成功时App发出短震动提示,这个体验细节在很多NFC应用里都被忽略了。巡检现场环境嘈杂,声音提示容易被盖住,震动比声音实用得多。鸿蒙原生侧提供震动接口,RN侧同样用NativeModule桥接一下就行。
3.4 批量读取场景的扩展
项目中后来还接了一个批量读取的诉求:不是让用户一张一张刷新,而是把一批标签数据先通过NFC读出来,带回到后台批量处理。比如巡检时连续读取10台设备的标签,前台保持一个“已读数量”的计数,每读到一个标签就追加一条记录,最后统一提交到服务器。
RN侧实现这个流程非常顺手,维护一个数组状态,每次NFC标签回调成功后setRecords((prev) => [...prev, newRecord]),同时更新已读数量。OpenHarmony原生侧要做的工作就多一层:同一个标签连续贴两次会产生重复数据,原生模块可以在短时间内过滤掉相同UID的标签,避免UI上出现重复条目。过滤规则简单点就是记录最近一次读取时间和标签UID,相隔小于1秒且UID相同就忽略。
这个场景可以顺便解决热词里提到的“NFC批量写入”的一部分痛点:批量写和批量读在交互上很像,都是连续操作多张标签,区别只在数据流向。如果后续要做批量写入,原生模块加一个writeNdef方法,JS侧的批量管理逻辑几乎可以复用。
4. 权限配置与系统适配细节
4.1 权限声明与NFC开关检查
OpenHarmony应用使用NFC能力需要权限声明,在鸿蒙工程的module.json5里加入NFC相关权限。不同API版本的权限名略有差异,多数情况下是ohos.permission.NFC_TAG。声明方式如下:
{ "module": { "requestPermissions": [ { "name": "ohos.permission.NFC_TAG" } ] } }我实际遇到过明明在module.json5里声明了权限,但运行时还是提示没有权限的情况,最后发现是API版本和SDK版本匹配的问题。DevEco Studio有时候会帮你自动同步权限配置,有时候不会,最好在跑真机前手动检查一遍这个文件。
另外,NFC读取前一定要判断系统NFC开关是否开启。很多用户的NFC开关是关闭状态,如果App不做前置检查,用户把手机贴上标签毫无反应,很容易以为App坏了。OpenHarmony的nfcController里可以查询NFC状态,我将它封装在原生模块的checkNfcStatus()方法中,RN侧在页面进入时先调用一次,如果NFC没开就弹窗引导用户去系统设置里打开。
4.2 标签类型与兼容性
NFC标签不是一个统一的“铁片”,实际巡检验设备时,遇到最常见的是NFC-A类型(ISO14443A)的标签,里面存的又是NDEF格式数据。但也有可能会遇到纯MIFARE Classic标签,这种标签在手机上兼容性最差,很多手机根本没有对应的读卡驱动,OpenHarmony设备上表现也不稳定。
我在原生侧遇到不支持的标签类型时,会返回一个明确的错误码,比如TAG_TYPE_UNSUPPORTED。RN侧看到这个码就提示用户“当前标签暂不支持”,而不是让用户以为贴卡姿势不对。这里有一个经验:不要试图在RN侧做标签兼容性文档式的穷举,把判断逻辑留在原生侧,JS层只拿到结果,这个边界是桥接模块最合理的设计。
另外,有NFC功能的OpenHarmony设备不止手机一种,也可能是开发板、工业手持机。这些设备的NFC天线位置千差万别,有的在背面、有的在侧面,交互引导文案要写得有弹性。我的做法是把“天线位置”做成一个可配置项,不同型号设备用不同的引导文案,这比写死一个“请将手机背面贴近标签”要靠谱。
5. 常见问题与排查实录
5.1 RN应用启动白屏
这个坑在整个项目里最折磨人。RN for OpenHarmony应用在首次启动时,Metro服务没有正确连接,就会出现白屏。最直接的原因是绑定Metro的Bundle路径配置不对,或者真机和开发机不在同一局域网,Metro服务不响应。
排查套路我总结成三步:
- 先看DevEco控制台日志,有没有“Loading from Metro”字样。没有的话说明Bundle加载根本没发起。
adb reverse只对Android有用,鸿蒙真机要用hdc工具做端口映射。比如Metro跑在8081端口,执行hdc reverse tcp:8081 tcp:8081,这个操作非常关键。- 检查原生工程的
EntryAbility里有没有配置正确的Bundle URL,默认往往是localhost,换成开发机的局域网IP。
还有一个坑是debug和release的Bundle路径不同,release包没有Metro服务可以用,要预先打包Bundle打进App里。现场演示时如果只开着Metro,关机重启后App就会白屏,后来我改成把Bundle打进包里,彻底不去依赖Metro了。
5.2 标签读不出来、超时
NFC读不出来最容易被忽视的原因是贴卡时机不对。系统调用标签回调不是持续性的,它是标签进入磁场时触发一次事件,用户贴卡太快或太慢都可能错过。我在页面上加了一个“监听中”的状态指示,让用户看到指示灯变了再贴卡,这个物理交互细节可比代码细节重要多了。
第二个原因是标签本身坏了。NFC标签是电子信息载体,设备巡检场景里标签贴在外面,经常被雨淋、被擦碰,有些标签的芯片已经坏掉或者天线断裂。这种情况在调试时很恼人,因为你没法从代码层面解决。我的排查方法是在原生侧加一个标签信号强度回显,拿到标签的TagInfo就说明物理连接成功,拿不到就基本是标签损坏或天线位置不对。
第三个原因是OpenHarmony设备上多个App同时抢NFC事件。如果设备上还装了别的NFC应用,比如系统自带钱包,标签识别事件可能被系统优先分发给别的App。可以尝试在原生侧注册前台读卡优先级,把自己App放到前台的读取优先级里,像Android的enableReaderMode一样。不同OpenHarmony版本对这个功能的支持还不一致,调试时最好把其他NFC应用关掉。
5.3 NDEF解析乱码与空数据
NDEF标签里Payload的数据格式五花八门。最常见的问题是把UTF-16编码的数据强行按UTF-8解码,字符串全变乱码。我的处理方法是解析NDEF记录时检查Record的Encoding参数,如果标签里明确标了UTF-16,就按UTF-16解码,否则按UTF-8。这个逻辑放在原生侧的解析函数里,RN侧不用关心。
还有一种情况是标签里有NDEF消息,但Payload全是XML或者自定义二进制协议。我在业务中遇到的设备标签,有的塞的其实是一段JSON,有的则是一个URL。RN侧拿到Base64的Payload后,可以先尝试按UTF-8解析成文本,如果解析出来的内容以{开头就当成JSON,以http开头就引导打开浏览器,否则就把原始数据用Hex展示出来。这个“先探测再展示”的逻辑让RN侧能兼容很多种标签内容格式。
空标签问题也经常被忽略。有些全新的NFC标签出厂时只有厂商数据,没有NDEF消息,App拿到的NdefMessage是空的。如果业务上不允许空标签,原生侧返回错误;如果允许,RN侧就展示“标签无数据”。我在巡检场景里专门允许空标签,因为有的设备标签本来就是空白待写数据,第一次贴上去要的是“确认这是个新标签”的效果。
5.4 权限、NFC开关与设备兼容问题速查
这里整理一张排查表,方便现场应急:
| 问题现象 | 直接原因 | 处理方式 |
|---|---|---|
| 提示权限不足 | module.json5未声明或SDK不匹配 | 手动检查权限声明,确认API Level |
| 贴卡无响应 | NFC开关未打开或天线位置不对 | 引导用户开NFC,换贴卡角度 |
| 已识别但解析失败 | 标签损坏或非NDEF格式 | 原生侧返回错误码,JS提示不支持 |
| 读到的数据乱码 | 编码格式判断错误 | 原生侧按Encoding参数解码 |
| 其他App抢占事件 | 前台读卡优先级未启用 | 注册系统读卡优先级 |
| 读卡后页面卡顿 | Metro服务断连或Bundle未打包 | hdc reverse或打包Bundle进App |
排查现场如果还不行,先看DevEco的Log,OpenHarmony原生侧的错误日志信息量很大,不要只死盯RN侧的红屏。RN的红屏往往只是把原生错误翻译了一遍,真正的原因在原生层级更靠下。
写在最后的一些实操体会
这个项目做下来,我对RN在OpenHarmony生态里的定位比之前清晰了很多。RN的跨端优势在鸿蒙上是真实存在的,但前提是你要愿意写原生桥接代码,把平台差异收口干净。NFC读取这种系统能力,其实很适合做桥接:逻辑相对窄,接口不算深,原生侧包一层,JS侧用起来跟在Web环境里调用API一样顺手。
几个小经验给同行参考:第一,工程模板拿到后先别急着写业务,先把RN基础链路跑通,这个时间省不得;第二,NFC相关错误码一定要在JS侧翻译成人类语言,这个交互体验对巡检人员来说比代码架构重要得多;第三,关注标签的物理状态,设备上两三块钱一张的标签,稳定性没你想象的那么好,代码里要做好标签损坏的准备。后续如果要把这个方案做得更完善,可以考虑把NFC读取做成一个全局能力,封装成独立的包供多个RN应用复用,再往后把批量写入和批量读取做成一套完整的数据采集工具,就能覆盖现场资产盘点、巡检维保这整条业务线了。