拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

【AI开发之Rust】第 21 课:双端集成与出包 —— Android(.so→AAR)与 iOS(xcframework)

【AI开发之Rust】第 21 课:双端集成与出包 —— Android(.so→AAR)与 iOS(xcframework)

环境: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 对照:

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 ...

⚠️ 一定要--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 拼写/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 构建产物

# 必须在 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 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*.so
iOS 链接一堆 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 📝 动手练习

  1. Android 出包:装 target + cargo-ndk,编出 arm64-v8a 的.so,用file/readelf验证架构;把 19 课的 Python 冒烟换成"Android 工程里调listSessions()"。
  2. AAR 演示:建一个 Android Library 模块收jniLibs/+ 绑定 Kotlin,assembleRelease后解包 AAR 确认.so与类都在。
  3. Compose 壳:实现 21.2.4 的 ChatScreen,能发问并展示本地历史(不接真实 LLM 也可用 core 的 Echo/Fake 客户端)。
  4. iOS 出包:macOS 上编真机+simulator 两个静态库并打成 xcframework;用 SPM 封装接入一个空 SwiftUI 工程。
  5. iOS 冒烟:SwiftUI 里调listSessions创建会话并打印 id;再跑通一次流式问答(LlmSession轮询版)。
  6. 断网演练:双端各验一次 20 课的离线策略(历史可看、发送降级、重试可用)。
  7. 记录手册:把两条出包命令 + 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、包体积、网络策略、日志上报),最后给出本课程的知识回顾图与结课作业。学完这门课,你会拥有一个"一份核心、多端出包"的完整可交付工程。

返回列表