环境: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 准备交叉编译工具链
# 1. Rust target(按需选 ABI)rustup targetaddaarch64-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 包装器cargoinstallcargo-ndk# 4. 让 cargo-ndk 找到 NDKexportANDROID_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
# 在 ffi-bindings crate 目录执行:-o 直接按 ABI 目录结构输出cargondk-tarm64-v8a-tx86_64-o../android-out/jniLibs build--release# 产物形状(Android 期望的 jniLibs 布局):# android-out/jniLibs/# ├── arm64-v8a/libmy_ai_ffi.so# └── x86_64/libmy_ai_ffi.so# 验证产物确实是"Android 库"而非主机库fileandroid-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 工程
# 绑定要从"某个架构的产物"里读 ABI 元数据(任选一个已编好的即可)uniffi-bindgen generate src/lib.rs\--library../target/aarch64-linux-android/release/libmy_ai_ffi.so\--languagekotlin --out-dir../android-out/kotlin# 产物:android-out/kotlin/my_ai_ffi/(含 .kt 绑定与元数据)接入工程:
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 直接引。
// Android Library 的 build.gradle.kts 关键项android{defaultConfig{ndk{abiFilters+=listOf("arm64-v8a","x86_64")}// 只打进这两个 ABI}}21.2.4 Kotlin 壳:协程 + 主线程回 UI
// 用 20 课的门面模式包住 raw 绑定classAiRepository(dbPath:String,systemPrompt:String){privatevalraw=AiAssistant(dbPath,systemPrompt)// uniffi 生成privatevalscope=CoroutineScope(SupervisorJob()+Dispatchers.Main)funask(sessionId:String,question:String,onText:(String)->Unit,// 回调都切回主线程onError:(UiError)->Unit,){scope.launch{try{valanswer=raw.ask(sessionId,question)// uniffi 映射的挂起函数onText(answer)}catch(e:ApiError){onError(uiError(e))// 20 课话术映射表}}}}最小 Compose 壳(只做"发命令 + 渲染"):
@ComposablefunChatScreen(repo:AiRepository,sessionId:String){vartextbyremember{mutableStateOf("")}varquestionbyremember{mutableStateOf("")}Column{Text(text)OutlinedTextField(question,{question=it})Button(onClick={repo.ask(sessionId,question,onText={text+=it},onError={})}){Text("发送")}}}21.2.5 Android 调试三板斧
# 1. 跨层日志:Rust 侧 println!/tracing 会进 logcatadb logcat|grep-imy_ai_ffi# 2. Rust panic 栈adb shell setprop debug.rust.backtrace1# 3. .so 找不到:UnsatisfiedLinkError → 检查 jniLibs 目录名/ABI 拼写/abiFilters21.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 构建产物
# 必须在 macOS + Xcode。先装 targetrustup targetaddaarch64-apple-ios# 真机rustup targetaddaarch64-apple-ios-sim# Apple Silicon 模拟器rustup targetaddx86_64-apple-ios# Intel 模拟器(可选)# 编静态库(iOS 用 staticlib,链接进 App)cargobuild-pmy_ai_ffi--release--targetaarch64-apple-ioscargobuild-pmy_ai_ffi--release--targetaarch64-apple-ios-sim# 打成 xcframework(真机 + 模拟器两片,Xcode 运行时自动挑)xcrun xcframework create\-librarytarget/aarch64-apple-ios/release/libmy_ai_ffi.a\-librarytarget/aarch64-apple-ios-sim/release/libmy_ai_ffi.a\-outputios-out/MyAiCore.xcframework⚠️ 现代标准是xcframework(可同时含真机+模拟器片),
lipo合并多架构属于旧做法;Windows 编不了 iOS,CI 用 macOS runner(22 课给 workflow)。
21.3.2 生成 Swift 绑定并接入 Xcode
uniffi-bindgen generate src/lib.rs\--librarytarget/aarch64-apple-ios/release/libmy_ai_ffi.a\--languageswift --out-dir ios-out/swift# 产物:my_ai_ffi.swift + FFI 头/模块文件接入两种方式:
方式 A(手动):把 .xcframework 拖进 Target → General → Frameworks; 把生成的 .swift 拖进工程 方式 B(SPM 封装,推荐):建 Swift Package,把 .xcframework 作为 binary target、 Swift 源码作为普通 target → 多 App 复用 + 版本管理更干净// Swift 壳:把 20 课的 @MainActor 门面接上 raw 绑定importMyAiCore// SPM 包名@MainActorfinalclassAssistantViewModel:ObservableObject{privateletassistant:AiAssistant@Publishedvartext=""@PublishedvarerrorText:String?init()throws{assistant=tryAiAssistant(dbPath:Self.dbPath(),systemPrompt:"你是课程助教")}funcask(question:String){Task{do{// async 绑定在后台跑完,回主线程更新 @Publishedtext+=tryawaitassistant.ask(sessionId:sessionId,question:question)}catchleteasApiError{errorText=Self.friendly(e)// 20 课话术映射}catch{errorText="未知错误"}}}}最小 SwiftUI 壳:
structContentView:View{@StateObjectprivatevarvm:AssistantViewModel@Stateprivatevarinput=""varbody:someView{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 双端联调套路(推荐顺序)
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、包体积、网络策略、日志上报),最后给出本课程的知识回顾图与结课作业。学完这门课,你会拥有一个"一份核心、多端出包"的完整可交付工程。