一篇偏教程向的
uniffi-bindgen-react-native入门。适合已经有 Rust core、正在做 React Native / Expo 移动端,并且不想手写 Swift / Kotlin / C++ 胶水的人。
这是 SwarmNote 跨端架构系列的第三篇。上一篇讲了 tauri-specta 如何让 Tauri 的 invoke 有类型;这一篇接着讲移动端:React Native 怎么调用同一份 Rust core。
先说故事
桌面端的问题解决了:Tauri 的 invoke("xxx") 不再靠手写类型硬撑,tauri-specta 帮我们生成了 commands.xxx() 和 events.xxx.listen()。
然后移动端来了。
SwarmNote Mobile 选择了 React Native / Expo。原因在第一篇讲过:Tauri mobile 能跑,但移动端文件系统、权限、手势、键盘、安全区这些东西,RN/Expo 生态更顺手。
可是换成 RN 以后,新的问题出现了:
Rust core 已经写好了,React Native 怎么调用它?
你当然可以手写 Swift、Kotlin、C ABI、C++ Turbo Module,再手写 TypeScript 类型。能做,但很快就会变成另一种"跨端重复劳动"。
我们真正想要的是:
rust
#[uniffi::export]
pub async fn open_workspace(path: String) -> Result<WorkspaceInfo, FfiError> {
// Rust 做真正的事
}
RN 侧:
ts
const workspace = await appCore.openWorkspace(path);
这就是 uniffi-bindgen-react-native 要解决的问题。
它是什么
一句话:
uniffi-bindgen-react-native基于 Mozilla UniFFI,把 Rust API 自动生成成 React Native 可以调用的 TypeScript + C++ JSI / Turbo Module 绑定。
运行时链路是:
和桌面端的对应关系:
| 桌面 Tauri | 移动 React Native |
|---|---|
#[tauri::command] |
#[uniffi::export] |
tauri-specta 生成 bindings.ts |
uniffi-bindgen-react-native 生成 TS + C++ |
| WebView IPC | Hermes JSI / Turbo Module |
tauri_specta::Event |
callback interface / foreign trait |
Tauri 注入 AppHandle / State |
Rust Object 自己持有状态 |
这篇不讲所有底层原理,只讲怎么搭起来、怎么写 API、怎么在 RN 里用。
你需要准备什么
基础工具:
bash
# Rust 工具链
rustup --version
cargo --version
# C++ 构建工具
cmake --version
ninja --version
Android:
bash
rustup target add aarch64-linux-android armv7-linux-androideabi i686-linux-android x86_64-linux-android
cargo install cargo-ndk
iOS:
bash
xcode-select --install
rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
如果你用 Expo,要记住一句话:
有原生 Rust Turbo Module 后,不能再用 Expo Go,需要 development build。
从零搭一个绑定包
通常做法是先创建一个 React Native library,再让 uniffi-bindgen-react-native 接管其中的 C++/TS 绑定。
1. 用 builder-bob 生成脚手架
bash
npx create-react-native-library@latest my-rust-lib
交互选项可以这样选:
text
Library type: Turbo module
Languages: C++ for Android & iOS
Example app: Vanilla
进入项目:
bash
cd my-rust-lib
yarn
为什么要选 C++?因为 uniffi-bindgen-react-native 会生成 C++ JSI 绑定,Turbo Module 壳需要这层。
2. 安装 uniffi-bindgen-react-native
bash
yarn add uniffi-bindgen-react-native
或者在 pnpm 项目里:
bash
pnpm add uniffi-bindgen-react-native
然后在 package.json 里加脚本:
json
{
"scripts": {
"ubrn:ios": "ubrn build ios --and-generate && (cd example/ios && pod install)",
"ubrn:android": "ubrn build android --and-generate",
"ubrn:web": "ubrn build web",
"ubrn:checkout": "ubrn checkout",
"ubrn:clean": "rm -rfv cpp/ android/CMakeLists.txt android/src/main/java android/*.cpp ios/ src/Native* src/index.*ts* src/generated/"
}
}
ubrn 是 uniffi-bindgen-react-native 的 CLI。
3. 清掉 builder-bob 原模板
builder-bob 会生成一套默认 C++ 示例。接入 Rust 以后,这些通常不需要:
bash
yarn ubrn:clean
Windows / pnpm 项目可以按自己习惯改成 del-cli 或 node 脚本。SwarmNote Mobile 里就额外有一个 ubrn:fix 脚本,用来修正生成后的工程细节。
配置 ubrn.config.yaml
项目根目录创建:
yaml
---
rust:
directory: ./rust
manifestPath: Cargo.toml
bindings:
cpp: cpp/generated
ts: src/generated
noOverwrite:
- "*.podspec"
- package.json
- android/build.gradle
如果 Rust 代码来自远程仓库,可以写:
yaml
rust:
repo: https://github.com/user/my-rust-crate.git
branch: main
manifestPath: crates/api/Cargo.toml
SwarmNote Mobile 是 monorepo / 本地目录模式:
yaml
rust:
directory: ./rust/mobile-core
manifestPath: Cargo.toml
bindings:
cpp: cpp/generated
ts: src/generated
noOverwrite:
- android/build.gradle
- package.json
- SwarmnoteCore.podspec
manifestPath 指向的 Rust crate 要能编译成 native library。通常在 Cargo.toml 里要有类似:
toml
[lib]
crate-type = ["staticlib", "cdylib", "rlib"]
具体保留哪些 crate type,取决于你的 iOS / Android / workspace 配置。经验上,移动端绑定 crate 最好独立出来,不要直接把 uniffi 宏撒进共享 core。
构建和生成
常用命令:
bash
# iOS:编译 Rust + 生成绑定 + pod install
yarn ubrn:ios
# Android:编译 Rust + 生成绑定
yarn ubrn:android
# 只拉取远程 Rust 代码
yarn ubrn:checkout
开发时可以只编某个架构,加快速度:
bash
ubrn build ios --sim-only --and-generate
ubrn build android --targets aarch64-linux-android --and-generate
生成后你会看到:
text
my-rust-lib/
├── cpp/generated/ # C++ JSI 绑定
├── src/generated/ # TypeScript 绑定
├── android/src/main/jniLibs/ # Android .so
└── ios/build/*.xcframework # iOS framework
SwarmNote Mobile 的绑定包叫 react-native-swarmnote-core,核心脚本是:
json
{
"scripts": {
"ubrn:android": "ubrn build android --and-generate && pnpm ubrn:fix",
"ubrn:android:release": "ubrn build android --and-generate --release -t arm64-v8a && pnpm ubrn:fix",
"ubrn:ios": "ubrn build ios --and-generate && pnpm ubrn:fix",
"ubrn:ios:release": "ubrn build ios --and-generate --release && pnpm ubrn:fix"
}
}
写 Rust API
Rust crate 入口先放:
rust
uniffi::setup_scaffolding!();
SwarmNote 的 mobile-core/src/lib.rs 大概是:
rust
uniffi::setup_scaffolding!();
mod app;
mod error;
mod events;
mod keychain;
mod types;
mod workspace;
下面看常用 API 形态。
1. 导出函数
最简单:
rust
#[uniffi::export]
pub fn greet(name: String) -> String {
format!("Hello, {name}")
}
TS 侧:
ts
const message = greet("SwarmNote");
2. Record:传值类型
没有方法、只是数据,用 Record:
rust
#[derive(Debug, Clone, uniffi::Record)]
pub struct DeviceInfo {
pub peer_id: String,
pub device_name: String,
pub hostname: String,
}
生成到 TS 后字段会转成 camelCase:
ts
type DeviceInfo = {
peerId: string;
deviceName: string;
hostname: string;
};
SwarmNote 里会专门做 FFI 侧 DTO,比如:
rust
#[derive(Debug, Clone, uniffi::Record)]
pub struct UniffiDeviceInfo {
pub peer_id: String,
pub device_name: String,
pub hostname: String,
pub os: String,
pub platform: String,
pub arch: String,
pub created_at: String,
}
不要把数据库表、内部状态机字段一股脑暴露给 RN。FFI DTO 应该是 host 需要的 API,而不是 core 内部结构的截图。
3. Object:让 RN 持有 Rust 句柄
真实 app 里更常见的是对象:
rust
use std::sync::Arc;
#[derive(uniffi::Object)]
pub struct AppCoreHandle {
inner: Arc<AppCore>,
}
#[uniffi::export(async_runtime = "tokio")]
impl AppCoreHandle {
#[uniffi::constructor]
pub async fn new(app_data_dir: String) -> Result<Arc<Self>, FfiError> {
let inner = AppCore::new(app_data_dir).await?;
Ok(Arc::new(Self { inner }))
}
pub async fn start_network(&self) -> Result<(), FfiError> {
self.inner.start_network().await?;
Ok(())
}
}
SwarmNote 真实代码里的设备级对象叫 UniffiAppCore:
rust
#[derive(uniffi::Object)]
pub struct UniffiAppCore {
pub(crate) inner: Arc<AppCore>,
pub(crate) event_bus: Arc<UniffiEventBusAdapter>,
}
它负责持有设备身份、配置、P2P 节点、devices DB 等长期状态。RN 启动时创建一次,放进 context/store 里。
生成后的异步 constructor 在 TS 侧通常是 create:
ts
const appCore = await UniffiAppCore.create(keychain, eventBus, appDataDir);
await appCore.startNetwork();
4. async:Rust Future 变 Promise
Rust:
rust
#[uniffi::export(async_runtime = "tokio")]
impl WorkspaceHandle {
pub async fn read_text(&self, rel_path: String) -> Result<String, FfiError> {
self.inner.read_text(&rel_path).await.map_err(Into::into)
}
}
TS:
ts
const text = await workspace.readText("daily.md");
如果你的 core 用 Tokio,#[uniffi::export(async_runtime = "tokio")] 很重要。SwarmNote 的 SeaORM、文件 I/O、P2P 任务都在 tokio runtime 里,所以导出 impl 基本都这样标。
uniffi-bindgen-react-native 生成的 async 方法还支持 AbortSignal:
ts
const controller = new AbortController();
setTimeout(() => controller.abort(), 10_000);
await workspace.readText("daily.md", { signal: controller.signal });
5. Enum:状态和事件
简单状态:
rust
#[derive(Debug, Clone, uniffi::Enum)]
pub enum NodeStatus {
Stopped,
Running,
Error { message: String },
}
带数据的 enum 会生成 tagged union 风格。SwarmNote 事件就是这种:
rust
#[derive(Debug, Clone, uniffi::Enum)]
pub enum UniffiAppEvent {
DocFlushed { doc_id: String },
ExternalUpdate { doc_id: String, update: Vec<u8> },
FileTreeChanged { workspace_id: String },
NodeStarted,
NodeStopped,
SyncProgress {
workspace_id: String,
peer_id: String,
completed: u32,
total: u32,
},
}
一个实战坑:不要随意在 enum 中间插入新 variant。旧 TS 绑定可能按旧 tag 解码,导致事件字段错位。新增事件尽量放末尾。
RN 侧怎么用
生成文件通常在:
text
src/generated/
你可以在包的 src/index.ts 里统一 re-export:
ts
export * from "./generated/mobile_core";
然后 app 里:
ts
import {
UniffiAppCore,
type ForeignEventBus,
type UniffiAppEvent,
} from "react-native-swarmnote-core";
class EventBus implements ForeignEventBus {
emit(event: UniffiAppEvent): void {
// 分发到 Zustand / Redux / event emitter
}
}
const appCore = await UniffiAppCore.create(keychain, new EventBus(), appDataDir);
const info = appCore.deviceInfo();
const workspace = await appCore.openWorkspace(path);
注意:命名一般会从 Rust snake_case 变成 TS camelCase,例如:
rust
pub async fn open_workspace(&self, path: String) -> Result<Arc<UniffiWorkspaceCore>, FfiError>
TS:
ts
await appCore.openWorkspace(path);
事件:Rust 怎么通知 JS
Tauri 桌面端可以 app.emit()。RN 里没有 Tauri AppHandle,所以用 callback interface / foreign trait。
完整链路是这样的:
1. Rust 先定义一个 JS 要实现的 trait
SwarmNote 的事件桥在 mobile-core/src/events.rs:
rust
#[uniffi::export(with_foreign)]
pub trait ForeignEventBus: Send + Sync {
fn emit(&self, event: UniffiAppEvent);
}
with_foreign 的意思是:这个 trait 由外部语言实现。生成到 TypeScript 后,会变成一个接口:
ts
export interface ForeignEventBus {
emit(event: UniffiAppEvent): void;
}
事件 payload 是一个 UniFFI enum:
rust
#[derive(Debug, Clone, uniffi::Enum)]
pub enum UniffiAppEvent {
DevicesChanged { devices: Vec<UniffiDevice> },
NetworkStatusChanged { nat_status: String, public_addr: Option<String> },
PairingRequestReceived {
pending_id: u64,
peer_id: String,
os_info: UniffiOsInfo,
method: UniffiPairingMethod,
expires_at: SystemTime,
},
ExternalUpdate { doc_id: String, update: Vec<u8> },
NodeStarted,
NodeStopped,
}
2. constructor 接收这个 callback
UniffiAppCore::new 把 JS 传进来的 event_bus 接住:
rust
#[uniffi::export(async_runtime = "tokio")]
impl UniffiAppCore {
#[uniffi::constructor]
pub async fn new(
keychain: Arc<dyn ForeignKeychainProvider>,
event_bus: Arc<dyn ForeignEventBus>,
app_data_dir: String,
) -> Result<Arc<Self>, FfiError> {
let event_bus = Arc::new(UniffiEventBusAdapter::new(event_bus));
let inner = AppCoreBuilder::new(
Arc::new(UniffiKeychainAdapter::new(keychain)),
event_bus.clone(),
app_data_dir,
)
.build()
.await?;
Ok(Arc::new(Self { inner, event_bus }))
}
}
这里的关键是:RN 侧传进来的对象不是临时调用一次就结束,而是被 Rust adapter 包起来,交给 AppCoreBuilder。之后 core 里任何地方触发 EventBus::emit(...),都能一路回到 JS。
3. Rust adapter 把 core event 映射成 FFI event
共享 core 不认识 UniFFI,它只认识自己的 EventBus trait。移动端 wrapper 负责做投影:
rust
pub(crate) struct UniffiEventBusAdapter {
foreign: Arc<dyn ForeignEventBus>,
}
impl EventBus for UniffiEventBusAdapter {
fn emit(&self, event: AppEvent) {
self.foreign.emit(map_event(event));
}
}
真实代码里还有一个 map_event(event),把 core 的 AppEvent 转成 UniffiAppEvent。比如 UUID 转成 string,chrono::DateTime 转成 SystemTime,内部设备类型转成 FFI DTO。
4. RN 侧实现 callback class
生成绑定后,RN 侧实现 ForeignEventBus:
ts
import {
type ForeignEventBus,
type UniffiAppEvent,
UniffiAppEvent_Tags,
} from "react-native-swarmnote-core";
export class EventBus implements ForeignEventBus {
emit(event: UniffiAppEvent): void {
switch (event.tag) {
case UniffiAppEvent_Tags.DevicesChanged:
useSwarmStore.getState().setDevices(event.inner.devices);
break;
case UniffiAppEvent_Tags.NetworkStatusChanged:
useSwarmStore.getState().setNetworkStatus({
natStatus: event.inner.natStatus,
publicAddr: event.inner.publicAddr ?? null,
});
break;
case UniffiAppEvent_Tags.PairingRequestReceived: {
const { pendingId, peerId, osInfo, method, expiresAt } = event.inner;
useNotificationStore.getState().push({
id: `pairing-${pendingId.toString()}-${Date.now()}`,
type: "pairing-request",
payload: {
pendingId,
peerId,
deviceName: osInfo.name ?? osInfo.hostname,
os: osInfo.os,
platform: osInfo.platform,
method: method.tag,
expiresAt,
},
timestamp: Date.now(),
});
break;
}
case UniffiAppEvent_Tags.ExternalUpdate: {
const active = getActiveEditorBridge();
if (active === null) break;
const { docId, update } = event.inner;
if (active.docUuid !== docId) break;
active.applyRemoteUpdate(new Uint8Array(update));
break;
}
}
}
}
这段代码的作用就是把 Rust 事件翻译成 RN 世界里的状态更新:设备列表进 store,配对请求进通知队列,远端 Yjs update 转给 WebView editor bridge。
5. 创建 AppCore 时把 callback 传进去
最后,在 RN 初始化核心对象时,把 callback 实例作为参数传给 Rust constructor:
ts
import { Paths } from "expo-file-system";
import { UniffiAppCore, type UniffiAppCoreLike } from "react-native-swarmnote-core";
import { EventBus } from "./event-bus";
import { Keychain } from "./keychain";
let corePromise: Promise<UniffiAppCoreLike> | null = null;
let core: UniffiAppCoreLike | null = null;
export function initAppCore(): Promise<UniffiAppCoreLike> {
if (corePromise !== null) return corePromise;
corePromise = (async () => {
const instance = await UniffiAppCore.create(
new Keychain(),
new EventBus(),
Paths.document.uri,
);
core = instance;
return instance;
})();
return corePromise;
}
这就是前端"传 callback"的地方。new EventBus() 实现了生成出来的 ForeignEventBus 接口,UniFFI 会把它降到 native handle,Rust 侧收到的是 Arc<dyn ForeignEventBus>。
6. 回调链路小结
把上面的代码串起来就是:
text
RN: new EventBus()
↓ 作为 constructor 参数
UniffiAppCore::new(event_bus: Arc<dyn ForeignEventBus>)
↓ 包成 adapter
AppCoreBuilder::new(..., Arc<dyn EventBus>, ...)
↓ core 触发 AppEvent
UniffiEventBusAdapter::emit(AppEvent)
↓ map_event
ForeignEventBus.emit(UniffiAppEvent)
↓ 回到 JS
EventBus.emit(event)
↓
Zustand store / WebView editor bridge
经验规则:
- callback 里不要做重活,快速转发到 store 后返回。
- 不要持有 Rust 锁时调用 JS callback,容易死锁。
- 事件 payload 尽量用 FFI-friendly DTO,不要暴露内部复杂类型。
- JS callback 实例要和 core 一样长期存活;通常把
UniffiAppCore做成 singleton,callback 跟着它一起活。 - 如果事件里有
u64,TS 侧通常是bigint,UI store 需要Number(...)时要确认范围安全。
错误处理
Rust 侧:
rust
#[derive(Debug, thiserror::Error, uniffi::Error)]
pub enum FfiError {
#[error("invalid path: {0}")]
InvalidPath(String),
#[error("P2P node is not running")]
NetworkNotRunning,
#[error("invalid input ({field}): {reason}")]
InvalidInput { field: String, reason: String },
}
导出函数返回:
rust
pub async fn open_workspace(&self, path: String) -> Result<Arc<UniffiWorkspaceCore>, FfiError>
TS 侧是 rejected Promise。判断错误时,不要用普通 instanceof,用生成类型的 instanceOf():
ts
try {
await appCore.startNetwork();
} catch (e) {
if (FfiError.NetworkAlreadyRunning.instanceOf(e)) {
return;
}
throw e;
}
SwarmNote 没有直接给共享的 AppError derive uniffi::Error,而是在 mobile wrapper 里单独定义 FfiError,再 impl From<AppError> for FfiError。这样 swarmnote-core 不需要依赖 UniFFI,桌面端也不会被移动端绑定约束污染。
内存和生命周期
UniFFI Object 背后是真实 Rust 资源。JS 对象被 GC 时,Rust 端引用会被释放,但 GC 时机不可控。
轻量对象可以交给 GC;持有文件、数据库、网络、同步任务的对象,建议显式释放:
ts
try {
const workspace = await appCore.openWorkspace(path);
// ...
await workspace.close();
workspace.uniffiDestroy?.();
} finally {
// 确保切换 workspace 前旧资源释放
}
SwarmNote 的 UniffiWorkspaceCore 就有明确生命周期约定:切换工作区前先 close(),保证 dirty docs flush,再释放句柄。
也可以用 uniffiUse() 做 RAII 风格的临时对象管理:
ts
const result = manager.uniffiUse((m) => {
return m.doSomething();
});
常见类型映射
| Rust | TypeScript |
|-----------------------------|-----------------------|-------------|
| String | string |
| bool | boolean |
| u8 / u32 / i32 | number |
| u64 / i64 | bigint |
| Vec<T> | T[] |
| Vec<u8> | ArrayBuffer |
| Option<T> | `T | undefined` |
| SystemTime | Date |
| #[derive(uniffi::Record)] | plain object |
| #[derive(uniffi::Object)] | object handle / class |
| #[derive(uniffi::Enum)] | enum / tagged union |
| #[derive(uniffi::Error)] | typed thrown error |
这里和 tauri-specta 有个差异:64 位整数通常是 bigint,前端不要随手和 number 混算。
多 crate 项目怎么组织
如果你已经有一个 Rust workspace,不建议直接在核心 crate 上到处加 UniFFI 宏。更推荐这样:
text
rust/
├── core/ # 平台无关核心逻辑
├── p2p/ # 网络模块
└── mobile-core/ # UniFFI wrapper crate
mobile-core 负责:
- 包装 core 的对象和方法
- 定义 FFI-friendly DTO
- 把 core error 转成
FfiError - 把 core event 转成
UniffiAppEvent - 处理移动端 path、keychain、event bus 等 adapter
SwarmNote 就是这种思路:
text
TypeScript / RN
↓ generated bindings
mobile-core
↓ wrapper / adapter
swarmnote-core
↓
swarm-p2p-core / yrs / sea-orm
这和桌面端 Tauri 的分层是对称的:桌面 host 用 #[tauri::command] 包 core,移动 host 用 #[uniffi::export] 包 core。
常见坑
| 坑 | 处理方式 |
|---|---|
| Expo Go 跑不了 | 使用 development build |
| 改 Rust 后 TS 没变化 | 重新跑 ubrn build ... --and-generate |
u64 到 TS 变 bigint |
前端按 bigint 处理,或在 wrapper 层转成安全 u32 / String |
| 持有资源的 Object 只靠 GC | 提供 close(),RN 侧配合 uniffiDestroy() |
| callback 里做重活 | 只转发事件,异步处理放到 RN store / queue |
| core 被 UniFFI 污染 | 单独建 wrapper crate,不在共享 core 里 derive UniFFI |
| enum 中间插 variant | 新 variant 尽量追加到末尾 |
适合谁
适合:
- 已经有 Rust core
- RN 只是移动端 host
- 需要数据库、文件系统、加密、P2P、同步协议等重逻辑
- 想要生成 TypeScript 类型
- 不想维护 Swift / Kotlin 两套桥
不太适合:
- 只是普通 UI app
- 没有 Rust core
- 系统能力 Expo/RN 库已经完全覆盖
- 必须使用 Expo Go
小结
uniffi-bindgen-react-native 的主线其实是:
- 用 builder-bob 建一个 RN Turbo Module 包。
- 加
uniffi-bindgen-react-native和ubrn.config.yaml。 - 在 Rust wrapper crate 里写
uniffi::setup_scaffolding!()。 - 用
#[uniffi::export]、Record、Object、Enum、Error描述 FFI API。 - 跑
ubrn build android/ios --and-generate生成 TS + C++ + native library。 - RN 侧直接 import 生成 API,像调用普通 TS 对象一样调用 Rust。
如果上一篇的 tauri-specta 是"给 Tauri IPC 生成 TypeScript 契约",那这一篇的 uniffi-bindgen-react-native 就是"给 React Native 生成 Rust 原生模块契约"。
SwarmNote 最终采用的方式是:
text
桌面端: Tauri + tauri-specta
移动端: React Native + uniffi-bindgen-react-native
共享核心: swarmnote-core
不是一套壳跑所有端,而是每个平台用适合自己的壳,真正复杂的核心逻辑留在 Rust。
系列文章: