首页 / 资讯中心 / 文章详情

Rust移动端集成:从JNI/FFI到AAR/xcframework完整指南

Rust移动端集成:从JNI/FFI到AAR/xcframework完整指南 ★ FEATURED ARTICLE
从写 Rust 代码到真正把 .so 塞进 Android、把 xcframework 塞进 iOS中间隔着一整条工具链、链接器、ABI 和打包规范的鸿沟。这堂课我把双端集成的完整路径梳理了一遍Android 端从 jni 接口到 AAR 打包iOS 端从 C FFI 到 xcframework 生成最后讲清楚我在两端联调时踩过的那些坑。如果你正在做 AI 推理、数据预处理或者任何需要 Rust 高性能模块的移动端交付这篇可以直接照着走。先说结论Rust 写业务逻辑只是第一步真正的工程量在“跨语言边界”和“出包产物”上。Android 要的是 AAR 里裹着 jniLibs/*.soiOS 要的是 xcframework 里躺着不同架构的静态库或动态库两端各有一套 ABI 体系但 Rust 源码可以完全共用。这就是这门课的核心逻辑——用 Rust 的统一实现换双端一致的运行行为省掉两套原生代码的重复开发和维护。1. 项目整体设计与双端底层逻辑拆解1.1 为什么 AI 模块用 Rust而不是 Kotlin/Java 或 Swift/ObjC我的场景是这样的底层的 tokenizer 和 embedding 计算用 C 写了一版效果没问题但跨端维护成本太高。Android 端 JNI 调 C、iOS 端 bridge 调 C两边签名靠手写头文件维护AI 模型和推理 runtime 升级一次就要两端同步改一轮。后来把核心模块用 Rust 重写之后整个交付逻辑清晰了很多。Rust 在 AI 模块里的核心优势用大白话讲有三点第一是内存安全与计算性能能同时拿到。AI 推理里大量高频的向量运算、矩阵乘、字符串编码C 做起来要小心越界和悬垂指针Rust 在保证内存安全的条件下做了零成本抽象循环展开、SIMD 优化这些都能直接享受。用criterion做基准测试同样的 tokenizer 逻辑Rust 版本和 C 版本性能在同一量级但我几乎不用再花时间追内存泄漏。第二是跨平台能力。Rust 通过 target triple 支持 Androidaarch64-linux-android/armv7-linux-androideabi和 iOSaarch64-apple-ios/x86_64-apple-ios/aarch64-apple-ios-sim一份源码各自编译成对应平台的二进制。这跟 Kotlin Multiplatform 的思路不一样——Rust 不提供业务框架而是提供一个“可以用 FFI 调用的高性能计算内核”。第三是二进制体积和启动性能可控。Rust 的 release 构建经过opt-levelz和ltotrue优化后一个独立的 .so 或 .a 往往只有几百 KB 到几 MB。移动端对包体积很敏感AI 模块做小留给模型文件的空间就更大。这选的代价是调用双方必须通过 FFI 边界字符串、数组、回调都要做一层序列化和指针转换。我的目标就是把这一层做得足够薄让 AI 模块内部可以专注计算逻辑而把 Java/Kotlin 和 Swift 的类型系统“翻译”工作收敛在接口层。1.2 两端交付形态的核心差异JNI 动态链路 vs C FFI 静态链路Android 和 iOS 虽然都是用 C ABI 作为 Rust 和其他语言之间的“通用语言”但工程形态完全不同。Android 用的 JNIJava Native Interface。Rust 编译产物是.so动态库JVM 通过System.loadLibrary(xxx)在运行时加载它。JNI 要求函数名必须按照规范签名比如Java_com_example_MyClass_nativeMethod还要处理 JNIEnv 指针、jobject 引用这些东西。这套机制足够灵活但动态库的初始化时机、不同 ABI 目录arm64-v8a、armeabi-v7a、x86_64的兼容性都要在出包时谨慎处理。iOS 走的是标准 C 符号链接。如果 Rust 编成.a静态库链接进 App 可执行文件里就不涉及运行时动态加载如果编成.dylib还要处理签名和 embed 的问题。实践上首选静态库配合 xcframework 把模拟器和真机切片打包在一起Xcode 在编译时自动挑合适的架构。表格对比更直观维度AndroidiOSRust 产物.so 动态库.a 静态库推荐语言边界机制JNIC FFI / ObjC bridge架构切片arm64-v8a 等device simulator集成产物AARxcframework调用方式loadLibrary JNI 签名Header 声明 链接符号理解了这两条链路后面每一步操作都是顺着这条主线“填空”。2. JNI 接口层设计与跨语言数据传递2.1 接口层该暴露什么、藏什么一个 AI 推理模块的接口设计我接手的项目里Rust 核心模块承担了三个职责加载 tokenizer 词表、字符串编码成 token IDs、调用 ONNX runtime 做推理。Android/iOS 上层需要拿到的是 int 数组token IDs、String解码结果以及状态码成功/失败。我的设计原则是“一个公开函数只干一件大事返回值尽量是简单类型”。拿 tokenizer 举例Java 层需要的就是String[] tokenize(String text)或者更高效的int[] tokenize(String text)——把字符串转换成一组 id。在 JNI 层我不会直接返回 Java 对象而是返回一个jlong指针指向 Rust 侧一个常量字符串数组或字节数组然后由 JNI 封装层把它转换成 Java 数组。为什么这么做跨语言边界最怕的就是对象生命周期管理Java 持有 Rust 分配的内存如果不主动释放就是泄漏让 JNI 层每次调用时把数据拷贝到 Java 数组内存所有权还是归 Rust 管一次性释放简单可靠。下面是 rust 侧核心接口的骨架use jni::objects::{JClass, JString}; use jni::sys::{jbyteArray, jintArray, jlong}; use jni::JNIEnv; #[no_mangle] pub extern system fn Java_com_example_ai_MyRustLib_tokenize( env: JNIEnv, _class: JClass, input: JString, ) - jintArray { let input_str: String env .get_string(input) .expect(couldnt get java string) .into(); let tokens my_core::tokenize(input_str); // 拷贝结果到 jintArray let len tokens.len() as i32; let array env.new_int_array(len).expect(couldnt create int array); env.set_int_array_region(array, 0, tokens) .expect(couldnt set array region); array }这个函数表面上是 tokenize但它在 ABI 层面的真实身份是 JNI 入口参数里带着 JNIEnv 指针。这里的extern system很关键它保证符号按 C 调用约定导出而不是 Rust 的加壳约定。Java 层调用时传入一个 String拿到一个 int[]整个过程走的是 JNI 的 GetStringUTFChars / NewIntArray 的底层机制但 Rust 侧封装好了不需要手动碰 C 字符串指针。2.2 从 Rust 返回字符串、数组和错误码时容易忽略的细节字符串跨语言传递是我踩过最多坑的地方。JNI 的get_string拿到的是JString转成 Rust String 是拷贝操作这个开销在 tokenize 这种高频函数上积少成多。等成本优化后我会在接口层做一个缓冲复用如果输入长度在某个范围内直接复用 JNI 侧的一块固定缓冲区减少 JNI 调用的分配次数。返回字符串时我习惯返回jstring而不是*const c_char。原因很简单Java 层原生处理 String 最方便JNI 的env.new_string()可以直接包装 Rust 字符串。但是要注意new_string接收的是 JNI 的修改过的 UTF-8 字符串碰到超长字符串和特殊字符时要谨慎尽量先用 Java 侧做编码转换保证数据一致。错误码统一用整数0 表示成功负数表示不同的错误类型。比如 tokenizer 加载失败返回 -1模型推理超时返回 -2参数错误返回 -3。Java 层和 Swift 层各自写一个枚举映射不需要跨语言抛异常——跨语言抛异常这件事在 JNI 里很难处理干净在 Swift 侧更麻烦。#[no_mangle] pub extern system fn Java_com_example_ai_MyRustLib_getStatus( env: JNIEnv, _class: JClass, ) - jint { my_core::engine_status() as i32 }此外Rust 的 panic 默认会 unwinding越过了 FFI 边界是未定义行为这在移动端是致命的。一定要在入口函数里设置 panic 捕获#[no_mangle] pub extern system fn Java_com_example_ai_MyRustLib_tokenize(...) - jintArray { std::panic::catch_unwind(|| { // 原有逻辑 }) .unwrap_or_else(|_| { // 返回空数组或错误码 }) }这一条几乎所有的入门教程都没讲但缺了它生产环境的崩溃率直接上升一个量级。2.3 iOS 侧 C FFI 桥接的接口对称性iOS 侧没有 JNI 那套东西接口层要薄很多。我直接在 Rust 里导出一组 C 函数#[no_mangle] pub extern C fn tokenizer_load_model(path: *const c_char) - i32 { ... } #[no_mangle] pub extern C fn tokenizer_encode(text: *const c_char, out_ids: *mut i64, max_len: i32) - i32 { ... } #[no_mangle] pub extern C fn tokenizer_free() { ... }Rust 侧把*const c_char转成str时会检查空指针和 UTF-8 合法性靠std::ffi::CStr。输出参数out_ids指向的缓冲区由 Swift 侧分配Rust 只往里写不负责释放避免所有碰内存所有权的问题。Swift 侧用_cdecl或者直接 import 头文件来声明这些函数然后在Tokenizer.swift里封装一下把 C 指针操作收敛到一个文件里上层业务完全不感知 C 的存在。这就是对称性Android 的 JNI 封装层和 iOS 的 Swift 封装层虽然在语法上天差地别但设计模式都是“把 FFI 边界封装在一个模块里上层只面对普通的类型”。3. Android 实操从 .so 到 AAR 的完整构建与集成3.1 rustup 目标添加与 .cargo/config.toml 交叉编译配置在 Android 上编 Rust第一步要给 rustup 添加 Android targetrustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android三个 target 分别对应 arm64-v8a、armeabi-v7a、x86_64。x86_64 通常只用于模拟器但 CI 里跑测试会用到。这里的关键点是 NDK 工具链Rust 在编译时会调用 NDK 里的 clang 和链接器需要 ndk 路径。我在项目的.cargo/config.toml里写死[target.aarch64-linux-android] linker /Users/me/Library/Android/sdk/ndk/25.2.9519653/toolchains/llvm/prebuilt/darwin-x86_64/bin/aarch64-linux-android24-clang [target.armv7-linux-androideabi] linker /Users/me/Library/Android/sdk/ndk/25.2.9519653/toolchains/llvm/prebuilt/darwin-x86_64/bin/armv7a-linux-androideabi24-clang [target.x86_64-linux-android] linker /Users/me/Library/Android/sdk/ndk/25.2.9519653/toolchains/llvm/prebuilt/darwin-x86_64/bin/x86_64-linux-android24-clang路径里的-android24是 API level28 或 21 都行但要和你 app 的 minSdk 对齐推荐。如果你的 minSdk 是 26最好编一个 26 的 clang避免运行时链接到不存在的 libc 符号。强烈建议不要手动写路径用环境变量ANDROID_NDK_HOME替代脚本里动态拼链接器路径否则换个 NDK 版本整个工程就废了。构建命令直接cargo build --release --target aarch64-linux-android生成物在target/aarch64-linux-android/release/libmycore.so。体积控制注意Cargo.toml[profile.release] opt-level z lto true codegen-units 1 panic abort strip debuginfoopt-level z是优化体积panic abort还能进一步减小体积。这里要注意如果 Rust 侧存在可能 panic 的 FFI 入口panic abort相当于让整个进程直接 abort而不走 unwinding。所以我在入口函数里用catch_unwind把所有逻辑兜住这样 panic 不会穿过 FFI 边界进程也不会崩最坏情况是错误码归 -1。3.2 用 cargo-ndk 一键产出多 ABI 的 .so如果独立项目追求效率直接上cargo-ndkcargo install cargo-ndk cargo ndk -t arm64-v8a -t armeabi-v7a -t x86_64 -o ../android/src/main/jniLibs build --release这个命令会同时生成三种 ABI 的 .so并按 Android 约定的 jniLibs 目录结构放到指定路径下android/src/main/jniLibs/ ├── arm64-v8a/ │ └── libmycore.so ├── armeabi-v7a/ │ └── libmycore.so └── x86_64/ └── libmycore.so这种目录结构可以直接被 Gradle 识别如果项目是纯 Android 工程放到 src/main/jniLibs 下就完事了。如果 Rust 是独立工作区把 .so 文件拷贝过去即可。这里有个细节lib前缀不能丢。Android 的 System.loadLibrary 会自动补这层前缀但如果你的 .so 文件名不是lib开头就加载不到。3.3 手动拼 AAR把 jniLibs、manifest 和 ProGuard 规则塞进去如果不想依赖 Android Studio 的 Gradle 插件自动生成 AAR可以手动构造。AAR 本质上是一个 zip 包但里面要有特定的目录结构mycore.aar/ ├── AndroidManifest.xml ├── classes.jar ├── jni/ │ ├── arm64-v8a/ │ │ └── libmycore.so │ ├── armeabi-v7a/ │ │ └── libmycore.so │ └── x86_64/ │ └── libmycore.so ├── R.txt └── proguard.txt一个标准 AAR 必须有AndroidManifest.xml和classes.jar即使 Manifest 内容为空也要存在R.txt也是空的也可能被 Gradle 读取。手动 zip 时名字必须是.aar。比如我写了一个MyRustLib.java把它编译成 classes.jar 塞进 AAR上层 Module 通过compileOnly或api依赖这个 AAR就能直接调用MyRustLib.tokenize()。jni 目录下按 ABI 分文件夹Gradle 在最终打包 APK 时会根据主 App 的 abiFilters 自动选择包含哪些 .so。实际上我通常在项目里直接用 Android Library Module 来管理 Rust 产物而不是手动拼 AAR。流程是新建 Android Library 模块把 Rust 生成的 jniLibs 放进src/main/jniLibs然后在同一个模块里写 JNI 封装类最后 Gradle 的assembleRelease生成 AAR 给上层 App 或其他 Module 使用。3.4 Android Studio 集成abiFilters、minSdk 和 loadLibrary 的坑在上层 App 的 build.gradle 里android { defaultConfig { minSdk 26 ndk { abiFilters arm64-v8a, armeabi-v7a, x86_64 } } }abiFilters的作用是告诉 Gradle 最终 APK 里只保留指定 ABI 的 .so。如果不加Gradle 默认会打包所有 ABIAPK 体积变大是一回事更重要的是某些 ABI比如 mips在真实设备上根本运行不了。加载 .so 的位置也很有讲究。JNI 规范里要求System.loadLibrary在类初始化的时候被调用比如静态代码块里public class MyRustLib { static { System.loadLibrary(mycore); } public static native int[] tokenize(String text); }不要放在构造函数里更不要在每次调用前反复 load。多线程环境下多个线程同时进入 loadLibrary 驱动会偶发错误。另外一个老坑你的 App 如果引用了其他第三方库它们内部可能也带 soJNI 库名冲突会导致加载失败。另外一个常见问题是 minSdk 与 JNI 函数签名无关但和 .so 的 ELF 版本有关。如果你编的 so 里面引用了高版本 Android 才有的符号在低版本系统上加载时会报UnsatisfiedLinkError。保持 minSdk 26 以上基本可以避开大多兼容性问题。4. iOS 实操多架构静态库与 xcframework 生成全流程4.1 iOS 平台架构解析真机、模拟器和 Rosetta 的取舍iOS 的架构体系比 Android 简单但有一个特殊点模拟器。目前的模拟器分两种Apple Silicon Mac 上跑的是aarch64-apple-ios-simIntel Mac 上跑的是x86_64-apple-ios。第一次用 Rust 编 iOS 库的人百分之百会在这里迷路——你至少需要三种切片切片对应的 target场景真机 ARM64aarch64-apple-iosiPhone/iPad 真机模拟器 ARM64aarch64-apple-ios-simApple Silicon 上的模拟器模拟器 x86_64x86_64-apple-iosIntel Mac 上的模拟器Apple 已经被逼着抛弃 Intel Mac 了但 CI 上可能还有 Intel 的机器所以 CI 脚本里最好两种模拟器切片都编上然后用lipo -create或 xcframework 自动处理。添加 targetrustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios编静态库时不需要链接器配置因为 iOS 上 Rust 不产生动态库不让设置 dylib。如果是编 cdylib 用于测试另说正式集成一律staticlib。在 Cargo.toml 中设置 crate-type[lib] name mycore crate-type [staticlib, cdylib]编译时用cargo build --release --target aarch64-apple-ios生成的静态库在target/aarch64-apple-ios/release/libmycore.a。iOS 侧没有 JNI 那种运行时系统调用约束纯静态链接最省心签名、权限、隐私这些完全走 App 的正常流程。4.2 用 xcodebuild 打包 xcframeworkXcode 集成时如何选择切片早期 iOS 工程都是用 lipo 把多个架构合并成一个 fat 库但 Apple 在 Xcode 12 之后推荐用 xcframework因为它能正确表达每个切片对应的平台和架构而不是一个 fat 二进制里到处放。xcframework 的生成命令# 先生成三个静态库目录 # device cargo build --release --target aarch64-apple-ios # simulator arm64 cargo build --release --target aarch64-apple-ios-sim # simulator x86_64 cargo build --release --target x86_64-apple-ios然后把三个目录组织为device、sim-arm64、sim-x86_64三个文件夹每个文件夹里放头文件、模块映射和静态库。最方便的方式是直接用xcodebuild -create-xcframeworkxcodebuild -create-xcframework \ -library target/aarch64-apple-ios/release/libmycore.a \ -headers include \ -library target/aarch64-apple-ios-sim/release/libmycore.a \ -headers include \ -library target/x86_64-apple-ios/release/libmycore.a \ -headers include \ -output mycore.xcframework这个命令把三种切片的静态库和它们的公共头文件包在同一个 xcframework 目录里。Xcode 在编译时自动识别当前 target 是 device 还是 simulator并链接对应切片。集成到 Xcode 时直接把 mycore.xcframework 拖入 target 的 Frameworks 目录然后在 Build Settings 的FRAMEWORK_SEARCH_PATHS加上它的路径。在 Xcode 的 Link Binary With Libraries 里加上 MyCore.xcframeworkSwift 侧直接 import MyCore 就能调用公开函数。如果你的 xcframework 没设置BUILD_LIBRARY_FOR_DISTRIBUTION可能会出现模块找不到的问题一个临时解法是把头文件放到 Header Search Paths 里但更好的方式是让 Xcode 自动映射 module。4.3 Swift 侧封装 Rust 函数CChar 指针与 Data 的互操作Swift 调用 C 函数时字符串处理比较繁琐。我一般封装成import Foundation public enum Tokenizer { public static func encode(text: String) - [Int64] { let capacity 4096 var ids [Int64](repeating: 0, count: capacity) let count ids.withUnsafeMutableBufferPointer { buffer - Int32 in text.withCString { cString in tokenizer_encode(cString, buffer.baseAddress, Int32(capacity)) } } guard count 0 else { return [] } return Array(ids.prefix(Int(count))) } }重点是withCString和withUnsafeMutableBufferPointer这两个语法前者保证字符串在调用期间以 C 字符串形式存在后者保证 ids 数组的连续内存空间可以被 C 指针写入。这里面的调用不拷贝大块数据所以性能可控。对于模型路径Swift 侧把 Bundle 里的资源路径转成 C 字符串传给tokenizer_load_model。注意模型路径如果包含中文或特殊字符C 字符串的 UTF-8 编码要统一Swift 的fileSystemRepresentation是更安全的选择。4.4 资源文件双端路径解析Android asset 与 iOS Bundle 的统一逻辑AI 模块经常需要加载词表和模型文件双端的路径机制完全不同。Android 有 asset 和 file 两种路径通过context.getAssets()或者context.getFilesDir()拿到iOS 走Bundle.main.url(forResource:withExtension:)。我为了不把路径逻辑写死在 Rust 里设计了一个“路径注入”接口Rust 只认一个绝对路径字符串参数路径由上层根据平台注入。Android 端把 asset 里的文件先解压到缓存目录再把缓存路径传给 RustiOS 端把 Bundle 中的文件路径直接传给 Rust。这样做的好处是 Rust 的模型加载逻辑完全不感知双端差异。Android 端伪代码File modelFile new File(getCacheDir(), model.bin); copyAssetToFile(context, models/model.bin, modelFile); MyRustLib.loadModel(modelFile.getAbsolutePath());iOS 端if let path Bundle.main.path(forResource: model, ofType: bin) { tokenizer_load_model(path) }注意 Android 的 asset 路径经过 copy 后才变成真实文件路径iOS 的 Bundle 路径本身可以直接访问。这里我踩过的坑是 Android 某些低版本机型上 asset 解压到 cache 目录如果磁盘不足会静默失败所以最好在调用 Rust 前检查文件长度是否符合预期。5. 双端联调常见问题与排查清单5.1 Android 常见崩溃与 JNI 封装层的自查方法联调阶段最容易碰到的错误有几个高频类型整理成自查清单建议收藏对比。错误现象优先级排查方向UnsatisfiedLinkError: dlopen failed: library libmycore.so not found高.so 是否在正确 abi 目录abiFilters 是否排除UnsatisfiedLinkError: JNI_OnLoad not found高Rust 侧是否导出 JNI_OnLoad 符号java.lang.UnsatisfiedLinkError: No implementation found for native method高函数签名是否完全匹配包名/类名Process crashed with SIGSEGV紧急Rust 侧 panic 是否 catchRead an invalid pointer紧急跨语言传指针后是否悬垂No implementation found这类问题的排查先用javap -s查看生成的 JNI 签名再比对 Rust 的函数名。如果类在com.example.ai.MyRustLib函数名必须完全匹配Java_com_example_ai_MyRustLib_tokenize包名里的下划线不要漏内部类名用_00024转义。一个建议是让 JNI 函数不依赖类名直接提供一个JNI_OnLoad注册函数表#[no_mangle] pub extern system fn JNI_OnLoad(env: JNIEnv, _vm: JavaVM) - jint { let class env.find_class(com/example/ai/MyRustLib).unwrap(); // 在这里注册 native methods函数名可以自定义不再受命名规则约束 env.register_native_methods(class, [ JNINativeMethod::new(tokenize, ([I)[I, tokenize_impl), ]).unwrap(); JNI_VERSION_1_6 }这种方式更干净也更容易在多个类之间共享 Rust 函数实现。SIGSEGV 的排查大多数时候要么是 Rust 内部 panic 没 catch要么是空指针/悬垂指针问题。Rust 代码里指针操作用std::ptr::NonNull或者OptionT来保护不要裸用*const。5.2 iOS 联调时 Xcode 报 Undefined Symbols 的几种典型场景iOS 集成阶段最典型的就是链接错误。如果你看到Undefined symbol: _tokenizer_encode说明 Rust 静态库没有正确链接到 target或者编译 slice 不匹配。Xcode 的 Build Log 里用-target参数查看最后一步链接了哪个库。另一个典型问题是 Bitcode。从 Xcode 14 开始 Apple 移除了 Bitcode但早期工程里还留着ENABLE_BITCODE YES的配置。Rust 默认不支持 BitcodeXcode 编译报错时把 Build Setting 里的 ENABLE_BITCODE 设置为 NO。模拟器跑起来了真机却在这个函数上崩溃十有八九是 slice 不匹配。确保 xcframework 里 device slice 存在不能只在模拟器环境下编译通过就算完事。用lipo -info查看静态库架构lipo -info target/aarch64-apple-ios/release/libmycore.a如果只显示 arm64说明这个库只支持真机模拟器切过去会直接报架构错误。5.3 Rust 侧 panic 穿越 FFI 边界导致的隐蔽崩溃这个问题在 Android 和 iOS 上都会出现而且是所有坑里最隐蔽的。默认情况下 Rust 的 panic 会触发栈展开但如果跨过了 C ABI 边界栈展开会试图处理一个不属于 Rust 的栈帧结果通常是直接崩溃。前面提过的std::panic::catch_unwind方案可以兜住大部分 panic但 catch_unwind 只能捕获 Rust 的 panic如果 panicabort进程直接终止连 catch 的机会都没有。最好的方案是在Cargo.toml里设置panic unwind用于开发release 阶段设置panic abort但所有 FFI 入口都用 catch_unwind 包裹在入口函数最外层用 catch_unwind 保护返回一个错误码内部逻辑不要使用 unwrap、expect、panic!全部用 Result 传播。我在实际项目里把 unwrap 这种调用写死在 clippy 的 deny 列表里防止新代码引入 paniccargo clippy -- -D warnings -D clippy::unwrap_used养成这个习惯FFI 边界基本不会再出隐藏崩溃。5.4 符号冲突与 .so 加载顺序问题Android 上加载多个 .so 时如果两个库里有同名符号后加载的库可能覆盖先加载的符号导致 AI 模块意外调用到别的库里同名函数。这是极其隐蔽的 bug。排查思路用readelf -s libmycore.so | grep查看导出符号把所有 Rust 导出符号用仓库名加前缀比如mycore_tokenizer_encode避免全局面貌冲突。iOS 静态库不存在运行时冲突但链接时多个 .a 之间可能有重复符号Xcode 会直接报 duplicate symbol处理办法是把不需要的 obj 文件从库中剔除或者把重复的符号隐藏到 static 函数里。正常情况下 Rust 静态库里只有你会调用的符号但如果 crate 依赖引入了某些全局符号链接时会有惊喜。6. 出包自动化的工程化实践6.1 用 Justfile 或 Makefile 封装双端构建脚本避免复制粘贴命令手动敲构建命令试过一两次就够了后面一定会写脚本。我推荐 Justfile 或 Makefile# Makefile ANDROID_NDK : $(ANDROID_NDK_HOME) .PHONY: android ios xcframework android: cargo ndk -t arm64-v8a -t armeabi-v7a -t x86_64 -o ../android/src/main/jniLibs build --release ios: cargo build --release --target aarch64-apple-ios cargo build --release --target aarch64-apple-ios-sim cargo build --release --target x86_64-apple-ios xcframework: ios rm -rf MyCore.xcframework xcodebuild -create-xcframework \ -library target/aarch64-apple-ios/release/libmycore.a -headers include \ -library target/aarch64-apple-ios-sim/release/libmycore.a -headers include \ -library target/x86_64-apple-ios/release/libmycore.a -headers include \ -output MyCore.xcframework release: android xcframework echo release done实际项目里还会在脚本里做这些事检查ANDROID_NDK_HOME是否存在自动定位 NDK 版本用cargo test跑一遍 Rust 侧单元测试后再出包用git rev-parse --short HEAD把 commit hash 写进生成物文件名方便回溯上传产物到内部制品库AAR 走 maven 私服xcframework 直接打包上传或通过 SwiftPM 分发。6.2 CI 流水线里按架构并行构建显著缩短双端出包时间在 GitHub Actions 或自建 CI 上把各 target 构建分到不同 job 并行跑能省一半以上时间。Android 和 iOS 的交叉编译不互相依赖流水线大致分为Rust lint 单元测试并行 jobAndroid 三 ABI 构建 iOS 三 slice 构建汇聚打 AAR / 生成 xcframework上传产物举个 GitHub Actions 的关键片段jobs: android: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - uses: dtolnay/rust-toolchainstable with: targets: aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android - uses: nttld/setup-ndkv1 id: ndk with: ndk-version: r25c - run: cargo ndk -t arm64-v8a -t armeabi-v7a -t x86_64 -o out/jniLibs build --releaseiOS 构建必须有 macOS 环境的 runnermacos-latest或者自建 mac 节点都行。注意dtolnay/rust-toolchain的targets参数一次同时添加所有 target省掉后面手动 rustup target add。6.3 版本规范化Rust crate 版本、AAR 版本和 xcframework 版本如何对齐双端集成最怕版本漂移Android 上跑到 1.2.0 的 Rust 核心iOS 上还是 1.1.0AI 模型输入输出就可能对不上。我在项目里做了一个约定Rust crate 版本是唯一真源写在Cargo.toml的version字段。Android 的 library module 用gradle的version属性传参构建脚本自动从 Cargo.toml 读取版本写入 generated 文件。iOS 的 xcframework 产物目录里包含版本号MyCore-1.2.0.xcframework并且安装进 Xcode 后可以在 Info.plist 里写入版本描述。# 从 Cargo.toml 读取版本号 VERSION$(grep ^version Cargo.toml | sed s/.*\(.*\)/\1/)脚本把这个版本写入 Android 的 BuildConfig 和 iOS 的 Info.plist。两边在启动时上报版本号如果差异过大直接提示强制更新这是联调阶段省心省力的关键一步。模型文件也建议打上同版本号。AI 模块模型文件如果和 Rust 逻辑不是同一版本轻则推理结果差重则直接内存越界。我的做法是模型文件名带 hash构建脚本从模型服务器下载时自动匹配 hash避免“路径同名但内容不同”的事故。7. 实操心得与最后的小补充先说我最直观的感受Rust 双端集成这件事难度不在于 Rust 本身而在于对整个工具链的耐心。Android 的 JNI 签名、iOS 的 slice 组合、NDK 路径、Bitcode 关闭每一个环节单独拎出来都是半小时能解决的但串在一起就是好几个晚上。如果你第一次做建议按“一个平台先跑通再复制到另一个平台”的节奏来。不要一上来就双端并行不然出错时你会分不清是 Rust 逻辑问题还是集成问题。我第二次做这个方案时把整个流程简化成了两个 make 命令“make android” 和 “make xcframework”集成文档写了一页 A4 纸新同事半天就能跑通。你如果是在团队里推广 Rust 移动端方案完全可以按这个思路来先花两个晚上把脚本和文档沉淀好后面所有人的时间都会被省回来。一个小技巧分享给你如果你 Debug 阶段想在 Android Studio 里直接跑 Rust 侧单元测试不要只在 target 上编 .so在宿主机上跑cargo test会更舒服。Rust 侧业务逻辑和 JNI 解耦得越干净这个测试就越好用。接口层只有薄薄一层 JNI 语法而真正复杂的算法都在my_corecrate 里单元测试全跑在开发机上整个迭代速度会快到你不适应。最后提醒一个冷门但致命的事情iOS 静态库如果开启了某些 Rust crate 的std特性但它依赖了系统动态库例如libsqlite3或libz链接时你会看到怪异的 undefined symbol。这时候在 Xcode 里手动加对应的系统 framework 或-lz -lsqlite3链接参数就能修复。Android 也有类似问题target 平台缺系统库时会报 dlopen 错误。做集成之前先跑一遍依赖树分析大概率能避开后面一半的坑。
阅读完成 · 觉得有帮助?
咨询建站