【AI开发之Rust】第 19 课:UniFFI 导出核心能力

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 导出的三条现实约束:

  1. 绑定层对"流式回调"支持有限 ------历史版本的 UniFFI 不允许把"回调 + async"组合得舒服。实战方案(也是各厂 App 的常见妥协):Rust 侧流式消费并逐段追加到会话的 chunk 队列,UI 侧轮询/定时拉取(19.6 给可运行方案);纯实时推送交给壳层自建 socket 或 UniFFI 回调版本(随版本演进,本课程用"轮询队列"保证跨版本稳定)。
  2. 导出的 async 函数会在各自绑定的线程池上跑,Rust 侧若用 #[tokio::main] 之外的运行时需 Arc<Runtime> 托底------遵循"一个 core 一套 tokio 运行时"。
  3. #[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 通道验证等价行为)。

  1. 最小导出 :把 17 课的 Session/Message/Role 镜像成 Record/Enum,只导出 list_sessions/new_session 同步或 async 各一,Python 冒烟通过。
  2. 错误翻译 :把 18 课"删不存在会话"路径触发 core StoreError,经 ApiError::from 映射到 UI 侧,Python 侧 except my_ai_ffi.ApiError 断言 kind/message。
  3. 队列流式 :实现 LlmSession(19.5),Python 侧 asyncio 里 80ms 轮询 drain_pending 拼出完整回答,断言与预期一致。
  4. 多线程安全 :Python 里同时开 5 个 coroutine 各发一次 ask,验证无 panic、数据不串(体会 18 课 Mutex 的串行化设计)。
  5. 重开持久化 :用临时 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 课一键多平台与工程收尾。

相关推荐
2601_966949651 小时前
股票池发生变化后,如何高效更新行情数据?从全量刷新到增量同步
开发语言·python·数据分析·pandas·量化交易·股票数据·quantdash
ServBay1 小时前
基于Jev的浏览器Agent插件狂揽 21k star,3分钟教你解放双手
后端·aigc·ai编程
Json____1 小时前
家居装修 AI 智能咨询助手:让装修咨询从“大海捞针“变成“一问即答“
java·后端·vue3·it学习·wwwoop.com
繁华的地方不一定留下你的脚印1 小时前
C++ std::variant 与 std::visit:安全保存多种类型,写清每个处理分支
开发语言·c++
小羊没烦恼!2 小时前
在Scrum中实施敏捷建模
java·开发语言·windows·算法·c#
❀͜͡傀儡师2 小时前
20 年沉淀,CAS 8.0 重新定义企业级 SSO:适配 JDK 25 与 Spring Boot 4.1
java·开发语言·spring boot
weixin_307779132 小时前
基于睿擎工业开发平台的预训练视觉模型轻量化适配与低代码部署优化
开发语言·算法
实心儿儿2 小时前
Qt — Qt 多线程
开发语言·qt
Apifox2 小时前
Apifox 9 月更新|CLI 能力升级、GitLab 私有化部署接入与产品体验优化
前端·后端·测试