环境:Android 部分 Win/mac/Linux 皆可;iOS 部分必须 macOS + Xcode
21.1 这节课解决什么问题
第 19 课生成了绑定,第 20 课定好了封装模式。本课做最终交付------把同一份 core 打进两个移动平台的"标准包":
Android:Rust 编成 .so → 连同 UniFFI 生成的 Kotlin 打成 AAR → Compose 调用
iOS: Rust 编成静态库 → xcrun 合并成 xcframework → Swift 调用(SwiftUI Demo)
两条管线在"交叉编译"这个点上分道扬镳,但核心资产完全共享:core 的 Rust 代码一个字不改,两端的差异只在"怎么把核心编成对应平台要的形状"。
┌─────────────── Rust core(一次编写,17-19 课)───────────────┐
│ │
▼ ▼
cargo-ndk + aarch64-linux-android 等 rustup target + aarch64-apple-ios 等
│ 生成各 ABI 的 .so │ 生成真机/模拟器静态库
▼ ▼
UniFFI Kotlin 绑定 ──► Android AAR ──► Compose UniFFI Swift 绑定 + xcframework ──► SwiftUI
💡 本课"重复劳动"偏重(命令多、路径长),目标不是背命令,而是理解每个平台产物的"形状" :Android 要的是
jniLibs/<abi>/libxxx.so,iOS 要的是xcframework/<平台>/libxxx.a。理解形状后,无论用脚本、Xcode、Gradle 还是 CI,都是把文件放到对的位置。
21.2 Android 线:.so → AAR → Kotlin
21.2.1 准备交叉编译工具链
bash
# 1. Rust target(按需选 ABI)
rustup target add aarch64-linux-android armv7-linux-androideabi \
x86_64-linux-android i686-linux-android
# 2. NDK:Android Studio SDK Manager 安装,记住路径
# (命令行 NDK:https://developer.android.com/ndk/downloads)
# 3. cargo-ndk:cargo 的 NDK 包装器
cargo install cargo-ndk
# 4. 让 cargo-ndk 找到 NDK
export ANDROID_NDK_HOME=~/Library/Android/sdk/ndk/<版本号>
ABI 与 target 对照:
| ABI | target | 覆盖设备 |
|---|---|---|
| arm64-v8a | aarch64-linux-android |
现代手机/平板(主力) |
| armeabi-v7a | armv7-linux-androideabi |
老 32 位机(可选) |
| x86_64 | x86_64-linux-android |
模拟器/x86 设备(调试用) |
| x86 | i686-linux-android |
老模拟器(可选) |
💡 生产通常只发
arm64-v8a+(调试用)x86_64;省包体就别打全四个。
21.2.2 构建 .so
bash
# 在 ffi-bindings crate 目录执行:-o 直接按 ABI 目录结构输出
cargo ndk -t arm64-v8a -t x86_64 -o ../android-out/jniLibs build --release
# 产物形状(Android 期望的 jniLibs 布局):
# android-out/jniLibs/
# ├── arm64-v8a/libmy_ai_ffi.so
# └── x86_64/libmy_ai_ffi.so
bash
# 验证产物确实是"Android 库"而非主机库
file android-out/jniLibs/arm64-v8a/libmy_ai_ffi.so
# ELF 64-bit LSB shared object, ARM aarch64, for Android NDK ...
⚠️ 一定要
--release(debug 的 .so 巨大且慢);ffi-bindings/Cargo.toml用crate-type = ["cdylib", "staticlib"]------Android 走 cdylib(.so),iOS 走 staticlib(.a)。
21.2.3 生成 Kotlin 绑定并接入 Android 工程
bash
# 绑定要从"某个架构的产物"里读 ABI 元数据(任选一个已编好的即可)
uniffi-bindgen generate src/lib.rs \
--library ../target/aarch64-linux-android/release/libmy_ai_ffi.so \
--language kotlin --out-dir ../android-out/kotlin
# 产物:android-out/kotlin/my_ai_ffi/(含 .kt 绑定与元数据)
接入工程:
bash
Android 工程 myapp/
└── app/src/main/
├── java/org/example/myapp/... # 你自己的 Kotlin
├── ...(把生成的 my_ai_ffi.kt 放同源集即可)
└── jniLibs/ # ★ 把 jniLibs 目录整体拷到这里
├── arm64-v8a/libmy_ai_ffi.so
└── x86_64/libmy_ai_ffi.so
要发 AAR :把这些文件放进一个 Android Library 模块的 src/main/,跑 gradle :lib:assembleRelease 即可得到"含 .so + Kotlin 绑定"的 .aar,其它 App 直接引。
kotlin
// Android Library 的 build.gradle.kts 关键项
android {
defaultConfig {
ndk { abiFilters += listOf("arm64-v8a", "x86_64") } // 只打进这两个 ABI
}
}
21.2.4 Kotlin 壳:协程 + 主线程回 UI
kotlin
// 用 20 课的门面模式包住 raw 绑定
class AiRepository(dbPath: String, systemPrompt: String) {
private val raw = AiAssistant(dbPath, systemPrompt) // uniffi 生成
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
fun ask(
sessionId: String,
question: String,
onText: (String) -> Unit, // 回调都切回主线程
onError: (UiError) -> Unit,
) {
scope.launch {
try {
val answer = raw.ask(sessionId, question) // uniffi 映射的挂起函数
onText(answer)
} catch (e: ApiError) {
onError(uiError(e)) // 20 课话术映射表
}
}
}
}
最小 Compose 壳(只做"发命令 + 渲染"):
kotlin
@Composable
fun ChatScreen(repo: AiRepository, sessionId: String) {
var text by remember { mutableStateOf("") }
var question by remember { mutableStateOf("") }
Column {
Text(text)
OutlinedTextField(question, { question = it })
Button(onClick = {
repo.ask(sessionId, question, onText = { text += it }, onError = {})
}) { Text("发送") }
}
}
21.2.5 Android 调试三板斧
bash
# 1. 跨层日志:Rust 侧 println!/tracing 会进 logcat
adb logcat | grep -i my_ai_ffi
# 2. Rust panic 栈
adb shell setprop debug.rust.backtrace 1
# 3. .so 找不到:UnsatisfiedLinkError → 检查 jniLibs 目录名/ABI 拼写/abiFilters
21.2.6 Android 发布注意
| 事项 | 说明 |
|---|---|
| minify/R8 | 绑定是普通 Kotlin,默认规则即可;混淆后报 UnsatisfiedLinkError 就加 -keep class my_ai_ffi.** { *; } |
| ABI 分包 | abiFilters 只留 arm64-v8a 减小包体(Play 还支持 AAB 自动分发) |
| 主线程纪律 | 同步重活别放主线程;async 方法已在线程池跑 |
| release 调优 | 见 22 课 [profile.release] + strip |
21.3 iOS 线:xcframework → Swift
21.3.1 构建产物
bash
# 必须在 macOS + Xcode。先装 target
rustup target add aarch64-apple-ios # 真机
rustup target add aarch64-apple-ios-sim # Apple Silicon 模拟器
rustup target add x86_64-apple-ios # Intel 模拟器(可选)
# 编静态库(iOS 用 staticlib,链接进 App)
cargo build -p my_ai_ffi --release --target aarch64-apple-ios
cargo build -p my_ai_ffi --release --target aarch64-apple-ios-sim
# 打成 xcframework(真机 + 模拟器两片,Xcode 运行时自动挑)
xcrun xcframework create \
-library target/aarch64-apple-ios/release/libmy_ai_ffi.a \
-library target/aarch64-apple-ios-sim/release/libmy_ai_ffi.a \
-output ios-out/MyAiCore.xcframework
⚠️ 现代标准是 xcframework (可同时含真机+模拟器片),
lipo合并多架构属于旧做法;Windows 编不了 iOS,CI 用 macOS runner(22 课给 workflow)。
21.3.2 生成 Swift 绑定并接入 Xcode
bash
uniffi-bindgen generate src/lib.rs \
--library target/aarch64-apple-ios/release/libmy_ai_ffi.a \
--language swift --out-dir ios-out/swift
# 产物:my_ai_ffi.swift + FFI 头/模块文件
接入两种方式:
css
方式 A(手动):把 .xcframework 拖进 Target → General → Frameworks;
把生成的 .swift 拖进工程
方式 B(SPM 封装,推荐):建 Swift Package,把 .xcframework 作为 binary target、
Swift 源码作为普通 target → 多 App 复用 + 版本管理更干净
swift
// Swift 壳:把 20 课的 @MainActor 门面接上 raw 绑定
import MyAiCore // SPM 包名
@MainActor
final class AssistantViewModel: ObservableObject {
private let assistant: AiAssistant
@Published var text = ""
@Published var errorText: String?
init() throws {
assistant = try AiAssistant(
dbPath: Self.dbPath(), systemPrompt: "你是课程助教")
}
func ask(question: String) {
Task {
do {
// async 绑定在后台跑完,回主线程更新 @Published
text += try await assistant.ask(
sessionId: sessionId, question: question)
} catch let e as ApiError {
errorText = Self.friendly(e) // 20 课话术映射
} catch { errorText = "未知错误" }
}
}
}
最小 SwiftUI 壳:
swift
struct ContentView: View {
@StateObject private var vm: AssistantViewModel
@State private var input = ""
var body: some View {
VStack {
ScrollView { Text(vm.text).frame(maxWidth: .infinity, alignment: .leading) }
HStack {
TextField("问点什么", text: $input)
Button("发送") { vm.ask(question: input); input = "" }
}
}
.padding()
}
}
21.3.3 iOS 调试与注意
| 事项 | 说明 |
|---|---|
| 模拟器 vs 真机 | 模拟器跑在真机片会报 "built for iOS Simulator but linking ... built for iOS";缺 sim 片就加 sim target 再编一次 |
| 签名 | xcframework 不签名,Xcode 对 App 统一签名即可 |
| Deployment Target | Rust 侧无要求;绑定要求 Swift 版本别低于工程设置 |
| 隐私清单 | 网络请求走系统的隐私政策------把涉及网络的说明填进 PrivacyInfo.xcprivacy,否则上架审核可能被问 |
| ATS | 开发期访问 http:// 明文端点需 Info.plist 临时 NSAllowsArbitraryLoads;上架前移除,全部走 https |
| 包体积 | 静态库被链接器裁掉未用代码;strip 与 release 调优见 22 课 |
21.4 双端联调套路(推荐顺序)
markdown
1. core: cargo test(不碰任何平台)
2. ffi-bindings: cargo build --release(主机) + Python 冒烟(19 课通道)
3. Android: cargo-ndk 出 so → logcat 里看 Hello/Rust 日志 → 再跑真实用例
4. iOS: xcframework → 先模拟器跑通 → 再真机
5. 双端各跑:登录/会话历史(本地库) → 流式问答 → 断网降级 → 重启后历史还在
💡 每端第一次接通的"最小冒烟"别接 UI:先在 Android/iOS 里只调
listSessions()/listSessions()(不联网),确认"库能加载、绑定能跑",再逐步往上叠。定位问题时永远先砍到最小可复现。
21.5 常见坑清单(跨端)
| 症状 | 原因 | 对策 |
|---|---|---|
UnsatisfiedLinkError / "dylib not found" |
jniLibs 路径、ABI 目录拼错;cdylib 编了但没拷 | 核对 jniLibs/<abi>/lib*.so |
| iOS 链接一堆 undefined symbol | 编了 cdylib 忘编 staticlib | crate-type 含 "staticlib" |
| 模拟器/真机互相不认 | target 片不对 | 检查 .a 的 file/arch |
| 换 NDK 版本后编译炸 | 工具链与 NDK 不匹配 | 固定 NDK 版本并记录;CI 与本地一致 |
| 绑定与库版本不一致 | bindgen 版本 ≠ crate 版本 | cargo install uniffi_bindgen --version 对齐 |
| release 下行为不同 | 优化导致的时序/溢出被掩盖或放大 | debug/release 各跑一遍核心测试 |
21.6 📝 动手练习
- Android 出包 :装 target + cargo-ndk,编出 arm64-v8a 的
.so,用file/readelf验证架构;把 19 课的 Python 冒烟换成"Android 工程里调listSessions()"。 - AAR 演示 :建一个 Android Library 模块收
jniLibs/+ 绑定 Kotlin,assembleRelease后解包 AAR 确认.so与类都在。 - Compose 壳:实现 21.2.4 的 ChatScreen,能发问并展示本地历史(不接真实 LLM 也可用 core 的 Echo/Fake 客户端)。
- iOS 出包:macOS 上编真机+simulator 两个静态库并打成 xcframework;用 SPM 封装接入一个空 SwiftUI 工程。
- iOS 冒烟 :SwiftUI 里调
listSessions创建会话并打印 id;再跑通一次流式问答(LlmSession轮询版)。 - 断网演练:双端各验一次 20 课的离线策略(历史可看、发送降级、重试可用)。
- 记录手册 :把两条出包命令 + NDK/Xcode 版本 + 本机踩的坑写成
docs/build-notes.md(22 课的"可复现构建"基础)。
验收门禁:能不看笔记说清 Android 与 iOS 两个产物的形状与各自要求;能说出模拟器片缺失时的报错长什么样;能在任意一端独立完成"编库 → 打包装 → 最小调用"。
✅ 本节小结
- 两种产物 :Android 要
jniLibs/<abi>/lib*.so(cdylib);iOS 要xcframework里嵌.a(staticlib); - Android :
cargo ndk -t ... -o jniLibs build --release→ 拷目录/打 AAR → Kotlin 挂起函数 + Compose 壳;logcat + backtrace 调跨层问题; - iOS :
rustup target add→ 多 target 编.a→xcrun xcframework create→ SPM/手动接入 →@MainActor壳 + SwiftUI; - 纪律 :release 构建、
abiFilters/target 按需、主线程不回传重活、ATS/隐私清单上架前核对; - 联调顺序:core 测试 → Python 冒烟 → 单端最小冒烟 → 真实用例 → 断网演练;
- 不变资产:core 零改动,两条管线只负责"编成平台要的形状"。
下一课预告 :第 22 课《一键多平台与工程收尾》------把两条手工管线固化成 scripts/build-all.sh 与 CI 矩阵、补上桌面/Web 的快速扩展、发布前 checklist(release 优化、strip、包体积、网络策略、日志上报),最后给出本课程的知识回顾图与结课作业。学完这门课,你会拥有一个"一份核心、多端出包"的完整可交付工程。