【AI开发之Rust】第 20 课:壳侧 API 封装 —— 把 Rust 能力接进真实 UI

文章来源声明: 原文作者:柯南4668; 来源站点:掘金; 原文链接:https://juejin.cn/post/7690218295267442697; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

对做跨端 AI 应用的开发者很实用,核心价值在于划清 Rust 与壳的职责边界,并给出可落地的错误映射与流式呈现方案。适合 iOS/Android/桌面多端接入 UniFFI 的工程团队参考。

20.1 这节课解决什么问题 --------------

第 19 课生成了各语言的"原始绑定"——它们是能力正确但面向数据的薄层。真实 UI 需要更顺手的封装,本课完成"最后一公里":

① 语义封装:把 uniffi 生成的 raw <span>API</span> 包成各端<span>"符合平台习惯"</span>的门面
   (<span>Swift</span>: <span>async</span>/<span>await</span> + <span>@MainActor</span>;<span>Kotlin</span>: 挂起函数 + <span>Flow</span>;<span>TS</span>/桌面: <span>Promise</span>)
② 增量呈现:让流式回答像<span>"打字机"</span>一样出现在气泡里
③ 错误策略:<span>ApiError</span> 的 kind/message → 用户能看懂的话术与重试动作
④ 异常路径:离线、断流、超时——<span>UI</span> 不崩、可恢复

💡 分工重申(17.1):Rust 提供"业务正确 + 线程安全"的核心;壳负责"平台习惯 + 用户体验"。壳里不写业务逻辑(怎么调 LLM、怎么存历史都由 core 管),壳只做翻译与呈现——这条边界让 21 课能做到"换个壳,核心零改动"。

20.2 从原始绑定到平台门面:三端对照

20.2.1 Swift(iOS/macOS):@MainActor + async/await

UniFFI 的 Swift 绑定天然给 async 方法生成 async 版本,但调用线程与 UI 线程要理顺。惯例包一层:

<span>import</span> my_ai_ffi

<span>// 平台门面:UI 只跟它说话</span>
<span>@MainActor</span>
<span>final</span> <span>class</span> <span>AiAssistantClient</span>: <span>ObservableObject</span> {
    <span>private</span> <span>let</span> raw: <span>AiAssistant</span>           <span>// uniffi 生成的 raw 对象</span>
    <span>private</span> <span>let</span> streaming <span>=</span> <span>StreamingBuffer</span>()   <span>// 见 20.3</span>

    <span>init</span>(<span>dbPath</span>: <span>String</span>, <span>systemPrompt</span>: <span>String</span>) <span>throws</span> {
        <span>self</span>.raw <span>=</span> <span>try</span> <span>AiAssistant</span>(
            dbPath: dbPath,
            systemPrompt: systemPrompt
        )
    }

    <span>func</span> <span>createSession</span>(<span>title</span>: <span>String</span>) <span>async</span> <span>throws</span> -> <span>Session</span> {
        <span>try</span> <span>await</span> raw.newSession(title: title)      <span>// await:不卡主线程</span>
    }

    <span>func</span> <span>ask</span>(<span>sessionId</span>: <span>String</span>, <span>question</span>: <span>String</span>) <span>async</span> <span>throws</span> -> <span>String</span> {
        <span>try</span> <span>await</span> raw.ask(sessionId: sessionId, question: question)
    }
}

关键设计:

  • @MainActor:所有 UI 触点集中在主线程,Rust 的异步在 tokio 线程池跑完后回到主线程更新模型——避免"后台改 UI"这类经典崩溃;
  • 原始对象持有权:AiAssistant 是引用语义(UniFFI Object),持有即可;App 生命周期内单例,不要反复创建(每次 new 都会开数据库连接/LLM 客户端);
  • 绑定层抛出的 ApiError 是 Swift Error 枚举(case .store(let message) 之类),可直接进 do/catch。

20.2.2 Kotlin(Android):挂起函数 + StateFlow

<span>import</span> my_ai_ffi.AiAssistant
<span>import</span> my_ai_ffi.ApiError

