19.1 从"手写 FFI"到"自动绑定"
第 16 课手动搭桥教会我们 FFI 的三条协议(布局、所有权、安全)。但手写绑定有致命问题:
重复劳动:每个函数都要配 no_mangle/extern "C"、内存配对、catch_unwind
类型贫瘠:String/Vec/Result/回调全要手工映射
绑定爆炸:每支持一种语言(Swift/Kotlin/TS/Python)就要再写一遍
**UniFFI(Mozilla 出品)**把这事自动化:你给 Rust 代码贴几个属性宏,它生成两层东西:
第 1 层:scaffolding ------ C ABI 胶水(等价于 16 课手写部分,编译期/构建期自动生成)
第 2 层:bindings ------ 各语言绑定(Swift/Kotlin/Python/TS......),调用 scaffolding
Rust core(17-18 课)
│ #[uniffi::export] 等
▼
UniFFI 生成 scaffolding(C ABI,自动处理
布局/内存/错误/panic/异步)
│ 生成的各语言绑定(Swift/Kotlin/Python...)
▼
iOS App / Android App / Desktop / 测试脚本
💡 UniFFI 的取舍:桥更"厚"但更安全 ------它按 16 课的三条协议写死模板并自动成对生成 free/错误处理,人类不再手写 C 胶水。代价:能过桥的类型要符合它的"受支持类型集",个别花哨类型(复杂泛型、某些回调形态)需要绕道。本课 = 学会在 UniFFI 的类型约束内导出 17-18 课的 core。
版本声明:UniFFI 迭代快,本课按 0.2x(0.29 系)写法示例。若你安装的版本更新,以官方 docs(mozilla.github.io/uniffi-rs)的迁移说明为准------核心概念不变,属性名/宏入口偶尔微调。
19.2 工程结构:ffi-bindings crate
第 17 课建过空壳,现在填实。导出层与 core 分离,让 core 保持"无 UniFFI 依赖":
ffi-bindings/
├── Cargo.toml
├── build.rs # 生成 scaffolding(proc-macro 模式无需 UDL)
└── src/
├── lib.rs # uniffi::setup_scaffolding!()
├── models.rs # #[derive(uniffi::Record/Enum)] 的传输类型(壳层 DTO)
└── api.rs # #[derive(uniffi::Object)] 门面对象 + #[uniffi::export]
toml
# ffi-bindings/Cargo.toml
[package]
name = "my_ai_ffi"
edition = "2021"
[dependencies]
my-ai-core = { path = "../core" }
uniffi = { version = "0.29", features = ["tokio"] } # tokio:异步导出用
[build-dependencies]
uniffi = { version = "0.29", features = ["build"] }
rust
// build.rs ------ proc-macro 模式:生成 Rust scaffolding(无需 .udl 接口文件)
fn main() {
uniffi::generate_scaffolding("src/lib.rs").expect("UniFFI scaffolding 生成失败");
}
rust
// src/lib.rs ------ 导出层入口
uniffi::setup_scaffolding!(); // 必须出现且只出现一次(放在被 generate_scaffolding 指向的文件)
pub mod api;
pub mod models;
⚠️ 关键点:
setup_scaffolding!所在文件路径必须与build.rs里generate_scaffolding("src/lib.rs")一致------UniFFI 靠它定位 crate 根符号。路径不一致是最常见的"链接期找不到符号"原因。
19.3 传输类型:Record / Enum 的映射规则
UniFFI 只认它支持的"安全类型子集"。把 core 的内部类型(models::Session 等)与 UniFFI 类型解耦的惯用手法:在 ffi 层定义镜像 DTO。
19.3.1 Record(结构体)与 Enum
rust
// ffi-bindings/src/models.rs
#[derive(uniffi::Record)]
pub struct Session {
pub id: String,
pub title: String,
pub created_at_ms: i64,
}
#[derive(uniffi::Enum)]
pub enum Role {
User,
Assistant,
System,
}
#[derive(uniffi::Record)]
pub struct Message {
pub id: String,
pub session_id: String,
pub role: Role,
pub content: String,
pub created_at_ms: i64,
}
impl From<&my_ai_core::models::Session> for Session {
fn from(s: &my_ai_core::models::Session) -> Self {
Session { id: s.id.clone(), title: s.title.clone(), created_at_ms: s.created_at_ms }
}
}
impl From<&my_ai_core::models::Message> for Message {
fn from(m: &my_ai_core::models::Message) -> Self {
Message {
id: m.id.clone(),
session_id: m.session_id.clone(),
role: match m.role {
my_ai_core::models::Role::User => Role::User,
my_ai_core::models::Role::Assistant => Role::Assistant,
my_ai_core::models::Role::System => Role::System,
},
content: m.content.clone(),
created_at_ms: m.created_at_ms,
}
}
}
对应到各语言的自然形态:
| Rust(UniFFI 标记) | Swift | Kotlin | Python |
|---|---|---|---|
#[derive(uniffi::Record)] struct |
struct |
data class |
@dataclass |
#[derive(uniffi::Enum)] enum |
enum |
sealed class/enum |
Enum |
#[derive(uniffi::Object)] + impl |
class(引用语义) |
class |
对象 |
String / i64 / Vec<T> |
String/Int64/数组 |
对应基础类型 | 对应类型 |
💡 支持类型清单(最常用子集):
String、整数族(i8..u64)、f32/f64、bool、Vec<T>、Option<T>、HashMap<String, T>、上述 Record/Enum、Object、Result<T, E>(E 是导出的 Error)。不支持 :&str参数、泛型自定义类型、usize(建议统一 i64)。
19.3.2 Error:让调用方能 match
core 的错误枚举(17 课)不能直接导出(内含 #[from] 包装的 io/rusqlite 等不可表示类型),惯例做法也是在 ffi 层定义"UI 可理解"的错误:
rust
// ffi-bindings/src/models.rs
#[derive(uniffi::Error)]
pub enum ApiError {
/// 存储错误:含 DB 语义(UI 可提示"历史加载失败")
Store { message: String },
/// LLM 错误:细分为"上游/网络/超时/中断",UI 可针对性提示或重试
Llm { kind: LlmErrorKind, message: String },
/// 参数非法(空标题、空会话 id 等),UI 拦截即可
InvalidArgument { message: String },
/// 其他(通用兜底)
Other { message: String },
}
#[derive(uniffi::Enum)]
pub enum LlmErrorKind {
Http,
Upstream,
Timeout,
StreamInterrupted,
Config,
}
rust
impl From<my_ai_core::service::ServiceError> for ApiError {
fn from(e: my_ai_core::service::ServiceError) -> Self {
use my_ai_core::llm::chat::LlmError;
use my_ai_core::service::ServiceError as Inner;
match e {
Inner::Store(_) => ApiError::Store { message: format!("{e}") },
Inner::SessionNotFound => ApiError::InvalidArgument { message: "会话不存在".into() },
Inner::Llm(l) => ApiError::Llm {
kind: match l {
LlmError::Http(_) => LlmErrorKind::Http,
LlmError::Upstream(_) => LlmErrorKind::Upstream,
LlmError::Timeout => LlmErrorKind::Timeout,
LlmError::StreamInterrupted => LlmErrorKind::StreamInterrupted,
LlmError::Config(_) => LlmErrorKind::Config,
},
message: format!("{l}"),
},
}
}
}
💡 这条"错误翻译链"就是 7 课"库用精确错误、边界转可展示错误"的落地。UI 拿到
ApiError就能按kind决定:网络错→重试按钮,超时→提示,中断→展示已收到部分。
19.4 导出门面:Object + async
19.4.1 同步能力的导出
rust
// ffi-bindings/src/api.rs
use crate::models::{ApiError, Session};
use my_ai_core::service::AssistantService;
/// 门面对象:每个 UI 层一个实例(持有 store 与 llm)
#[derive(uniffi::Object)]
pub struct AiAssistant {
inner: AssistantService, // 不直接导出 core 类型,包在 Object 里
}
#[uniffi::export]
impl AiAssistant {
#[uniffi::constructor]
pub fn new(db_path: String, system_prompt: String) -> Result<Arc<AiAssistant>, ApiError> {
// 用 18 课的真实 sqlite store
let store = my_ai_core::store::SqliteStore::open(&db_path)
.map_err(|e| ApiError::Store { message: format!("{e}") })?;
// LLM 客户端在下一节换成真实实现;此处给"配置缺失"占位
let llm = crate::llm_client::build_client()?;
let service = AssistantService::new(Box::new(store), llm, system_prompt);
Ok(Arc::new(AiAssistant { inner: service }))
}
pub fn list_sessions(&self, limit: u32) -> Result<Vec<Session>, ApiError> {
let sessions = self.inner.list_sessions(limit).map_err(ApiError::from)?;
Ok(sessions.iter().map(Session::from).collect())
}
pub fn delete_session(&self, session_id: String) -> Result<(), ApiError> {
self.inner.delete_session(&session_id).map_err(ApiError::from)
}
}
19.4.2 async 导出
rust
use std::sync::Arc;
#[uniffi::export(async_runtime = "tokio")] // 关键:指定驱动 core 内部 async 的运行时
impl AiAssistant {
pub async fn new_session(&self, title: String) -> Result<Session, ApiError> {
let s = self.inner.new_session(&title).await.map_err(ApiError::from)?;
Ok(Session::from(&s))
}
/// 一次问答:返回完整回答文本
pub async fn ask(&self, session_id: String, question: String) -> Result<String, ApiError> {
let store = ...; // 演示占位:完整实现见 code/19-uniffi/
let answer = self.inner.ask(&session_id, &question).await.map_err(ApiError::from)?;
Ok(answer)
}
}
⚠️ async 导出的三条现实约束:
- 绑定层对"流式回调"支持有限 ------历史版本的 UniFFI 不允许把"回调 + async"组合得舒服。实战方案(也是各厂 App 的常见妥协):Rust 侧流式消费并逐段追加到会话的 chunk 队列,UI 侧轮询/定时拉取(19.6 给可运行方案);纯实时推送交给壳层自建 socket 或 UniFFI 回调版本(随版本演进,本课程用"轮询队列"保证跨版本稳定)。
- 导出的 async 函数会在各自绑定的线程池上跑,Rust 侧若用
#[tokio::main]之外的运行时需Arc<Runtime>托底------遵循"一个 core 一套 tokio 运行时"。#[derive(uniffi::Object)]的方法签名里,返回引用/借用类型不可导出 ------全走"值传递"(Session而非&Session),所以 19.3 的 DTO 是值类型。
19.5 流式问答的跨端妥协:增量队列 + 轮询
先讲清取舍,再给"能跑"的代码。UniFFI 目前对"逐字推送回调"支持不完整,因此实战上常用两步走:
Rust 侧:ask_stream 执行期间把每个 delta 追加进 "chunk 队列"
(对象内 Mutex<Vec<String>>,App 线程可随时读走)
UI 侧:每隔 ~50-100ms 调 drain_pending() 取走新增量 → 刷新气泡
结束后调 is_busy() == false / 取完整文本收尾
rust
use std::sync::Mutex;
#[derive(uniffi::Object)]
pub struct LlmSession {
chunks: Mutex<Vec<String>>, // 流式增量暂存
done: Mutex<bool>,
}
#[uniffi::export(async_runtime = "tokio")]
impl LlmSession {
#[uniffi::constructor]
pub fn new(session_id: String) -> Arc<LlmSession> {
Arc::new(LlmSession { chunks: Mutex::new(Vec::new()), done: Mutex::new(false) })
}
/// 启动一次流式问答(不阻塞 UI;由 Rust 侧把增量写进队列)
pub async fn ask_stream(&self, question: String) -> Result<(), ApiError> {
// ------ 伪骨架,完整实现见 code/19 ------
// let mut rx = self.inner.ask_stream(&self.session_id, &question).await?;
for piece in ["你", "好", "世", "界"] {
self.chunks.lock().unwrap().push(piece.to_string()); // 模拟增量
tokio::time::sleep(std::time::Duration::from_millis(30)).await;
}
*self.done.lock().unwrap() = true;
Ok(())
}
/// UI 轮询:取走新到的增量(并清空队列)
pub fn drain_pending(&self) -> Vec<String> {
std::mem::take(&mut *self.chunks.lock().unwrap())
}
pub fn is_done(&self) -> bool {
*self.done.lock().unwrap()
}
}
💡 这个"队列 + 轮询"在移动端是务实且被证明可行的模式(很多语音助手的"打字中"就是 ~80ms 轮询)。真正的"事件推送"若要做得丝滑,可让壳层自建一层:壳持有 native 回调 → 第 20 课把"增量转成 UI 事件"放壳侧实现。
19.6 生成绑定:Python 立刻验证 / Swift / Kotlin
bash
# 1) 先编译并生成 scaffolding
cargo build -p my_ai_ffi
# 2) 用 uniffi-bindgen 生成目标语言绑定
cargo install uniffi_bindgen --version 0.29.0 # 与 uniffi 版本严格一致!
uniffi-bindgen generate src/lib.rs --library target/debug/libmy_ai_ffi.dylib \
--language python --out-dir bindings/python
uniffi-bindgen generate src/lib.rs --library ... --language swift --out-dir bindings/swift
uniffi-bindgen generate src/lib.rs --library ... --language kotlin --out-dir bindings/kotlin
⚠️ bindgen 与 uniffi crate 版本必须一致 ,否则二进制接口对不上------这是 UniFFI 最常见的报错
...does not match the version...。建议固定uniffi = "0.29"且cargo install uniffi_bindgen --version 0.29.0。
Python 侧冒烟(教学最快验证路径,等价 16 课的 ctypes 但要爽得多):
python
# bindings/python/demo.py
import asyncio
import sys
sys.path.append(".") # 让 my_ai_ffi 模块可见
import my_ai_ffi
async def main():
# 构造器对应 #[uniffi::constructor] new
assistant = my_ai_ffi.AiAssistant(":memory:", "你是课程助教")
session = await assistant.new_session("Python 冒烟")
print("会话 id:", session.id, "标题:", session.title)
answer = await assistant.ask(session.id, "Rust 的 async 是什么?")
print("回答:", answer)
sessions = assistant.list_sessions(10)
print("历史条数:", len(sessions))
asyncio.run(main())
💡 注意 async 方法在 Python 侧是
await(UniFFI 用 Python 原生async/await包装);同步方法直接调用。Swift/Kotlin 侧同理(async → Swift concurrency / Kotlin coroutines)。20 课会把它们接进 UI。
19.7 测试与陷阱清单
bash
# 每轮改动的回归链
cargo test -p my-ai-core # 核心逻辑(无 UniFFI 依赖)
cargo build -p my_ai_ffi # 导出层
python3 bindings/python/demo.py # 跨语言冒烟
| 现象 | 原因 | 解决 |
|---|---|---|
undefined symbol: _uniffi_* |
setup_scaffolding 路径与 build.rs 不一致 | 统一 generate_scaffolding("src/lib.rs") |
| bindgen 版本不匹配报错 | uniffi 与 uniffi_bindgen 版本漂移 | 用 cargo install uniffi_bindgen --version X |
usize/&str 类型报不支持 |
不在 UniFFI 类型集 | 换 i64 / String |
| 导出 async 无 tokio feature | 忘开 features=["tokio"] |
补 uniffi 依赖 feature |
| Object 方法想返回借用 | Object 只走值/句柄语义 | 返回 Vec<DTO>/Arc<Object> |
19.8 📝 动手练习
参考实现放 code/19-uniffi/(写作时同步给出;若网络无法安装 bindgen,用 16 课 ctypes 通道验证等价行为)。
- 最小导出 :把 17 课的
Session/Message/Role镜像成 Record/Enum,只导出list_sessions/new_session同步或 async 各一,Python 冒烟通过。 - 错误翻译 :把 18 课"删不存在会话"路径触发 core
StoreError,经ApiError::from映射到 UI 侧,Python 侧except my_ai_ffi.ApiError断言 kind/message。 - 队列流式 :实现
LlmSession(19.5),Python 侧asyncio里 80ms 轮询drain_pending拼出完整回答,断言与预期一致。 - 多线程安全 :Python 里同时开 5 个 coroutine 各发一次
ask,验证无 panic、数据不串(体会 18 课 Mutex 的串行化设计)。 - 重开持久化 :用临时 db 文件而非
:memory:,完成一轮问答后重新AiAssistant::new,确认历史还在(与 18.6 遥相呼应)。
验收门禁:能说出 UniFFI 生成的两层产物分别是什么;能默写 Record/Enum/Object/constructor/async 的标记;能解释"为什么在 ffi 层再造 DTO/错误,而不是直接导出 core 类型"。
✅ 本节小结
- 动机:UniFFI = 16 课手动 FFI 三协议的模板化与自动化;一层 scaffolding(C ABI)+ 一层各语言绑定;
- 结构:core(纯逻辑,零 UniFFI 依赖)+ ffi-bindings(导出门面);
- 类型:Record(struct)/ Enum / Object 三类标记;类型集受限 → 造镜像 DTO + 映射 From;
- 错误 :core 精确错误 → ffi 层翻译成
ApiError(UI 可按 kind 决策); - async :
#[uniffi::export(async_runtime = "tokio")];流式回调未成熟 → 增量队列 + UI 轮询的务实模式; - 绑定 :
uniffi-bindgen generate,版本必须与 crate 一致;Python 是最快验证通道; - 纪律:核心逻辑始终在 core 测(无 UniFFI 也能全绿),ffi 层只做"翻译 + 导出"。
下一课预告:第 20 课《壳侧 API 封装》------进入 App 的世界:Swift 的 async/await 封装与增量回调、Kotlin 协程桥接、TypeScript/桌面侧封装;错误码 → 用户提示的策略、离线与断流路径、以及"让原生 UI 消费 Rust 能力"的边界设计。第 21 课就做双端集成与出包(Android AAR / iOS xcframework),第 22 课一键多平台与工程收尾。