
为什么要跨平台
- 减少人力成本,减少开发时间。
- 抹平多端在逻辑细节的实现差异,提高代码一致性,保证工程质量。
- 多个平台共享一套代码,后期产品维护简单。
目前常见的跨平台方案
C++
很多公司的跨平台移动基础库基本都有 C++ 的影子,如微信,腾讯会议,还有早期的 Dropbox,知名的开源库如微信的 Mars 等。好处是一套代码多端适配,但是需要大公司对 C++ 有强大的工具链支持,还需要花重金聘请 C++ 研发人员,随着团队人员变动,产品维护成本也不可忽视,所以 Dropbox 后期也放弃了使用 C++ 的跨端方案。
Rust + FFI
Rust 和对应平台的 FFI 封装。常见的方法如飞书和 AppFlow 是通过类似 RPC 的理念,暴露少量的接口,用作数据传输。好处是复杂度可控,缺点是要进行大量的序列化和反序列化,同时代码的表达会受到限制,比如不好表达回调函数。
Flutter
更适合于有 UI 功能的跨平台完整 APP 解决方案,不适用于跨平台移动端 SDK 的方案。
为什么用 Rust ?
开发成本
不考虑投入成本的话,原生方案在发布、集成和用户 Debug 等方面都会更有优势。但考虑到初创团队需要配置至少两名资深的研发人员来维护两套 SDK 需要面临成本问题。
Rust 有丰富的跨平台经验
不少团队用Rust 实现过跨平台的网络栈,如用 tokio 和 quinn 等高质量的 crate 实现了长连接的客户端和服务端。
安全稳定
- 作为应用的基础架构,基建 SDK 肩负了本地鉴权、APM、策略降级等职责,对 SDK 的稳定性要求更高。
- 原生移动端 SDK 一旦出现多线程崩溃的问题,难以定位和排查,需要较长的修复周期。
- Rust 的代码天生是线程安全的,无需依赖于丰富经验的移动端开发人员,也可以保证提供高质量、稳定的 SDK。
可行性调查
大型公司在移动端使用 Rust
- Google 在 2021 年将 Rust 引入 Android Rust in the Android platform
- Mozilla 使用 Rust 编写跨平台应用服务组件Firefox Application Services
- 飞书客户端非 UI 部分使用 Rust 跨平台实现
个人开发者在移动端的尝试 Rust 的案例
- Rust & cross-platform mobile development
- RustDesk 远程桌面应用
- 深度探索:前端中的后端
- Publish game on Android with Macroquad
- Rust on iOS and Mac Catalyst: A Simple, Updated Guide
什么是FFI?
在本系列的第一期(Rust 编程系列01 - 为什么选择 Rust)中我们曾介绍过:
为了和现有的生态系统良好地集成,Rust 支持非常方便且零成本的 FFI 机制,兼容 C-ABI,并且从语言架构层面上将 Rust 语言分成 Safe Rust 和 Unsafe Rust 两部分。
通过前面几期的学习,我们了解到了什么是 Rust,那么 FFI 又是什么呢?
FFI 概述
FFI(Foreign Function Interface)是这样一种机制:用一种编程语言写的程序能调用另一种编程语言写的函数(routines)。
FFI 有两种内涵。一种是是在当前正在使用的语言(host)中,调用由其它语言(guest)提供的库。第二种内涵与第一种方向相反,即,使用当前语言(host)写库,供其它语言(guest)调用。不过,后者不是任何语言都能做到的,有些语言即使能做,也会非常吃力。
FFI 的历史和现状
FFI 这个术语最早来自 Common Lisp 的规范。目前几乎所有严肃编程的语言都有提供 FFI 的支持,但大多数是单向功能。
不同语言称呼这种语言间调用的功能名字可能不同。Common Lisp、Haskell、Python、Rust 这些叫 FFI,Java 叫 JNI 或 JNA,还有一些其它语言叫 “绑定”。严格来说,FFI 与 绑定,意义并不相同,绑定可以理解为 FFI 中的一种实现。
不同语言实现 FFI 的方式不尽相同。有的语言,比如,要调用 C 库,必须用 C 语言,按那种语言的绑定规范,实现一个 C 项目,用 C 编译器编译并链接,生成库文件,再由这种语言调用(这种语言本身已经实现了加载其定义的规范 C 库的能力)。
有的语言,比如,Rust,要调用 C 库,不再需要使用 C 语言写绑定工程,而是直接使用 Rust 语言写。这样,就有个好处是,你不再需要掌握 C 语言的那么多的繁文缛节和工具链(但是还是必须懂 C 语言)。
FFI 调用原理
为什么不同的语言之间能互相调用呢?
我们知道,计算机的运算,最底层的数据/代码都是以二进制的形式存在。所有的语言在编译后,都会以二进制的形式去执行(即使编译后的代码为字节码,虚拟机在运行的时候,也会继续翻译成 CPU 认识的二进制指令)。这就为不同语言间的调用提供了可能性。
但是,可能归可能。二进制毕竟太底层了。没有大家一致认可的调用约定,那也是不可能互通的。于是,ABI(应用程序二进制接口) 就出现了。调用约定,类型表示和名称修饰这三者的统称,即是众所周知的应用二进制接口(ABI)。
试想,如果所有的语言在调用时都能认识同样一套 ABI 规范,那么就能完全畅通的调用了。可惜,世界不会像我们人为想象的那样干净。
在计算机技术发展的过程中,出现了各种 ABI 规范,它们有的看起来相似,但在具体编译器的实现上,又有细微不同。所以,这是一件很麻烦的事情。大体来说,有如下规范:
- cdecl
- syscall
- optlink
- pascal
- register
- stdcall
- fastcall
- thiscall
- winapi
- Intel ABI
- System V
等。详情可参考:X86调用约定。
而 Rust 目前支持如下 ABI 约定:
- stdcall
- aapcs
- cdecl
- fastcall
- vectorcall
- Rust
- rust-intrinsic
- system
- C
- win64
- sysv64
不过,值得庆幸的是,目前我们 IT 工业的基石,绝大部分是由 C 语言写成。于是自然而然,绝大多数库都遵循 cdecl(或 C)规范。所以我们可以专注于 C 规范来讨论问题。
FFI 的困难之处
FFI 实现起来,比想像的要复杂许多,困难体现在:
- 如果 host 语言(调用主动方)带 GC(垃圾收集器),而 guest 语言(调用被动方)不带,那么可能会在资源管理(创建,释放)上面造成一些问题,需要特别细致地处理;
- 复杂对象或类型,在映射到两边的时候,可能会有一些不协调甚至失真的现象;
- 两边要同时引用一个可变对象的时候,可能会遇到问题;
- 如果两边的语言都是运行在 VM 之上的语言,那么这两个语言之间的直接 FFI 非常困难甚至不可能;
- 类型系统/对象组合模型/继承机制等其它细节,可能在跨语言的时候,成为障碍;
- 其它。
所以,虽然都能做 FFI,但是不同语言实现 FFI 的困难程度是不同的。
哪些语言可以方便地对外提供 FFI 库支持
可惜,大部分语言只能单向地“索取”。目前所知,能(较方便地)对其它语言提供 FFI 库支持的语言有:
- C
- C++(通过定义 C 接口)
- Rust(通过使用 C 约定)
- Ada
- Fortran
偷懒的程序员
在开发的过程中,要一个一个对大量的 C/C++ 库写绑定来进行 FFI,毕竟是一项费时费力的活儿。聪明的程序员们就开始构想一些“通用”的方案,实现批量快速绑定。
SWIG
以下定义来自 https://zh.wikipedia.org/wiki/SWIG:
简单包装界面产生器(SWIG)是一个开源软件工具,用来将C语言或C++写的计算机程序或函式库,连接脚本语言,例如Lua, Perl, PHP, Python, R, Ruby, Tcl, 和其它语言,例如C#, Java, JavaScript, Go, D, OCaml, Octave, Scilab以及Scheme. 也可以输出成XML格式。
也就是说,使用了 SWIG 这套工具和规范,就可以直接在上层语言(动态语言居多)中调用 C/C++ 库了,省却大量烦恼。但在实际使用中,还会有一些细节问题,往往需要人工调整。所以也不是那么完美。
SWIG 官网:http://swig.org/ 。
Gnome 社区关于构建通用 GI 规范的理想和实践
Gnome/Gtk 那一帮理想主义青年,发明了 GI(GObject Introspection)。用于对基于 glib/gobject 生态的众多软件(C 代码库)自动生成完整的接口描述文件(及 typelib),然后其它语言只要实现了对 Gir 这一个标准的支持,那么就可以无缝调用所有经过 Gir 化处理的 C 库。而不再需要单独为每一个 C 库做绑定了。这样就大大简化了 FFI 接口项目的编写工作。
目前这一杰出创意的重量级工作成果有 cairo, pango, gtk 等库。
更多信息请参考:https://gi.readthedocs.io/en/latest/。
另一种思路——基于字节码的平台级路线
语言间的相互调用,历史的发展提供了另一条路线:建立一个共同的字节码平台,这个平台之上的所有语言,皆可便捷地相互调用。
JVM 平台语言之间的 FFI
Java 发展到现在,已经形成了一个强大的 JVM 生态。JVM 平台上有大量的新语言产生,比如 Scala, Clojure, JRuby, Jython 等。这些语言前端不同,但是共享同一套 JVM 字节码和调用规范。因此,这些语言和 Java 之间,以及这些衍生语言之间,能比较容易地实现相互调用。
JVM 平台的缺点在于,其生态中的成果,被局限在了 JVM 平台内,无法(或很难)被其它语言平台所享用。
WASM 平台的 FFI
Web Assembly(WASM)是一个新的字节码平台,其势头发展很猛。其有着比 JVM 平台更大的野心和联盟。因为是新设计的字节码,故其在设计的时候,就对 JVM 平台的一些问题做了规避(这方面可 Google 查阅相关资料)。
目前几乎所有主流语言都已实现将 WASM 作为编译目标,并且有相当一部分语言能够加载 WASM 库文件,调用其中的函数。不同的语言编译出的 WASM 效能和体积大小也是不同的。目前来看,C、C++、Rust 这些非 GC 语言能够编译出最精简,执行效率最高的 WASM 字节码。
WASM 的规范还在快速完善中。
UniFFI-rs
UniFFI-rs 是 Mozilla 出品,应用在 Firefox mobile browser 上的 Rust 公共组件,用于自动生成 Rust 库与不同编程语言之间的绑定,UniFFI-rs 有以下特点:
安全
- UniFFI-rs 的设计目标第一条就是“安全优先”,所有暴露给调用语言的 Rust 生成的方法,都不应该触发未定义的行为。
- 所有暴露给外部语言的 Rust Object 实例都要求是 Send + Sync。
简单
- 不需要使用者去学习 FFI 的使用
- 只定义一个 DSL 的接口抽象,框架生成对应平台实现,不用操心跨语言的调用封装。
高质量
- 完善的文档和测试。
- 所有生成的对应语言,都符合风格要求。
UniFFI-rs 是如何工作的?
首先我们 clone uniffi-rs 的项目到本地, 用喜欢的 IDE 打开 arithmetic 这个项目:
1 | git clone https://github.com/mozilla/uniffi-rs.git |
打开项目,查看 arithmetic.udl (文件路径uniffi-rs/examples/arithmetic/src),我们看下这个样例代码具体做了什么:
1 | [Error] |
这段代码是使用 UniFFI 语法编写的 Rust FFI(Foreign Function Interface)接口定义文件的一部分。代码定义了一个 Rust 库的对外接口,允许其他语言通过 FFI 调用这些 Rust 函数。
[Error]
- 这是一个注解,用于定义错误类型。在 UniFFI 中,[Error] 注解后面跟着的是一个枚举类型的定义,这里定义了一个名为 ArithmeticError 的枚举类型。
enum ArithmeticError {
- 开始定义 ArithmeticError 枚举类型。
- “IntegerOverflow”,
- 枚举的一个成员,表示整数溢出错误。
namespace arithmetic {
- 开始定义一个名为 arithmetic 的命名空间,其中包含了多个函数的声明。
[Throws=ArithmeticError]
- 这是一个注解,表示接下来的函数可能会抛出 ArithmeticError 类型的错误。
u64 add(u64 a, u64 b);
- 定义了一个名为 add 的加法函数,它接受两个 u64 类型的参数 a 和 b,返回一个 u64 类型的结果。由于前面有 [Throws=ArithmeticError] 注解,所以这个函数可能会抛出 ArithmeticError 类型的错误。
u64 sub(u64 a, u64 b);
- 定义了一个名为 sub 的减法函数,它接受两个 u64 类型的参数 a 和 b,返回一个 u64 类型的结果。同样,这个函数可能会抛出 ArithmeticError 类型的错误。
u64 div(u64 dividend, u64 divisor);
- 定义了一个名为 div 的除法函数,它接受两个 u64 类型的参数 dividend 和 divisor,返回一个 u64 类型的结果。这里没有 [Throws] 注解,意味着这个函数不会抛出错误。
boolean equal(u64 a, u64 b);
- 定义了一个名为 equal 的 判断两个整数是否相等函数,它接受两个 u64 类型的参数 a 和 b,返回一个 boolean 类型的结果。同样,这个函数没有抛出错误的能力。
这些函数可以通过 UniFFI 自动生成的绑定在其他编程语言中调用。
在 arithmetic.udl 中,我们看到定义里一个 Error 类型,还定义了 add, sub, div, equal 四个方法,namespace 的作用是在代码生成时,作为对应语言的包名是必须的。我们接下来看看 lib.rs 中 rust 部分是怎么写的:
1 |
|
这段 Rust 代码定义了一个简单的算术操作库,其中包括加法、减法、除法和比较功能。同时,它还定义了一个错误类型 ArithmeticError 用于处理整数溢出的情况。
ArithmeticError
1 |
|
- #[derive(Debug, thiserror::Error)] 是属性宏,用于自动实现 Debug trait 和 thiserror::Error trait。
- pub enum ArithmeticError 定义了一个公开的枚举类型 ArithmeticError。
- #[error(“Integer overflow on an operation with {a} and {b}”)] 属性宏用于指定错误消息的格式字符串,其中 {a} 和 {b} 是格式化占位符。
- IntegerOverflow { a: u64, b: u64 } 枚举的一个变体,包含两个 u64 类型的字段 a 和 b。
- 加法函数
add
1 | fn add(a: u64, b: u64) -> Result<u64> { |
- fn add(a: u64, b: u64) -> Result
定义了一个名为 add 的函数,接受两个 u64 类型的参数 a 和 b,返回一个 Result 类型的结果。 - a.checked_add(b) 调用 checked_add 方法来安全地执行加法操作,如果发生溢出,则返回 None。
- .ok_or(ArithmeticError::IntegerOverflow { a, b }) 如果 checked_add 返回 None,则构造一个 ArithmeticError::IntegerOverflow 错误。
- 减法函数
sub
1 | fn sub(a: u64, b: u64) -> Result<u64> { |
- 类似于 add 函数,但是使用 checked_sub 方法来执行减法操作。
- 除法函数
div
1 | fn div(dividend: u64, divisor: u64) -> u64 { |
- fn div(dividend: u64, divisor: u64) -> u64 定义了一个名为 div 的函数,接受两个 u64 类型的参数 dividend 和 divisor,返回一个 u64 类型的结果。
- 如果 divisor 为 0,则使用 panic! 宏引发运行时错误。
- 如果 divisor 不为 0,则执行除法操作。
- 比较函数
equal
1 | fn equal(a: u64, b: u64) -> bool { |
- fn equal(a: u64, b: u64) -> bool 定义了一个名为 equal 的函数,接受两个 u64 类型的参数 a 和 b,返回一个布尔值,表示两个数是否相等。
- 定义
Result
类型别名
1 | type Result<T, E = ArithmeticError> = std::result::Result<T, E>; |
- type Result<T, E = ArithmeticError> = std::result::Result<T, E>; 定义了一个类型别名 Result,它是 std::result::Result<T, E> 的别名,其中 E 默认为 ArithmeticError。
- UniFFI 模块引入
1 | uniffi::include_scaffolding!("arithmetic"); |
- uniffi::include_scaffolding!(“arithmetic”); 是 UniFFI 的宏调用,用于自动生成与外部语言交互所需的绑定代码。这里的 “arithmetic” 是模块名称,UniFFI 会基于这个模块生成相应的绑定代码。
我们再来看一下 uniffi-rs/uniffi_bindgen/src/bindings/mod.rs 这个文件里的内容:
1 | mod kotlin; |
这段 Rust 代码定义了一个模块,用于生成不同编程语言的绑定代码。这些绑定代码允许其他语言与 Rust 编写的组件交互。代码中包含了几个子模块,每个子模块负责生成特定语言的绑定代码。此外,还提供了用于测试这些绑定的辅助功能。
- 子模块定义:
- kotlin: 定义了 Kotlin 绑定生成器。
- python: 定义了 Python 绑定生成器。
- ruby: 定义了 Ruby 绑定生成器。
- swift: 定义了 Swift 绑定生成器。
- 公共导出:
- 将各个子模块中的绑定生成器公开出来,以便外部使用。
- KotlinBindingGenerator
- PythonBindingGenerator
- RubyBindingGenerator
- SwiftBindingGenerator
- 将各个子模块中的绑定生成器公开出来,以便外部使用。
- 测试相关代码:
- 当启用 “bindgen-tests” 特性时,会导入测试相关的子模块和结构体。
- kotlin_test, python_test, ruby_test, swift_test
- 当启用 “bindgen-tests” 特性时,会导入测试相关的子模块和结构体。
- RunScriptOptions 结构体:
- 定义了一个 RunScriptOptions 结构体,用于配置 run_script 函数的行为。
- show_compiler_messages: 控制是否显示编译器消息,默认为 true。
- 定义了一个 RunScriptOptions 结构体,用于配置 run_script 函数的行为。
- RunScriptOptions 默认实现:
- 实现了 Default trait 为 RunScriptOptions 提供默认值。
下图是一张 UniFFI-rs 各个文件示意图,我们一起来看下,上面的 udl 和 lib.rs 属于图中的哪个部分:
- 图中最左边
Interface Definition File
对应 arithmetic.udl 文件 - 图中最下面红色的
Rust Business Logic
对应到 example 中的 lib.rs - test/bindings/ 目录下的各平台的调用文件对应最上面绿色的方块
那方框中蓝色的绑定文件去哪里了呢, 我们发现 lib.rs 最下面有这样一行代码
1 | uniffi_macros::include_scaffolding!("arithmetic"); |
这句代码会在编译的时候引入生成的代码做依赖,我们这就执行一下测试用例,看看编译出来的文件是什么:
1 | cargo test |
如果顺利的话,你会看到:
1 | test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s |
我们可以看到,这个测试用例,运行了 python, ruby, swift 和 kotlin 四种语言的调用,需要本地有对应语言的环境。
具体如何安装对应环境超出了本文的范围,但是这里给大家一个方法看具体测试用例是如何启动的
我们执行如下命令:
1 | cargo test -- --nocapture |
--nocapture
是传递给测试运行器的一个选项,它告诉测试运行器不要捕获标准输出(stdout)和标准错误输出(stderr)。
默认情况下,cargo test 会捕获测试运行期间的所有输出,以防止测试输出干扰测试结果的显示。使用 –nocapture 可以让测试中的 println! 或者 eprintln! 输出直接显示在控制台中。
如果出现长时间等待:
当你执行 cargo test – –nocapture 并遇到 Blocking waiting for file lock on package cache 的提示时,这通常是正常的流程。这是因为 cargo 命令在执行测试之前会尝试获取文件锁来确保包缓存的一致性。在多进程环境中,这种锁机制可以防止多个 cargo 实例同时修改相同的包缓存文件。
解释
- 文件锁的目的:
- 文件锁是为了避免多个 cargo 进程同时访问包缓存而导致的数据不一致。
- 当一个 cargo 进程正在使用包缓存时,它会锁定包缓存文件,其他进程必须等待当前进程完成才能继续。
- 为什么会出现等待提示:
- 当你执行 cargo test – –nocapture 时,cargo 首先会检查包缓存并尝试获取文件锁。
- 如果另一个 cargo 进程(例如构建、测试或其他命令)正在运行并且已经锁定了包缓存,那么新的 cargo 进程就会等待文件锁释放。
- 这个等待提示表明 cargo 正在等待文件锁被释放,以便它可以继续执行。
处理方法
- 等待:
- 如果提示只是一时出现,并且很快消失,那么通常不需要特别处理,只需要等待即可。
- 这通常意味着另一个 cargo 进程即将完成。
- 检查其他运行中的进程:
- 如果提示长时间不消失,可以检查是否有其他 cargo 进程正在运行。
- 使用 ps aux | grep cargo (在 Linux 或 macOS 上)或 tasklist | findstr cargo (在 Windows 上)来查找正在运行的 cargo 进程。
- 如果发现有其他进程,可以尝试终止它们,然后重新运行你的 cargo 命令。
- 清理缓存:
- 如果上述方法无效,可以尝试清理 cargo 的包缓存,以解决潜在的文件锁问题。
- 执行 cargo clean 来清理项目缓存。
- 执行 cargo cache clean 来清理全局包缓存(需要安装 cargo-cache 插件)。
- 环境隔离:
- 如果你在 CI/CD 环境中遇到这个问题,可以考虑为每个构建任务分配独立的缓存目录,以避免文件锁冲突。
- 使用不同的工作目录或缓存目录来隔离不同任务之间的文件锁竞争。
这里我们采用终止进程的办法,运行命令来查找正在运行的 cargo 进程:
1 | ps aux | grep cargo |
找到你想杀死的进程进行直接 kill 掉即可。如果你想一次性终止所有 cargo 进程,可以使用 pgrep 和 kill 结合来完成。这里是一个示例命令
1 | pgrep -f 'cargo' | xargs kill |
在清理完其他 cargo 进程后,我们重新运行命令,可以看到,没有再被 Blocking waiting for file lock on package cache 卡住了,但是产生了全新的其他问题,我们可以看到,生成各种目标语言失败了:
这几个报错都是相同的问题,这表明生成的 .dylib 文件是为 arm64 架构编译的,但是运行环境需要的是 x86_64 架构的库。这意味着你的开发环境可能是基于 x86_64 的 Mac,而 Rust 编译器却为 arm64 架构编译了代码。
你需要确保 .dylib 文件是为正确的架构编译的,并且路径正确。
问题解决方案 :
- 安装x86架构的 Rust 工具链
1 | rustup install stable-x86_64-apple-darwin |
- 设置当前默认工具链为x86架构
1 | rustup default stable-x86_64-apple-darwin |
- 再次检查 Rust 当前的编译目标架构
1 | rustc --version --verbose |
如果提示如下内容,则表明切换架构成功,直接跳到步骤6。
1 | rustc 1.80.1 (3f5fd8dd4 2024-08-06) |
如果仍然提示之前的内容,则表明切换架构失败,需要进一步执行
1 | rustc 1.77.1 (7cf61ebde 2024-03-27) |
- 使用 rustup show 命令来验证设置是否正确
1 | rustup show |
该命令会展示出详细的信息
1 | Default host: aarch64-apple-darwin |
注意高亮部分,从 rustup show 输出来看,默认工具链被设置为了 stable-x86_64-apple-darwin,但是在当前目录 (/Users/xiangkuilin/Desktop/Rust/uniffi-rs) 中存在一个 rust-toolchain.toml 文件,它覆盖了默认设置,并指定了 1.77.1-aarch64-apple-darwin 作为当前项目的工具链。
解决方案:
- 移除 rust-toolchain.toml 文件 如果你想让当前项目使用 stable-x86_64-apple-darwin 工具链,你可以删除项目中的 rust-toolchain.toml 文件。这将使项目使用默认工具链进行编译。
- 修改 rust-toolchain.toml 文件 如果你希望保留 rust-toolchain.toml 文件,你可以修改文件内容,指定使用 stable-x86_64-apple-darwin 工具链。文件内容应如下所示:
1 | [toolchain] |
鉴于该文件存在未知风险,我们选择直接删除 rust-toolchain.toml 文件,注意:需要完全删除,不能继续放在 uniffi-rs 可以扫描到的文件路径内。
- 删除文件后,运行 rustup show 来确认默认工具链是否生效,预期输出应该显示 host: x86_64-apple-darwin,并且版本号应该与 rustup show 中显示的一致,即 rustc 1.80.1 (3f5fd8dd4 2024-08-06)。
1 | Default host: aarch64-apple-darwin |
这时我们可以看到,在 active toolchain 下的工具链已经是x86架构了,且版本号为1.80.1,说明已经在本项目中设置生效成功并保持一致了。
- 运行 cargo clean 清理项目
1 | Removed 8808 files, 1.6GiB total |
- 使用 cargo build 重新编译项目,以确保它使用正确的工具链进行编译:
1 | cargo build |
再次运行 cargo test,我们可以看到完整的生成过程,这里生成通过了python 和 swift 文件,kotilin 和 ruby 报错是我本机这两个语言环境有问题,暂且忽略,需要的话检查本机相关路径处理即可
接下来我们就能在 uniffi-rs/target/temp 中看到生成的代码:
1 | arithmetic.kt |
其中的 kt 是 kotlin, py 是 python,rb 是 ruby,剩下4个都是 swift,这些文件是图中上面的平台绑定文件,我们以 swift 的代码为例,看下里面的 add 方法:
这段代码是用 Swift 语言编写的,它定义了一个名为 add 的公共函数,该函数接收两个 UInt64 类型的参数 a 和 b,并返回一个 UInt64 类型的结果。这个函数使用了 Swift 的 throws 关键字,表明它可以抛出错误。
下面是对这段代码的逐行解释:
public func add(a: UInt64, b: UInt64) throws -> UInt64 {
- 定义了一个公共函数 add,它接受两个 UInt64 类型的参数 a 和 b。
- 函数声明使用了 throws 关键字,表示该函数可能会抛出错误。
- 函数的返回类型是 UInt64。
return try FfiConverterUInt64.lift(try rustCallWithError(FfiConverterTypeArithmeticError.lift) {
- 这一行开始了一个复合表达式,用于调用 Rust 函数并处理可能的错误。
- FfiConverterUInt64.lift 是一个转换函数,它将 Rust 中的值转换为 Swift 中的值。
- try 关键字表示 lift 方法也可能抛出错误。
- rustCallWithError 是一个函数,它负责调用 Rust 函数并处理错误。
- FfiConverterTypeArithmeticError.lift 是一个转换函数,它将 Rust 中的错误类型转换为 Swift 中的错误类型。
- { 表示闭包的开始,该闭包是 rustCallWithError 的参数。
uniffi_arithmetical_fn_func_add(
- 调用了一个名为 uniffi_arithmetical_fn_func_add 的 Rust 函数,这是一个通过 FFI(Foreign Function Interface,外部函数接口)调用的 Rust 函数。
- 这个函数接受三个参数。
FfiConverterUInt64.lower(a),
- FfiConverterUInt64.lower 是一个转换函数,它将 Swift 中的 UInt64 值转换为 Rust 中可以理解的形式。
- a 是第一个参数,它的值被转换为 Rust 可以接受的形式。
FfiConverterUInt64.lower(b),
- 同样,FfiConverterUInt64.lower 被用来将第二个参数 b 的值转换为 Rust 可以接受的形式。
$0
- $0 是闭包中的第一个参数,它在这里代表 Rust 函数可能返回的错误处理逻辑。
- 在这种情况下,$0 是 rustCallWithError 的第三个参数,它将被传递给 uniffi_arithmetical_fn_func_add 函数,通常用于错误处理。
综上所述,这段代码的作用是:
- 将 Swift 中的 UInt64 参数转换为 Rust 可以接受的形式。
- 调用 Rust 函数 uniffi_arithmetical_fn_func_add 来计算两个整数的和。
- 处理 Rust 函数可能抛出的错误。
可以看到实际调用的是 FFI 中的 uniffi_arithmetical_fn_func_add
方法,它被定义在 arithmeticFFI.h
中:
目前还缺图中的 Rust scaffolding 文件没找到,它实际藏在 /uniffi-rs/target/debug/build/uniffi-example-arithmetic 开头目录的 out 文件夹中,注意多次编译可能有多个相同前缀的文件夹。我们以 add 方法为例:
我们看看生成的这个类
1 | // This file was autogenerated by some hot garbage in the `uniffi` crate. |
其中 extern “C” 就是 Rust 用来生成 C 语言绑定的写法。我们终于知道这个奇怪的 add 方法名是如何生成的了
到这里,我们就凑齐了上图中的所有部分,明白了 uniffi-rs 的整体流程。
如何集成到项目中?
现在,我们知道如何用 UniFFI-rs 生成对应平台的代码,并通过命令行可以调用执行,但是我们还不知道如何集成到具体的 Android 或者 Xcode 的项目中。在 UniFFI-rs 的帮助文档中,有 Gradle 和 XCode 的集成文档,但是读过之后,还是很难操作。
单来说,就是有个 Rust 的壳工程作为唯一生成二进制的 crate,其他组件如 autofill, logins, sync_manager 作为壳工程的依赖,把 udl 文件统一生成到一个路径,最终统一生成绑定文件和二进制。好处是避免了多个 rust crate 之间的调用消耗,只生成一个二进制文件,编译发布集成会更容易。
安卓平台:是生成一个 aar 的包,Mozilla 团队提供了一个 org.mozilla.rust-android-gradle.rust-android 的 gradle 插件,可以在 Mozilla 找到具体使用。
苹果平台:是一个 xcframework,Mozilla 的团队提供了一个 build-xcframework.sh 的脚本,可以在 Mozilla 找到具体的使用。
我们只需要适当的修改下,就可以创建出自己的跨平台的项目。
以 iOS 为例,整个跨平台的开发流程如下:
- Rust 编码
- 通过 UniFFI-rs 生成目标语言相关文件,实现 Swift 绑定
- 编译目标平台静态库
- 把静态库和头文件打包成 XCFramework,并依赖
- 测试,发布
下一期,我们将着重介绍如何在各个平台部署我们的 FFI 代码库。