<span>class</span> <span>AssistantRepository</span>(<span>private</span> <span>val</span> raw: AiAssistant) {
    <span>suspend</span> <span><span>fun</span> <span>ask</span><span>(sessionId: <span>String</span>, question: <span>String</span>)</span></span>: String =
        raw.ask(sessionId, question)          <span>// uniffi 已映射成挂起函数</span>

    <span>// 把"错误 → 用户提示 + 是否可重试"的决策放这里</span>
    <span><span>fun</span> <span>uiError</span><span>(e: <span>ApiError</span>)</span></span>: UiError = <span>when</span> (e) {
        <span>is</span> ApiError.Store -> UiError(<span>"历史加载失败,请重试"</span>, retriable = <span>true</span>)
        <span>is</span> ApiError.Llm -> <span>when</span> (e.kind) {
            LlmErrorKind.TIMEOUT -> UiError(<span>"连接超时,请重试"</span>, retriable = <span>true</span>)
            LlmErrorKind.UPSTREAM -> UiError(<span>"服务繁忙,稍后再试"</span>, retriable = <span>true</span>)
            LlmErrorKind.STREAMINTERRUPTED -> UiError(<span>"回答中断,已保留部分内容"</span>, retriable = <span>false</span>)
            LlmErrorKind.HTTP, LlmErrorKind.CONFIG -> UiError(<span>"网络异常"</span>, retriable = <span>true</span>)
        }
        <span>is</span> ApiError.InvalidArgument -> UiError(<span>"内容无效"</span>, retriable = <span>false</span>)
        <span>is</span> ApiError.Other -> UiError(<span>"出了点问题"</span>, retriable = <span>false</span>)
    }
}

💡 观察 19.3.2 的 payoff:Rust 侧把错误"翻译"成 ApiError{kind, message},壳只做展示层映射,不用猜底层是哪个 crate 的异常。这就是贯穿 7/17/19 课的错误分层在真实 App 里的样子。

20.2.3 TypeScript / 桌面侧:Promise

若桌面用 Tauri/Electron + UniFFI TS 绑定:

<span>import</span> { <span>AiAssistant</span> } <span>from</span> <span>"./bindings"</span>;  <span>// 生成的绑定</span>

<span>const</span> client = <span>new</span> <span>AiAssistant</span>(dbPath, systemPrompt);

<span>async</span> <span>function</span> <span>ask</span>(<span>sessionId: <span>string</span>, q: <span>string</span></span>): <span>Promise</span><<span>string</span>> {
    <span>try</span> {
        <span>return</span> <span>await</span> client.<span>ask</span>(sessionId, q);
    } <span>catch</span> (e) {
        <span>// e 是带 kind/message 的错误对象</span>
        <span>return</span> <span>uiErrorText</span>(e);
    }
}

20.3 增量呈现:流式回答的"打字机"效果

19.5 说 Rust 侧把增量写进队列、UI 轮询。壳侧把这个"轮询"包成平台习惯的形态:

20.3.1 Swift:AsyncSequence / 定时器 + buffer

<span>import</span> Combine

<span>// 增量缓冲:Swift 侧每 80ms 把 Rust 队列里的新块接进来</span>
<span>final</span> <span>class</span> <span>StreamingBuffer</span>: <span>ObservableObject</span> {
    <span>@Published</span> <span>var</span> currentText <span>=</span> <span>""</span>      <span>// 已呈现文本(增量累积)</span>
    <span>private</span> <span>var</span> timer: <span>Timer</span>?

    <span>func</span> <span>startStreaming</span>() {
        timer <span>=</span> <span>Timer</span>.scheduledTimer(withTimeInterval: <span>0.08</span>, repeats: <span>true</span>) { [<span>weak</span> <span>self</span>] <span>_</span> <span>in</span>
            <span>guard</span> <span>let</span> <span>self</span> <span>else</span> { <span>return</span> }
            <span>let</span> deltas <span>=</span> <span>self</span>.rawSession.drainPending()   <span>// uniffi 方法</span>
            <span>self</span>.currentText <span>+=</span> deltas.joined()
        }
    }

    <span>func</span> <span>stopStreaming</span>() { timer<span>?</span>.invalidate(); timer <span>=</span> <span>nil</span> }
}

