Tauri / Android / JNI / RFID
在 Tauri Android App 中封装 RFID SDK
2026年7月28日
Tauri 2.0 支持将 Web App 打包为 iOS 和 Android App。以 Web-based 方式开发,界面迭代通常比 Java / Kotlin 原生开发更快,也可以直接使用 React 生态的大量组件与工具;手机端与后台也能共用同一套设计语言和部分业务逻辑。
不过,WebView 仍无法直接使用所有 Android 原生能力。Tauri 官方已提供 barcode scanner、biometric、notification 等常用功能的 plugin;遇到 RFID 扫描枪、专用打印机等厂商硬件,厂商只会提供 Android SDK,就需要自行编写一个 plugin,把原生 API 封装成前端可调用的接口。
这篇以 FAMS 的 RFID 扫描枪为例,记录由厂商 SDK 到 Tauri plugin 的接入流程。官方的 移动端插件文档 说明了 Kotlin command 与 plugin event,但整体写得较为简单,一些细节也没有说得很清楚。
本例使用的 RFID SDK
在这个例子中,我们使用一个 RFID SDK。它包含两类文件:
HCUHF_v1.0.7_20241126.aar:Java / Kotlin 可以 import 的 RFID API,提供UHFReader、UHFTagEntity、SelectEntity等类别。- JNI library:
libModuleAPI.so、libModuleAPIJni.so、libSerialPortHc.so、libpower.so。AAR 的 Java API 会加载这些 library,再经由 JNI 操作扫描枪、serial port 和电源。
.aar 本身是 zip 格式,也可以内含 jni/<ABI>/*.so;这份 SDK 的 native library 另外提供,因此除了加入 AAR,也要将 .so 放进 Android 专案。
这些 SDK 类会不直接给 React 使用,而是包在一个 tauri plugin 中,plugin 对前端提供 JS API;前端只需 import tauri-plugin-fams-rfid-api,不需要知道 UHFReader 或 JNI library 的存在。
建立 Android plugin
先在 App 根目录加入 Android target:
pnpm tauri android init
再建立带 Android library 的 plugin。从 App 根目录执行:
pnpm tauri plugin new fams-rfid --android
mkdir -p plugins
mv tauri-plugin-fams-rfid plugins/
这会产生 Rust crate、TypeScript API package 和 Android Kotlin module。
Tauri 会建立 src-tauri/gen/android/,其下的 app 是最终 APK 的 Android Gradle module;plugin 的 Kotlin 代码则在 plugins/tauri-plugin-fams-rfid/android/。两者是不同 module,接入 SDK 时需要分别处理。
将 AAR 和 .so 放进 Android 专案
先将 AAR 放到 plugin:
plugins/tauri-plugin-fams-rfid/android/libs/
└── HCUHF_v1.0.7_20241126.aar
然后在 plugin 的 android/build.gradle.kts 宣告 compileOnly。这一步只让 RFIDPlugin.kt 能 import 厂商的 class;不负责把 SDK 带入 APK。
dependencies {
compileOnly(files("libs/HCUHF_v1.0.7_20241126.aar"))
implementation(project(":tauri-android"))
}
最终 App module 还要有同一份 AAR。这次做法是复制到 src-tauri/gen/android/app/libs/,并在 src-tauri/gen/android/app/build.gradle.kts 加入 implementation:
dependencies {
implementation(files("libs/HCUHF_v1.0.7_20241126.aar"))
}
native library 则放在 Android 默认的 jniLibs source set:src-tauri/gen/android/app/src/main/jniLibs/<ABI>/。Gradle 会依 APK 的 ABI 将该文件夹内的 .so 打包进去。本次实际使用的部分如下;libapp_lib.so 是 Tauri 编译出的 Rust library,不是厂商 SDK,保留在同一个 ABI 文件夹即可。
src-tauri/gen/android/app/src/main/jniLibs
├── arm64-v8a
│ ├── libModuleAPI.so
│ ├── libModuleAPIJni.so
│ ├── libSerialPortHc.so
│ ├── libapp_lib.so
│ └── libpower.so
├── armeabi-v7a
│ ├── libModuleAPI.so
│ ├── libSerialPortHc.so
│ ├── libapp_lib.so
│ └── libpower.so
├── x86
│ └── libapp_lib.so
└── x86_64
└── libapp_lib.so
arm64-v8a 和 armeabi-v7a 分别对应 64 位和 32 位 ARM CPU。每个目录只放该架构可加载的 .so;本次 SDK 的 libModuleAPIJni.so 只有 arm64-v8a 版本,因此只出现在该目录。
将 SDK 包装成 Kotlin command
plugin 类位于 android/src/main/java/RFIDPlugin.kt。SDK 的入口是 UHFReader.getInstance(),它负责建立连接、开始 / 停止盘点和读取单个标签;每个操作都返回 UHFReaderResult<T>,其中包含 result code、message 和实际 data。盘点时会用到的其余两个类是:
| SDK 类别 | 在 plugin 中的用途 |
|---|---|
SelectEntity |
指定读取或盘点的筛选条件:memory bank、address、length 和内容。 |
UHFTagEntity |
表示一个读到的标签,包含 EPC、TID、RSSI、天线与读取次数。 |
Tauri plugin 是一个继承 Plugin 的 Kotlin class。@TauriPlugin 将 class 标记为 plugin,@Command 则将 class 内的方法暴露给 JS 侧调用;例如 invoke("plugin:fams-rfid|connect") 会执行 RFIDPlugin.connect。本例将需要使用的 UHFReader 操作封装成以下 command:
| SDK 操作 | Kotlin plugin command | 用途 |
|---|---|---|
connect(activity) |
connect |
初始化扫描枪并建立连接。 |
disConnect() |
disconnect |
关闭扫描枪连接。 |
startInventory() |
startInventory |
开始持续盘点;可附带 SelectEntity 筛选条件。 |
stopInventory() |
stopInventory |
结束持续盘点。 |
singleTagInventory() |
singleTagInventory |
读取一个标签;可附带 SelectEntity 筛选条件。 |
以 connect 为例,Kotlin 获取 SDK 单例、传入 Android Activity,再把厂商结果转为 JSON:
@TauriPlugin
class RFIDPlugin(private val activity: Activity) : Plugin(activity) {
@Command
fun connect(invoke: Invoke) {
val result = UHFReader.getInstance().connect(activity)
invoke.resolve(JSObject().apply {
put("code", result.getResultCode())
put("message", result.getMessage())
put("data", result.getData() ?: false)
})
}
}
startInventory 和 singleTagInventory 可接收由 Tauri @InvokeArg 解析的 SelectEntityArgs,再转成 SDK 的 SelectEntity:
val selectEntity = SelectEntity().apply {
setAddress(args.address)
setLength(args.length)
setData(args.content)
setOption(args.option)
}
例如可用 EPC 内存区作为筛选条件,只读取当前资产所绑定的标签。SDK object 不要直接返回到前端;将 UHFTagEntity 明确转成 EPC、TID、RSSI、天线编号等基本字段后,Web 层才不会依赖厂商类型。
startInventory:将 SDK callback 转成 plugin event
singleTagInventory 是一次操作,直接 resolve 一个 tag 即可。startInventory 则持续返回多批标签,若等待 command 结束才返回,前端既看不到实时结果,也无法调用停止操作。
因此 startInventory 先以 setOnInventoryDataListener 注册 SDK callback,再调用 SDK 的 startInventory。command 只返回「是否成功开始盘点」;每批标签到达时,callback 再把它们转为 plugin event。Tauri 的 插件事件文档 提供的 trigger 正是从原生 plugin 向 JS 发送这类异步数据的接口:
@Command
fun startInventory(invoke: Invoke) {
UHFReader.getInstance().setOnInventoryDataListener(::handleInventoryData)
val result = UHFReader.getInstance().startInventory()
invoke.resolve(JSObject().apply {
put("code", result.getResultCode())
put("message", result.getMessage())
put("data", result.getData())
})
}
handleInventoryData 收到的是 SDK 提供的 List<UHFTagEntity>。它逐一转为 JSObject,再以事件名称 onInventoryData 调用 trigger:
private fun handleInventoryData(tags: List<UHFTagEntity>) {
val event = JSObject()
val data = JSArray()
tags.forEach { data.put(tagToJSObject(it)) }
event.put("data", data)
trigger("onInventoryData", event)
}
JS 侧以同一个 plugin 名称 fams-rfid 和事件名称 onInventoryData 订阅。TypeScript API 放在 guest-js/index.ts,将 command name 和 event name 包装起来:
export async function startInventory(selectEntity?: SelectEntity) {
return invoke<UHFResult<boolean>>("plugin:fams-rfid|startInventory", selectEntity);
}
export async function onInventoryData(handler: (data: InventoryData) => void) {
return addPluginListener("fams-rfid", "onInventoryData", handler);
}
页面卸载时要停止盘点并移除 listener;App 结束或不再使用扫描枪时再 disconnect()。UHFReader 是 SDK 单例,若重复建立 listener 或未停止盘点,很容易留下旧 callback,造成重复数据或扫描枪被占用。
将 plugin 加入 App
在 App 的 src-tauri/src/lib.rs 初始化 plugin:
.plugin(tauri_plugin_fams_rfid::init())
plugin 的 src/mobile.rs 再将 Kotlin package 和 class name 登记给 Tauri:
api.register_android_plugin("com.plugin.fams.rfid", "RFIDPlugin")?
在 JS 侧使用 plugin
React 只需 import tauri-plugin-fams-rfid-api。App 的 root component 加载时连接扫描枪:
import { useEffect } from "react";
import { connect } from "tauri-plugin-fams-rfid-api";
function RootComponent() {
useEffect(() => void connect(), []);
// ...
}
资产定位页使用 singleTagInventory 读取指定 EPC。SDK 返回的 RSSI 会保留最近三次并取平均,再转为界面的接近度进度环:
import { SelectOption, singleTagInventory } from "tauri-plugin-fams-rfid-api";
const { code, data } = await singleTagInventory({
address: 32,
length: 16,
content: asset.epc,
option: SelectOption.EPC,
});
const tag = code === 0 ? data : null;
setRssiHistory((prev) => [...prev, tag?.rssi ?? RSSI_MIN].slice(-3));
页面每 200 ms 执行一次这个读取操作,因此使用 scanning state 防止前一次尚未完成时重复调用。前端获取的是 UHFTagEntity 的 TypeScript type,不需要处理 Android Activity、AAR 或 .so。
若改为批量盘点,先订阅 event,再启动和停止盘点;页面卸载时也要取消 listener:
import { onInventoryData, startInventory, stopInventory } from "tauri-plugin-fams-rfid-api";
const listener = await onInventoryData(({ data: tags }) => {
setTags(tags);
});
await startInventory();
// 在页面卸载或停止按钮中执行
await stopInventory();
await listener.unregister();