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

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 是 Swift Error 枚举(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 壳侧质量保障:从"能跑"到"可发布"

  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 并做发布检查与结课。

相关推荐
Yunovian5 小时前
AI时代,针对模型与应用,浅谈一下各编程语言
开发语言·c++·人工智能·python·ai·rust·ai编程
Kapaseker7 小时前
Rust 凭什么不用垃圾回收?先搞懂 Owner
rust
Amos_Web7 小时前
Rspack 源码解析(十五):Loader Runner 与 JS Loader 桥接
前端·rust·前端框架
柯南466814 小时前
【AI开发之Rust】第 18 课:SQLite 持久化与本地缓存 —— 给 store 填上真实现
rust·编程语言
柯南466814 小时前
【AI开发之Rust】第 17 课:项目总览与核心架构 —— AI 助手 Rust 核心从 0 到 1
rust·编程语言
挖掘狂人15 小时前
ObjectSense:一门千行内核、把可靠性写进骨子里的面向对象脚本语言
程序员·编程语言·汇编语言
codigger15 小时前
ObjectSense:一门千行内核、把可靠性写进骨子里的面向对象脚本语言
开发语言·编程·编程语言
柯南466815 小时前
【AI开发之Rust】第 16 课:FFI 手写绑定与内存布局 —— 把 Rust 交给别的语言
rust·编程语言
达子66615 小时前
RUST 图解 第 2 章:写小游戏
rust