所有文章

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,提供 UHFReaderUHFTagEntitySelectEntity 等类别。
  • JNI library:libModuleAPI.solibModuleAPIJni.solibSerialPortHc.solibpower.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-v8aarmeabi-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)
        })
    }
}

startInventorysingleTagInventory 可接收由 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();