与 iOS(xcframework))
环境Android 部分 Win/mac/Linux 皆可iOS 部分必须 macOS Xcode21.1 这节课解决什么问题第 19 课生成了绑定第 20 课定好了封装模式。本课做最终交付——把同一份 core 打进两个移动平台的标准包AndroidRust 编成 .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.soiOS 要的是xcframework/平台/libxxx.a。理解形状后无论用脚本、Xcode、Gradle 还是 CI都是把文件放到对的位置。21.2 Android 线.so → AAR → Kotlin21.2.1 准备交叉编译工具链# 1. Rust target按需选 ABIrustup targetaddaarch64-linux-android armv7-linux-androideabi\x86_64-linux-android i686-linux-android# 2. NDKAndroid Studio SDK Manager 安装记住路径# 命令行 NDKhttps://developer.android.com/ndk/downloads# 3. cargo-ndkcargo 的 NDK 包装器cargoinstallcargo-ndk# 4. 让 cargo-ndk 找到 NDKexportANDROID_NDK_HOME~/Library/Android/sdk/ndk/版本号ABI 与 target 对照ABItarget覆盖设备arm64-v8aaarch64-linux-android现代手机/平板主力armeabi-v7aarmv7-linux-androideabi老 32 位机可选x86_64x86_64-linux-android模拟器/x86 设备调试用x86i686-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 ...⚠️ 一定要--releasedebug 的 .so 巨大且慢ffi-bindings/Cargo.toml用crate-type [cdylib, staticlib]——Android 走 cdylib.soiOS 走 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{abiFilterslistOf(arm64-v8a,x86_64)}// 只打进这两个 ABI}}21.2.4 Kotlin 壳协程 主线程回 UI// 用 20 课的门面模式包住 raw 绑定classAiRepository(dbPath:String,systemPrompt:String){privatevalrawAiAssistant(dbPath,systemPrompt)// uniffi 生成privatevalscopeCoroutineScope(SupervisorJob()Dispatchers.Main)funask(sessionId:String,question:String,onText:(String)-Unit,// 回调都切回主线程onError:(UiError)-Unit,){scope.launch{try{valanswerraw.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,{questionit})Button(onClick{repo.ask(sessionId,question,onText{textit},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] strip21.3 iOS 线xcframework → Swift21.3.1 构建产物# 必须在 macOS Xcode。先装 targetrustup targetaddaarch64-apple-ios# 真机rustup targetaddaarch64-apple-ios-sim# Apple Silicon 模拟器rustup targetaddx86_64-apple-ios# Intel 模拟器可选# 编静态库iOS 用 staticlib链接进 Appcargobuild-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 编不了 iOSCI 用 macOS runner22 课给 workflow。21.3.2 生成 Swift 绑定并接入 Xcodeuniffi-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 拖进工程 方式 BSPM 封装推荐建 Swift Package把 .xcframework 作为 binary target、 Swift 源码作为普通 target → 多 App 复用 版本管理更干净// Swift 壳把 20 课的 MainActor 门面接上 raw 绑定importMyAiCore// SPM 包名MainActorfinalclassAssistantViewModel:ObservableObject{privateletassistant:AiAssistantPublishedvartextPublishedvarerrorText:String?init()throws{assistanttryAiAssistant(dbPath:Self.dbPath(),systemPrompt:你是课程助教)}funcask(question:String){Task{do{// async 绑定在后台跑完回主线程更新 Publishedtexttryawaitassistant.ask(sessionId:sessionId,question:question)}catchleteasApiError{errorTextSelf.friendly(e)// 20 课话术映射}catch{errorText未知错误}}}}最小 SwiftUI 壳structContentView:View{StateObjectprivatevarvm:AssistantViewModelStateprivatevarinputvarbody: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 TargetRust 侧无要求绑定要求 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*.soiOS 链接一堆 undefined symbol编了 cdylib 忘编 staticlibcrate-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/ 绑定 KotlinassembleRelease后解包 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.md22 课的可复现构建基础。验收门禁能不看笔记说清 Android 与 iOS 两个产物的形状与各自要求能说出模拟器片缺失时的报错长什么样能在任意一端独立完成编库 → 打包装 → 最小调用。✅ 本节小结两种产物Android 要jniLibs/abi/lib*.socdylibiOS 要xcframework里嵌.astaticlibAndroidcargo ndk -t … -o jniLibs build --release→ 拷目录/打 AAR → Kotlin 挂起函数 Compose 壳logcat backtrace 调跨层问题iOSrustup target add→ 多 target 编.a→xcrun xcframework create→ SPM/手动接入 →MainActor壳 SwiftUI纪律release 构建、abiFilters/target 按需、主线程不回传重活、ATS/隐私清单上架前核对联调顺序core 测试 → Python 冒烟 → 单端最小冒烟 → 真实用例 → 断网演练不变资产core 零改动两条管线只负责编成平台要的形状。下一课预告第 22 课《一键多平台与工程收尾》——把两条手工管线固化成scripts/build-all.sh与 CI 矩阵、补上桌面/Web 的快速扩展、发布前 checklistrelease 优化、strip、包体积、网络策略、日志上报最后给出本课程的知识回顾图与结课作业。学完这门课你会拥有一个一份核心、多端出包的完整可交付工程。