ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

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

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

从写 Rust 代码到真正把 .so 塞进 Android、把 xcframework 塞进 iOS,中间隔着一整条工具链、链接器、ABI 和打包规范的鸿沟。这堂课我把双端集成的完整路径梳理了一遍:Android 端从 jni 接口到 AAR 打包,iOS 端从 C FFI 到 xcframework 生成,最后讲清楚我在两端联调时踩过的那些坑。如果你正在做 AI 推理、数据预处理或者任何需要 Rust 高性能模块的移动端交付,这篇可以直接照着走。

先说结论:Rust 写业务逻辑只是第一步,真正的工程量在“跨语言边界”和“出包产物”上。Android 要的是 AAR 里裹着 jniLibs/*.so,iOS 要的是 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 支持 Android(aarch64-linux-android/armv7-linux-androideabi)和 iOS(aarch64-apple-ios/x86_64-apple-ios/aarch64-apple-ios-sim),一份源码,各自编译成对应平台的二进制。这跟 Kotlin Multiplatform 的思路不一样——Rust 不提供业务框架,而是提供一个“可以用 FFI 调用的高性能计算内核”。

第三是二进制体积和启动性能可控。Rust 的 release 构建经过opt-level="z"和lto=true优化后,一个独立的 .so 或 .a 往往只有几百 KB 到几 MB。移动端对包体积很敏感,AI 模块做小,留给模型文件的空间就更大。

这选的代价是:调用双方必须通过 FFI 边界,字符串、数组、回调都要做一层序列化和指针转换。我的目标就是把这一层做得足够薄,让 AI 模块内部可以专注计算逻辑,而把 Java/Kotlin 和 Swift 的类型系统“翻译”工作收敛在接口层。

1.2 两端交付形态的核心差异:JNI 动态链路 vs C FFI 静态链路

Android 和 iOS 虽然都是用 C ABI 作为 Rust 和其他语言之间的“通用语言”,但工程形态完全不同。

Android 用的 JNI(Java 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 在编译时自动挑合适的架构。

表格对比更直观:

维度AndroidiOS
Rust 产物.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("couldn't 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("couldn't create int array"); env.set_int_array_region(&array, 0, &tokens) .expect("couldn't 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 target:

rustup 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 level,28 或 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 = "debuginfo"

opt-level = "z"是优化体积;panic = "abort"还能进一步减小体积。这里要注意:如果 Rust 侧存在可能 panic 的 FFI 入口,panic = "abort"相当于让整个进程直接 abort,而不走 unwinding。所以我在入口函数里用catch_unwind把所有逻辑兜住,这样 panic 不会穿过 FFI 边界,进程也不会崩,最坏情况是错误码归 -1。

3.2 用 cargo-ndk 一键产出多 ABI 的 .so

如果独立项目追求效率,直接上cargo-ndk:

cargo 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 默认会打包所有 ABI,APK 体积变大是一回事,更重要的是某些 ABI(比如 mips)在真实设备上根本运行不了。

加载 .so 的位置也很有讲究。JNI 规范里要求System.loadLibrary在类初始化的时候被调用,比如静态代码块里:

public class MyRustLib { static { System.loadLibrary("mycore"); } public static native int[] tokenize(String text); }

不要放在构造函数里,更不要在每次调用前反复 load。多线程环境下,多个线程同时进入 loadLibrary 驱动,会偶发错误。另外一个老坑:你的 App 如果引用了其他第三方库,它们内部可能也带 so,JNI 库名冲突会导致加载失败。

另外一个常见问题是 minSdk 与 JNI 函数签名无关,但和 .so 的 ELF 版本有关。如果你编的 so 里面引用了高版本 Android 才有的符号,在低版本系统上加载时会报UnsatisfiedLinkError。保持 minSdk 26 以上基本可以避开大多兼容性问题。

4. iOS 实操:多架构静态库与 xcframework 生成全流程

4.1 iOS 平台架构解析:真机、模拟器和 Rosetta 的取舍

iOS 的架构体系比 Android 简单,但有一个特殊点:模拟器。目前的模拟器分两种:Apple Silicon Mac 上跑的是aarch64-apple-ios-sim,Intel 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 自动处理。

添加 target:

rustup 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 打包 xcframework,Xcode 集成时如何选择切片

早期 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-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

这个命令把三种切片的静态库和它们的公共头文件包在同一个 xcframework 目录里。Xcode 在编译时自动识别当前 target 是 device 还是 simulator,并链接对应切片。集成到 Xcode 时,直接把 mycore.xcframework 拖入 target 的 Frameworks 目录,然后在 Build Settings 的FRAMEWORK_SEARCH_PATHS加上它的路径。

在 Xcode 的 Link Binary With Libraries 里加上 MyCore.xcframework,Swift 侧直接 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 里的文件先解压到缓存目录,再把缓存路径传给 Rust;iOS 端把 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 是否 catch?
Read 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或者Option<&T>来保护,不要裸用*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 默认不支持 Bitcode,Xcode 编译报错时把 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,如果 panic=abort,进程直接终止,连 catch 的机会都没有。最好的方案是:

  • 在Cargo.toml里设置panic = "unwind"用于开发,release 阶段设置panic = "abort"但所有 FFI 入口都用 catch_unwind 包裹;
  • 在入口函数最外层用 catch_unwind 保护,返回一个错误码;
  • 内部逻辑不要使用 unwrap、expect、panic!,全部用 Result 传播。

我在实际项目里把 unwrap 这种调用写死在 clippy 的 deny 列表里,防止新代码引入 panic:

cargo 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 的交叉编译不互相依赖,流水线大致分为:

  1. Rust lint + 单元测试
  2. 并行 job:Android 三 ABI 构建 + iOS 三 slice 构建
  3. 汇聚:打 AAR / 生成 xcframework
  4. 上传产物

举个 GitHub Actions 的关键片段:

jobs: android: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable with: targets: aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android - uses: nttld/setup-ndk@v1 id: ndk with: ndk-version: r25c - run: cargo ndk -t arm64-v8a -t armeabi-v7a -t x86_64 -o out/jniLibs build --release

iOS 构建必须有 macOS 环境的 runner,macos-latest或者自建 mac 节点都行。注意dtolnay/rust-toolchain的targets参数一次同时添加所有 target,省掉后面手动 rustup target add。

6.3 版本规范化:Rust crate 版本、AAR 版本和 xcframework 版本如何对齐

双端集成最怕版本漂移:Android 上跑到 1.2.0 的 Rust 核心,iOS 上还是 1.1.0,AI 模型输入输出就可能对不上。我在项目里做了一个约定:

  • 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 错误。做集成之前先跑一遍依赖树分析,大概率能避开后面一半的坑。

返回列表