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();