1. 项目缘起与整体方案拆解
RFID 识别在移动端的落地,说直白点就是让一台 Android 手机或平板具备读取电子标签的能力。这件事在工业巡检、仓储盘点、资产管理、门店盘点这些场景里需求非常硬,但真正动手做的时候,很多人会卡在同一个地方:uniapp 本身是跨端框架,它没有原生的串口、USB、串口转接、广播接收这些底层能力,而 RFID 读写器厂商给的 SDK 几乎清一色是 Android 原生 AAR 或 JAR 包。这两者之间的鸿沟,就是整个项目最核心的技术难点。
我这次做的项目,目标很明确:在一台搭载 Android 系统的工业手持终端上,通过外接或内置的 RFID 模块,实现标签的实时读取、去重、列表展示,并且把数据回传到 uniapp 的业务层做后续处理。设备侧用的是常见的 UHF RFID 模块,通信方式走的是 Android 广播(Broadcast)机制,模块厂商提供了一个原生 SDK,里面封装了串口通信、功率设置、盘存指令等底层操作。uniapp 这边负责 UI、业务逻辑、数据存储和上层的交互。
为什么选择“广播 + 原生插件”这条路线,而不是纯 JS 方案?原因很现实。RFID 模块的通信协议通常是厂商私有的,涉及串口波特率、帧头帧尾校验、CRC 校验、多标签防碰撞算法,这些用 JS 根本碰不到。Android 系统里,硬件模块和上层应用之间最常见的解耦方式就是广播,模块作为一个独立服务运行,把读到的标签数据通过 Intent 广播发出来,任何注册了对应 Action 的应用都能收到。uniapp 要做的就是通过原生插件去注册这个广播接收器,把数据捞回来,再通过 uni 的通信机制传给 JS 层。
这个方案的优势在于:第一,不需要自己写串口驱动,厂商 SDK 已经把最脏最累的活干了;第二,广播机制天然支持一对多,模块可以同时给多个应用发数据,调试的时候可以用厂商自带的 Demo 先验证硬件是否正常;第三,原生插件的形式让 uniapp 和原生代码的边界非常清晰,后续换模块、换厂商,只要广播 Action 和数据格式对得上,上层业务代码几乎不用动。
但这里有个前提必须说清楚:这套方案只适用于 Android 平台。iOS 对后台广播、外设访问的限制非常严格,RFID 模块在 iOS 上基本走的是蓝牙或 MFI 认证路线,和 Android 的广播方案完全不是一回事。所以如果你的项目需要同时覆盖 iOS,那得另起炉灶,本文不展开。
适合谁来参考这篇内容?如果你正在做 uniapp 项目,需要接入 RFID、NFC、串口设备、扫码枪这类外设,或者你手上有厂商给的 Android SDK 但不知道怎么和 uniapp 结合,那这篇实战指南就是为你写的。我会从方案设计、原生插件开发、广播接收、数据解析、常见坑位这几个维度,把整个流程拆开讲透。
2. 核心细节解析与实操要点
2.1 为什么是广播,而不是 AIDL 或 Socket
Android 里进程间通信的方式有好几种,AIDL、Messenger、Socket、广播、ContentProvider 都能用。RFID 模块厂商选择广播,不是随便拍的脑袋。广播最大的好处是松耦合:模块服务不需要知道谁在监听,它只管把数据发出去;应用也不需要绑定服务,注册个接收器就行。对于 RFID 这种“数据持续推送”的场景,广播的实时性足够,开发成本最低。
但广播有个坑:Android 8.0 之后,静态注册的广播接收器对隐式广播限制很严,很多系统广播必须动态注册才能收到。RFID 模块发的通常是自定义 Action 的广播,不受隐式广播限制,但为了保险,我建议一律用动态注册,在 Activity 的 onResume 里注册,onPause 里注销。这样既能保证收到数据,又不会在后台被系统干掉。
另一个细节是广播的发送方式。有些模块用的是sendBroadcast,有些用的是sendOrderedBroadcast。前者所有接收器都能收到,后者按优先级顺序接收,高优先级的可以截断。RFID 场景一般用普通广播就够了,除非你有多个应用同时监听且需要控制优先级。
2.2 原生插件的形态选择:Module 还是 Component
uniapp 原生插件分两种:Module 和 Component。Module 是没有 UI 的功能模块,Component 是带 UI 的组件。RFID 识别显然属于功能型,选 Module。Module 的生命周期由 JS 层调用触发,适合做“初始化—开始盘存—停止盘存—获取数据”这种命令式操作。
插件开发用 Android Studio,创建一个 Android Library 模块,引入厂商的 AAR 或 JAR,然后实现 uniapp 的UniModule接口。关键方法用@UniJSMethod注解暴露给 JS 调用。广播接收器在 Module 初始化时动态注册,收到数据后通过UniModule的fireGlobalEventCallback或callback把数据传回 JS。
这里有个经验:不要把广播接收器写在 Activity 里,因为 uniapp 的 Activity 可能被回收,而 Module 是单例的,生命周期更长。把接收器放在 Module 里,用 Application Context 注册,稳定性会好很多。
2.3 数据格式的协商与解析
厂商 SDK 通过广播发出来的数据格式,通常是一个 byte 数组或者一个字符串。常见的有两种:一种是直接给 EPC 码的十六进制字符串,另一种是给完整的标签信息,包括 EPC、TID、RSSI、天线号、读取次数。你在写解析逻辑之前,一定要拿到厂商的通信协议文档,搞清楚每个字段的偏移量和长度。
我遇到过一种情况:厂商 Demo 里广播出来的 EPC 是 24 位十六进制,但实际标签是 96 位 EPC,需要做补零或截取。这种细节文档里不一定写清楚,最好的办法是用厂商 Demo 先读几个已知标签,把原始数据打出来对照,确认格式后再写解析代码。
去重逻辑也很关键。RFID 盘存是高频重复的,同一个标签一秒可能被读到几十次。如果不去重,列表会疯狂刷新,UI 直接卡死。我的做法是在原生层用 HashMap 做一级去重,key 用 EPC,value 用时间戳,同一个 EPC 在 500ms 内只上报一次。JS 层再做二级去重,用 Set 或者对象缓存已展示的 EPC。两级去重下来,列表既实时又稳定。
2.4 权限与硬件兼容性
Android 的权限模型这几年变化很大。RFID 模块如果走 USB 或串口,可能需要android.permission.USB_PERMISSION或者串口设备的访问权限。如果是内置模块,通常不需要额外权限,但蓝牙模块需要BLUETOOTH_CONNECT和BLUETOOTH_SCAN(Android 12+)。这些权限要在 manifest 里声明,并且在运行时动态申请。
兼容性方面,不同厂商的模块功率设置范围不一样,有的 0-30dBm,有的 5-26dBm。功率太高会干扰相邻标签,太低读不到远处标签。实际调试时,我一般从中间值开始,比如 20dBm,然后根据读取距离和误读率微调。天线号也要注意,多天线模块需要指定用哪根天线盘存,单天线模块一般默认天线 1。
3. 实操过程与核心环节实现
3.1 环境准备与工程结构
先列一下我这次用的环境,供你对照:
| 项目 | 版本/型号 |
|---|---|
| uniapp | HBuilderX 3.8+,Vue2 语法 |
| Android Studio | 2022.3 或更高 |
| Android SDK | API 30 编译,最低 API 21 |
| RFID 模块 | UHF 模块,厂商提供 AAR |
| 测试设备 | Android 10 工业手持终端 |
工程结构上,uniapp 项目根目录下建一个nativeplugins文件夹,里面放原生插件的目录。插件目录结构如下:
nativeplugins/ RFIDModule/ android/ libs/ rfid-sdk.aar src/ main/ java/ com/example/rfid/ RFIDModule.java AndroidManifest.xml package.jsonpackage.json里声明插件的 id、版本、名称、方法列表。这个文件是 uniapp 识别插件的关键,格式必须对,否则 HBuilderX 打包时会报“插件不存在”。
3.2 原生插件核心代码实现
先看 Module 的主体结构。我把它简化成最核心的几个方法:
public class RFIDModule extends UniModule { private Context mContext; private BroadcastReceiver mReceiver; private UniJSCallback mCallback; private Map<String, Long> mTagCache = new HashMap<>(); @UniJSMethod(uiThread = true) public void init(UniJSCallback callback) { mContext = mUniSDKInstance.getContext(); mCallback = callback; registerReceiver(); callback.invoke("init success"); } private void registerReceiver() { mReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { String action = intent.getAction(); if ("com.example.rfid.TAG_DATA".equals(action)) { byte[] epcBytes = intent.getByteArrayExtra("epc"); String epc = bytesToHex(epcBytes); long now = System.currentTimeMillis(); Long last = mTagCache.get(epc); if (last == null || now - last > 500) { mTagCache.put(epc, now); JSONObject result = new JSONObject(); result.put("epc", epc); result.put("rssi", intent.getIntExtra("rssi", 0)); result.put("antenna", intent.getIntExtra("antenna", 1)); mCallback.invoke(result.toString()); } } } }; IntentFilter filter = new IntentFilter("com.example.rfid.TAG_DATA"); mContext.registerReceiver(mReceiver, filter); } @UniJSMethod(uiThread = true) public void startInventory(UniJSCallback callback) { // 调用厂商 SDK 开始盘存 RfidSdk.getInstance().startInventory(); callback.invoke("started"); } @UniJSMethod(uiThread = true) public void stopInventory(UniJSCallback callback) { RfidSdk.getInstance().stopInventory(); callback.invoke("stopped"); } @UniJSMethod(uiThread = true) public void setPower(int power, UniJSCallback callback) { RfidSdk.getInstance().setPower(power); callback.invoke("power set to " + power); } @Override public void onActivityDestroy() { if (mReceiver != null) { mContext.unregisterReceiver(mReceiver); } super.onActivityDestroy(); } private String bytesToHex(byte[] bytes) { StringBuilder sb = new StringBuilder(); for (byte b : bytes) { sb.append(String.format("%02X", b)); } return sb.toString(); } }这段代码有几个关键点。第一,@UniJSMethod(uiThread = true)表示方法在 UI 线程执行,因为广播注册和 SDK 调用通常要求在主线程。第二,去重缓存mTagCache放在原生层,避免高频数据穿透到 JS 层。第三,onActivityDestroy里注销广播,防止内存泄漏。
package.json的内容大概长这样:
{ "name": "RFIDModule", "id": "RFIDModule", "version": "1.0.0", "description": "RFID识别原生插件", "android": { "plugins": [ { "type": "module", "name": "RFIDModule", "class": "com.example.rfid.RFIDModule" } ] } }3.3 uniapp 侧调用与数据展示
JS 层的调用非常直接。先引入插件:
const rfid = uni.requireNativePlugin('RFIDModule');然后初始化并监听数据:
export default { data() { return { tagList: [], tagSet: new Set(), scanning: false }; }, methods: { initRfid() { rfid.init((res) => { console.log('RFID init:', res); }); }, startScan() { this.scanning = true; rfid.startInventory((res) => { console.log('inventory started'); }); // 监听标签数据 this.listenTag(); }, listenTag() { // 这里用轮询或全局事件的方式获取数据 // 实际项目中建议用 uni.$on 或 callback 持续回调 }, stopScan() { this.scanning = false; rfid.stopInventory((res) => { console.log('inventory stopped'); }); }, onTagReceived(tag) { if (this.tagSet.has(tag.epc)) return; this.tagSet.add(tag.epc); this.tagList.unshift({ epc: tag.epc, rssi: tag.rssi, antenna: tag.antenna, time: new Date().toLocaleTimeString() }); } }, onLoad() { this.initRfid(); }, onUnload() { this.stopScan(); } };这里有个实际开发中的取舍:原生插件的 callback 是一次性的还是持续性的?UniJSCallback默认调用一次就释放,如果要持续回调,需要用UniJSCallback的invokeAndKeepAlive方法,或者改用全局事件fireGlobalEventCallback。我这次用的是全局事件,在原生层收到广播后mUniSDKInstance.fireGlobalEventCallback("rfidTag", result),JS 层用uni.$on('rfidTag', handler)监听。这种方式更灵活,多个页面都能监听同一个事件。
3.4 功率与盘存参数调优
功率设置不是越大越好。我做过一组对比测试,同一批标签,不同功率下的读取表现:
| 功率(dBm) | 读取距离 | 误读率 | 备注 |
|---|---|---|---|
| 15 | 约 0.5m | 极低 | 适合近距离精确读取 |
| 20 | 约 1.5m | 低 | 日常盘点推荐 |
| 25 | 约 3m | 中 | 远距离快速盘存 |
| 30 | 约 5m | 高 | 容易串读相邻区域标签 |
实际项目中,我一般把功率设成 20-23dBm,配合天线增益和标签灵敏度,基本能覆盖 1-2 米的作业范围。如果是仓储整托盘盘点,可以临时调到 26dBm,但要注意屏蔽相邻货架。
盘存指令的重复次数(Session 和 Target)也影响读取效果。Session 1 适合快速移动的标签,Session 2 适合静态盘点。Target A 和 Target B 交替使用可以减少标签“疲劳”,提高读取率。这些参数厂商 SDK 一般都有接口,建议在初始化时根据场景预设好。
4. 常见问题与排查技巧实录
4.1 广播收不到数据怎么办
这是最高频的问题。排查顺序我总结成一张表:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 完全收不到广播 | Action 写错 | 用厂商 Demo 对比 Action 字符串 |
| 偶尔收到 | 注册时机不对 | 改到 onResume 注册,onPause 注销 |
| 收到但数据为空 | Extra key 不对 | 打印 intent.getExtras() 看所有 key |
| Android 8+ 收不到 | 隐式广播限制 | 确认是自定义 Action,动态注册 |
| 锁屏后收不到 | 后台限制 | 申请前台服务或保持屏幕常亮 |
我踩过最坑的一次是 Action 字符串里多了一个空格,肉眼根本看不出来,用equals对比才发现。所以建议把 Action 定义成常量,原生和 JS 层都引用同一个值。
4.2 标签重复上报导致列表卡顿
前面提过两级去重,这里补充一个细节:去重的时间窗口设多少合适?设太短,同一个标签会重复出现;设太长,标签离开后再回来会被误判为已存在。我的经验值是 500ms 到 1s。如果是高速传送带场景,可以缩短到 200ms;如果是人工盘点,1s 足够。
另外,列表渲染用unshift把新标签插到最前面,比push到末尾体验好,因为用户更关注刚读到的标签。但unshift在数据量大时性能差,超过 500 条建议改用虚拟列表或分页加载。
4.3 原生插件打包后找不到类
这个问题通常出在混淆配置上。厂商的 AAR 里如果有反射调用的类,混淆后类名变了,就会ClassNotFoundException。解决办法是在proguard-rules.pro里 keep 住厂商 SDK 的包名:
-keep class com.example.rfid.** { *; } -keep class com.rfid.sdk.** { *; }还有一个常见原因是package.json里的 class 路径写错了,或者 AAR 没有正确放到libs目录并在build.gradle里引用。打包前一定要用uni.getSystemInfo确认插件是否被识别,如果requireNativePlugin返回 undefined,说明插件根本没打进去。
4.4 权限申请框监听不到
热词里有人问“uniapp 能不能实时监听权限申请框的出现和消失”,这个问题在 RFID 场景里很实际。Android 的权限申请框是系统弹的,JS 层确实监听不到它的显示和消失。但你可以通过plus.android的requestPermissions回调来判断用户是否授权。更稳妥的做法是在原生插件里封装权限申请逻辑,申请结果通过 callback 返回给 JS,JS 根据结果决定是否继续初始化 RFID。
4.5 不同厂商模块的适配经验
我前后接过三家厂商的模块,广播 Action 和数据格式都不一样。有的用com.rfid.data,有的用android.intent.action.RFID,EPC 有的给 byte 数组,有的给十六进制字符串。适配的关键是抽象出一层“数据适配器”,把不同厂商的原始数据统一转成{epc, rssi, antenna, timestamp}这个标准结构。上层业务只认这个结构,换模块时只改适配器,不动业务代码。
另外,有些模块的 SDK 需要在Application里初始化,有些需要在Activity里。如果厂商文档没写清楚,就去看它的 Demo 工程,照抄初始化位置。这个细节看似小,但初始化位置不对,后面所有调用都会失败。
5. 性能优化与上线前的检查清单
5.1 高频数据下的 UI 流畅度
RFID 盘存时,一秒几十条数据是常态。如果每条数据都触发setData,页面必卡。我的优化手段有三个:第一,原生层做时间窗口去重,把上报频率压到每秒 5-10 条;第二,JS 层用requestAnimationFrame或定时器批量更新列表,比如每 200ms 把缓冲区的新标签一次性渲染;第三,列表项尽量简单,不要放复杂组件,图片用懒加载。
实测下来,经过这三层优化,即使同时读到 200 个标签,列表滚动依然流畅。如果还是卡,那就上虚拟列表,只渲染可视区域的项。
5.2 电量与发热控制
RFID 模块持续发射射频信号,耗电和发热都不小。工业手持终端一般电池容量大,但长时间盘存还是要注意。我的做法是:不盘存时立即调用stopInventory,不要让它空转;功率不要长期开最高;如果设备支持,设置盘存间隔,比如读 100ms 停 50ms,既能读到标签又降低功耗。
发热方面,模块天线附近温度会升高,这是正常的。但如果烫手,就要检查功率是否过高,或者模块是否被遮挡导致反射功率过大。
5.3 上线前的自检清单
打包上架前,我一般会过一遍这个清单:
- [ ] 原生插件在 release 包中能正常加载
- [ ] 混淆规则已添加,厂商 SDK 类未被混淆
- [ ] 动态权限已申请,拒绝后有引导提示
- [ ] 广播接收器在页面销毁时已注销
- [ ] 去重逻辑生效,无重复标签
- [ ] 功率默认值合理,不会干扰其他设备
- [ ] 异常情况下(模块未连接、SDK 初始化失败)有兜底提示
- [ ] 连续盘存 30 分钟无崩溃、无内存泄漏
这个清单看着简单,但每一条我都踩过坑。尤其是混淆和广播注销,前者导致线上包直接闪退,后者导致页面退出后还在后台收数据,白白耗电。
6. 从 RFID 延伸到其他外设接入的思路
这套“广播 + 原生插件”的架构,其实不只适用于 RFID。扫码枪、NFC 读卡器、串口传感器、甚至某些蓝牙设备,只要厂商提供了 Android 广播接口,都可以用同样的模式接入 uniapp。核心思路就是:原生层负责和硬件打交道,把数据标准化;JS 层负责业务逻辑和 UI;两层之间用 callback 或全局事件通信。
我后来用同样的架构接了一款 NFC 读卡器,只改了广播 Action 和数据解析部分,插件主体代码几乎没动。这说明架构的抽象层次是对的。如果你后续要接其他外设,建议把“广播接收 + 数据标准化”这部分做成通用模块,不同设备只写适配器,能省很多重复劳动。
最后分享一个调试技巧:在原生插件里加一个“原始数据日志”开关,把收到的每一条广播的原始 byte 数组和解析后的结果都打到 logcat 里。调试阶段打开,上线前关掉。这个日志在排查数据格式问题时非常有用,比猜来猜去高效得多。