要点:定时器只做"从 Rust 队列拿增量 + 刷新 @Published",不做网络、不碰数据库;当 rawSession.isDone() 为 true 且队列空时停表收尾。

20.3.2 Kotlin:callbackFlow 把轮询包成 Flow

<span><span>fun</span> <span>streamAnswer</span><span>(session: <span>LlmSession</span>, question: <span>String</span>)</span></span>: Flow<String> = callbackFlow {
    <span>val</span> scope = CoroutineScope(Dispatchers.Default)
    <span>var</span> job: Job? = <span>null</span>
    job = scope.launch {
        <span>while</span> (!session.isDone() || session.drainPending().isNotEmpty()) {
            session.drainPending().forEach { trySend(it) }
            delay(<span>80</span>)                          <span>// 轮询节奏</span>
        }
        close()
    }
    scope.launch { session.askStream(question) }   <span>// 触发 Rust 侧生成</span>
    awaitClose { job?.cancel() }                   <span>// UI 退出即取消</span>
}

UI 层 collect { bubble.appendText(it) },气泡像打字机一样生长;用户点"停止"时 cancel,Rust 侧 future 被丢(13 课的可取消性在此生效)。

⚠️ 轮询是"务实默认",不是唯一解。UniFFI 对同步回调其实是支持的(#[uniffi::export] + 回调接口),如果你用的版本支持,可以把 20.3 的两段代码替换成"回调直推";队列+轮询的写法在任何版本都稳,所以本课程主推它。

20.4 异常路径处理:UI 不崩、可恢复

20.4.1 三类典型异常及其策略表

场景用户看到系统动作
网络错误(HTTP)"网络异常,请检查连接"禁发送按钮 + 提供重试
超时(Timeout)"响应超时"可重试;若在流式中,先把已收到内容落库(19 课的 complete 标记)
流中断(StreamInterrupted)"回答中断,已保留前半部分"保留部分消息;允许"继续生成"(带上下文重发)
数据库忙/损坏"历史暂不可用"降级为"本次会话不落库"并提示;恢复后重试
参数非法内联提示不发起请求(壳侧提前校验,最省)
<span>// Swift 侧统一策略入口</span>
<span>enum</span> <span>RetryPolicy</span> { <span>case</span> retry, keepPartial, none }

<span>func</span> <span>handle</span>(<span>_</span> <span>error</span>: <span>Error</span>) -> <span>RetryPolicy</span> {
    <span>guard</span> <span>let</span> api <span>=</span> error <span>as?</span> <span>ApiError</span> <span>else</span> { <span>return</span> .none }
    <span>switch</span> api {
    <span>case</span> .llm(<span>let</span> kind, <span>let</span> message):
        <span>switch</span> kind {
        <span>case</span> .timeout, .http, .upstream: <span>return</span> .retry
        <span>case</span> .streamInterrupted: <span>return</span> .keepPartial
        <span>case</span> .config: <span>return</span> .none
        }
    <span>case</span> .store: <span>return</span> .retry
    <span>case</span> .invalidArgument, .other: <span>return</span> .none
    }
}

20.4.2 "继续生成"怎么实现(断流恢复的工程解)

19 课建议消息加 complete 标记。恢复路径在壳侧就是一次普通的重发:

<span>UI:</span> 用户点<span>"继续生成"</span>
壳: 把上一轮 user 问题 + assistant 已收到的半截内容作为上下文
    调用 core 的一个“续写”用例(core 负责拼接历史并再次 ask_stream)
<span>Rust:</span> 复用 <span>17</span> 课 ask_stream;唯一差别是落库策略改为<span>"覆盖上一条不完整的 assistant 消息"</span>

💡 这种"半截回答 + 续写"体验在真实 AI 产品里非常常见(回答被打断后接着问"继续")。它不依赖任何流式魔法,只是把"不完整消息"当成一种可续写状态——所以 17 课刻意把落库逻辑留在 service 内,壳不用知道细节。

20.4.3 离线路径:没有网络时体验不塌

进入页面时:
  壳检测网络 <span>→</span> 离线则隐藏输入框<span>/</span>置灰
  但<span>"历史列表"</span>照常展示(数据在本地 sqlite,<span>18</span> 课的成果)
<span>Rust</span> 侧:
  complete 时不抛错,而是返回<span>"离线"</span>的 <span>ApiError</span> <span>→</span> 壳转本地文案
会话历史永远先落库再更新 <span>UI:列表数据来源是本地库,不是内存</span>

20.5 壳侧质量保障:从"能跑"到"可发布"

  1. 壳只做胶水:核心用例(落库、LLM、错误翻译)已在 core 的 Rust 测试里覆盖——壳测试只测"映射逻辑"(如 uiError 的分支表)。
  2. 超时兜底放两端:Rust 有 connect/首字节超时;壳再加"整轮 60s 无新块"的 UI 级倒计时,双保险。
  3. 日志与可诊断:壳把 ApiError.message、会话 id、耗时上报;Rust 侧用 tracing(21 课给配置)。
  4. 内存/线程纪律:单例持 AiAssistant;不跨线程传递 raw 对象(都收在门面类里);Object 的释放由绑定管理,壳不要手动 free。
  5. 自动化冒烟:19 课的 Python 通道保留为"发布前回归"脚本——先跑 Rust 全量测试,再跑 Python 冒烟,最后才跑平台 UI 测试。

20.6 📝 动手练习

参考实现放 code/20-shells/(写作时同步给出;Swift/Kotlin 若环境不全,用 Python/TS 壳 + 注释体现代码逻辑)。

  1. 错误映射表:给 ApiError 的全部 kind 写一份"用户话术 + RetryPolicy"映射表(任一语言),并写单元测试断言每个 kind 都命中预期分支。
  2. 打字机实现:用 19 课的 LlmSession(drain_pending/is_done),在目标语言里实现 80ms 轮询拼字;手工"断流"一次,确认能保留已收内容。
  3. 离线策略:模拟"无网络",验证:历史列表仍可加载(本地库)、发送按钮禁用/给出文案、重试按钮可用。
  4. 重试幂等:把一次网络错误的 ask 重试 2 次(退避 200ms/500ms),确认第二次成功且不会产生重复的历史记录(思考:为什么重试不会重复落库?——错误发生在 LLM 层时 user 消息尚未落库,见 17.5 时序)。
  5. 壳测试:在纯 Rust/Python 侧模拟"壳的胶水逻辑"(错误映射 + 队列轮询状态机),不依赖真 UI 框架跑通。

验收门禁:能画出"core 错误 → ApiError → 壳映射 → 用户提示"这条链上每层的职责;能说出轮询方案为什么跨版本稳、以及替换成回调的时机;能列出离线/超时/断流三种路径的 UI 策略。

✅ 本节小结

  • 门面封装:Swift @MainActor + async/await、Kotlin 挂起函数 + Flow、TS Promise——平台习惯优先;
  • 增量呈现:Rust 队列 + 壳轮询(80ms)是最稳的跨版本"打字机";版本允许时可用回调直推;
  • 错误策略:ApiError.kind/message → 壳映射成"话术 + RetryPolicy";断流用"保留部分 + 续写"而非重头再来;
  • 离线体验:历史靠本地库照常展示,发送动作优雅降级;
  • 质量:壳只做胶水,逻辑留在 core 测试;双端超时;单例持有 raw 对象;
  • 边界记忆:业务正确归 Rust,平台手感归壳。

下一课预告:第 21 课《双端集成与出包》——把壳封装好的核心真正打进两端:Android 走 cargo-ndk 出 .so → jniLibs → AAR → Kotlin,iOS 走各 target 静态库 → xcframework → Swift;再配双端联调套路与跨端坑清单。第 22 课《一键多平台与工程收尾》再固化成 CI 并做发布检查与结课。