20.1 这节课解决什么问题
第 19 课生成了各语言的"原始绑定"------它们是能力正确但面向数据的薄层。真实 UI 需要更顺手的封装,本课完成"最后一公里":
typescript
① 语义封装:把 uniffi 生成的 raw API 包成各端"符合平台习惯"的门面
(Swift: async/await + @MainActor;Kotlin: 挂起函数 + Flow;TS/桌面: Promise)
② 增量呈现:让流式回答像"打字机"一样出现在气泡里
③ 错误策略:ApiError 的 kind/message → 用户能看懂的话术与重试动作
④ 异常路径:离线、断流、超时------UI 不崩、可恢复
💡 分工重申(17.1):Rust 提供"业务正确 + 线程安全"的核心;壳负责"平台习惯 + 用户体验"。壳里不写业务逻辑(怎么调 LLM、怎么存历史都由 core 管),壳只做翻译与呈现------这条边界让 21 课能做到"换个壳,核心零改动"。
20.2 从原始绑定到平台门面:三端对照
20.2.1 Swift(iOS/macOS):@MainActor + async/await
UniFFI 的 Swift 绑定天然给 async 方法生成 async 版本,但调用线程与 UI 线程要理顺。惯例包一层:
swift
import my_ai_ffi
// 平台门面:UI 只跟它说话
@MainActor
final class AiAssistantClient: ObservableObject {
private let raw: AiAssistant // uniffi 生成的 raw 对象
private let streaming = StreamingBuffer() // 见 20.3
init(dbPath: String, systemPrompt: String) throws {
self.raw = try AiAssistant(
dbPath: dbPath,
systemPrompt: systemPrompt
)
}
func createSession(title: String) async throws -> Session {
try await raw.newSession(title: title) // await:不卡主线程
}
func ask(sessionId: String, question: String) async throws -> String {
try await raw.ask(sessionId: sessionId, question: question)
}
}
关键设计:
@MainActor:所有 UI 触点集中在主线程,Rust 的异步在 tokio 线程池跑完后回到主线程更新模型------避免"后台改 UI"这类经典崩溃;- 原始对象持有权:
AiAssistant是引用语义(UniFFI Object),持有即可;App 生命周期内单例,不要反复创建(每次 new 都会开数据库连接/LLM 客户端); - 绑定层抛出的
ApiError是 SwiftError枚举(case .store(let message)之类),可直接进do/catch。
20.2.2 Kotlin(Android):挂起函数 + StateFlow
kotlin
import my_ai_ffi.AiAssistant
import my_ai_ffi.ApiError
class AssistantRepository(private val raw: AiAssistant) {
suspend fun ask(sessionId: String, question: String): String =
raw.ask(sessionId, question) // uniffi 已映射成挂起函数
// 把"错误 → 用户提示 + 是否可重试"的决策放这里
fun uiError(e: ApiError): UiError = when (e) {
is ApiError.Store -> UiError("历史加载失败,请重试", retriable = true)
is ApiError.Llm -> when (e.kind) {
LlmErrorKind.TIMEOUT -> UiError("连接超时,请重试", retriable = true)
LlmErrorKind.UPSTREAM -> UiError("服务繁忙,稍后再试", retriable = true)
LlmErrorKind.STREAMINTERRUPTED -> UiError("回答中断,已保留部分内容", retriable = false)
LlmErrorKind.HTTP, LlmErrorKind.CONFIG -> UiError("网络异常", retriable = true)
}
is ApiError.InvalidArgument -> UiError("内容无效", retriable = false)
is ApiError.Other -> UiError("出了点问题", retriable = false)
}
}
💡 观察 19.3.2 的 payoff:Rust 侧把错误"翻译"成
ApiError{kind, message},壳只做展示层映射,不用猜底层是哪个 crate 的异常。这就是贯穿 7/17/19 课的错误分层在真实 App 里的样子。
20.2.3 TypeScript / 桌面侧:Promise
若桌面用 Tauri/Electron + UniFFI TS 绑定:
typescript
import { AiAssistant } from "./bindings"; // 生成的绑定
const client = new AiAssistant(dbPath, systemPrompt);
async function ask(sessionId: string, q: string): Promise<string> {
try {
return await client.ask(sessionId, q);
} catch (e) {
// e 是带 kind/message 的错误对象
return uiErrorText(e);
}
}
20.3 增量呈现:流式回答的"打字机"效果
19.5 说 Rust 侧把增量写进队列、UI 轮询。壳侧把这个"轮询"包成平台习惯的形态:
20.3.1 Swift:AsyncSequence / 定时器 + buffer
swift
import Combine
// 增量缓冲:Swift 侧每 80ms 把 Rust 队列里的新块接进来
final class StreamingBuffer: ObservableObject {
@Published var currentText = "" // 已呈现文本(增量累积)
private var timer: Timer?
func startStreaming() {
timer = Timer.scheduledTimer(withTimeInterval: 0.08, repeats: true) { [weak self] _ in
guard let self else { return }
let deltas = self.rawSession.drainPending() // uniffi 方法
self.currentText += deltas.joined()
}
}
func stopStreaming() { timer?.invalidate(); timer = nil }
}
要点:定时器只做"从 Rust 队列拿增量 + 刷新 @Published" ,不做网络、不碰数据库;当 rawSession.isDone() 为 true 且队列空时停表收尾。
20.3.2 Kotlin:callbackFlow 把轮询包成 Flow
kotlin
fun streamAnswer(session: LlmSession, question: String): Flow<String> = callbackFlow {
val scope = CoroutineScope(Dispatchers.Default)
var job: Job? = null
job = scope.launch {
while (!session.isDone() || session.drainPending().isNotEmpty()) {
session.drainPending().forEach { trySend(it) }
delay(80) // 轮询节奏
}
close()
}
scope.launch { session.askStream(question) } // 触发 Rust 侧生成
awaitClose { job?.cancel() } // UI 退出即取消
}
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) | "回答中断,已保留前半部分" | 保留部分消息;允许"继续生成"(带上下文重发) |
| 数据库忙/损坏 | "历史暂不可用" | 降级为"本次会话不落库"并提示;恢复后重试 |
| 参数非法 | 内联提示 | 不发起请求(壳侧提前校验,最省) |
swift
// Swift 侧统一策略入口
enum RetryPolicy { case retry, keepPartial, none }
func handle(_ error: Error) -> RetryPolicy {
guard let api = error as? ApiError else { return .none }
switch api {
case .llm(let kind, let message):
switch kind {
case .timeout, .http, .upstream: return .retry
case .streamInterrupted: return .keepPartial
case .config: return .none
}
case .store: return .retry
case .invalidArgument, .other: return .none
}
}
20.4.2 "继续生成"怎么实现(断流恢复的工程解)
19 课建议消息加 complete 标记。恢复路径在壳侧就是一次普通的重发:
vbnet
UI: 用户点"继续生成"
壳: 把上一轮 user 问题 + assistant 已收到的半截内容作为上下文
调用 core 的一个"续写"用例(core 负责拼接历史并再次 ask_stream)
Rust: 复用 17 课 ask_stream;唯一差别是落库策略改为"覆盖上一条不完整的 assistant 消息"
💡 这种"半截回答 + 续写"体验在真实 AI 产品里非常常见(回答被打断后接着问"继续")。它不依赖任何流式魔法,只是把"不完整消息"当成一种可续写状态------所以 17 课刻意把落库逻辑留在 service 内,壳不用知道细节。
20.4.3 离线路径:没有网络时体验不塌
swift
进入页面时:
壳检测网络 → 离线则隐藏输入框/置灰
但"历史列表"照常展示(数据在本地 sqlite,18 课的成果)
Rust 侧:
complete 时不抛错,而是返回"离线"的 ApiError → 壳转本地文案
会话历史永远先落库再更新 UI:列表数据来源是本地库,不是内存
20.5 壳侧质量保障:从"能跑"到"可发布"
- 壳只做胶水 :核心用例(落库、LLM、错误翻译)已在 core 的 Rust 测试里覆盖------壳测试只测"映射逻辑"(如
uiError的分支表)。 - 超时兜底放两端:Rust 有 connect/首字节超时;壳再加"整轮 60s 无新块"的 UI 级倒计时,双保险。
- 日志与可诊断 :壳把
ApiError.message、会话 id、耗时上报;Rust 侧用tracing(21 课给配置)。 - 内存/线程纪律 :单例持
AiAssistant;不跨线程传递 raw 对象(都收在门面类里);Object 的释放由绑定管理,壳不要手动 free。 - 自动化冒烟:19 课的 Python 通道保留为"发布前回归"脚本------先跑 Rust 全量测试,再跑 Python 冒烟,最后才跑平台 UI 测试。
20.6 📝 动手练习
参考实现放 code/20-shells/(写作时同步给出;Swift/Kotlin 若环境不全,用 Python/TS 壳 + 注释体现代码逻辑)。
- 错误映射表 :给
ApiError的全部 kind 写一份"用户话术 + RetryPolicy"映射表(任一语言),并写单元测试断言每个 kind 都命中预期分支。 - 打字机实现 :用 19 课的
LlmSession(drain_pending/is_done),在目标语言里实现 80ms 轮询拼字;手工"断流"一次,确认能保留已收内容。 - 离线策略:模拟"无网络",验证:历史列表仍可加载(本地库)、发送按钮禁用/给出文案、重试按钮可用。
- 重试幂等 :把一次网络错误的
ask重试 2 次(退避 200ms/500ms),确认第二次成功且不会产生重复的历史记录(思考:为什么重试不会重复落库?------错误发生在 LLM 层时 user 消息尚未落库,见 17.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 并做发布检查与结课。