所有文章

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