Rust 在 Android 中的应用
❝本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!
❞
本文主要是关于如何在 Android 中接入 Rust 的扫盲文,并不会对 Rust 做过多的讲解,对 Rust 有兴趣的可以自己去学习 Rust,至于如何才能快速的学会 Rust,可以参考我的这篇文章:《如何快速的掌握一门编程语言》
Rust 的前景
可能会有人担心花了较多的时间成本去学习了 Rust 后没啥使用的场景,或者学习后很快就过时了,其实这个大可不必担心,Rust 是非常有前途的一门语言。而且有越来越多的大公司开始采用 Rust 用于关键系统和新项目的开发。比如 Mozilla 用 Rust 来开发浏览器引擎,vivo 用 Rust 开发蓝河操作系统,字节的飞书、Tiktok 客户端也有大范围的使用使用 Rust,并且 Rust 的社区非常活跃,拥有丰富的库和工具支持,这意味着 Rust 的轮子很多,我们可以用 Rust 实现很多事情。因此学会 Rust 是很有性价比的一件事。
为什么越来越多项目开始用 Rust 呢?原因有很多,比如他性能好,可以媲美 c++;他的安全性高,像内存问题、线程安全问题,错误捕获问题在 Rust 中都能被很好的解决;它的语言较新,有很多现代编程语言的特性以及很多语法糖的支持,可以让代码更简洁和简单……大家可以在实际使用过程中慢慢的探索这款语言的优点。
Rust 的接入和使用
我这里以一个简单的 Demo 来讲解 Android 中如何接入和使用 Rust。
初始化
首先是下载和初始化,通过 curl 脚本即可快速下载 Rust。
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Rust 下载完成之后通过在命令行窗口执行脚本 “cargo new study_rust”就能新建一个项目名为 study_rust 的项目。通过 IDE 打开这个项目后的界面如下,我是用 clion 这个 ide 打开的,大家用 androidstudio 或者其他 ide 都可以,不过可能要下载 Rust 支持的插件。
在这项目的命令窗口中,执行 “cargo run”,就能成功的将该项目运行起来。
跨端通信
我们接下来就要看看如何在 Android 中调用 Rust。不同语言之间的互相调用,都要通过 FFI (Foreign function interface),也就是外部函数接口。
IOS 和 Rust 之间的 FFI 接口是用的 RustBridge,对 Android 来说,FFI 接口就是我们熟知的 JNI(Java Native Interface),可以看到 JNI 不仅可以用在 Java 调用 c 或 c++,还可以用来调用 Rust,所以我们一定要熟悉 JNI 的相关知识,这是 Android 进阶的必须要掌握的知识之一。
我们接着来看 Rust 中如何使用 JNI,首先在 Rust 项目的依赖配置中引入 JNI 库,如下所示。此时我们就可以使用 JNI 了。
我们接着在代码中创建 JNI 函数,规则和我们调用 C++的 JNI 函数是一样的,Java 下划线+包名+类名+方法名,包括传参规则也是一样,比如 JNI 中的 int 类型我 jint,String 类型为 jstring。在下面的 Demo 中,我创建了一个 add 方法的 JNI 函数,实现两数相加并返回结果这个简单的逻辑。
并且基于架构考虑,我将 add 方法放在了另外一个模块中,如下所示,放在lib.rs文件中。
这个时候我们用 Rust 写的的逻辑就实现了。接下来就需要把这个项目打包成 so 库文件
打包成 so 文件
首先在 Rust 项目的根目录创建 .cargo/config 文件,因为我在 demo 中只打 64 位的库,所以我只需要配置 target.aarch64-linux-android 的 ndk 路径就好了,我们需要配置 linker 和 ar 这两个工具的路径,这两个工具的说明如下:
「linker」:用于链接二进制文件的工具。链接器负责将编译生成的目标文件(object files)合并成一个最终的可执行文件或共享库(.so 文件)。 「ar」:指定用于创建和修改存档文件(如静态库)的工具。它负责将多个目标文件打包成一个单独的归档文件。
在我们的 NDK 文件的 toolchains/llvm/prebuilt/darwin-x86_64/bin 目录下有这两个工具。我使用的是 25.0.8775105 这个 NDK 版本,所以配置径如下。
如果我们需要生成 32 位 so 木架,则需要新增 [target.armv7-linux-androideabi]配置,并将 linker 指向同样的 NDK 目录下 armv7a-linux-androideabi21-clang 文件,ar 指向 arm-linux-androideabi-ar 文件。
配置好 linker 和 ar 后,在命令窗口中执行 “rustup target add aarch64-linux-android” 指令,用于为 Rust 项目添加一个 64 位 so 库的编译目标。因为 Rust 编译器默认情况下只为当前主机平台编译代码,如果想为其他平台编译 Rust 代码,就需要添加相应的编译目标,如果要编译 32 位的 so,则需要继续执行 “rustup target add x86_64-linux-android” 指令。
最后在命令窗口中执行 “cargo build --target aarch64-linux-android --release” 执行,就可以生成 64 位的 release 版本的 so 库了。生成的 so 文件在 target/aarch64-linux-anroid/release 目录中,如下图所示:
Android 接入
成功构建 so 库后,Android 侧接入就比较简单了,我这里就以一个很简单的 demo 来进行接入,如下所示,Rust 构建出来的 so 库的加载和 native 函数的声明与我们使用 c++ 生成的 so 库没任何区别。
然后在 log 中调用该 Rust 函数
最后运行程序,可以看到成功的实现了对 Rust 的 add 函数的调用。
应用场景
通过这个简单的案例,我们就知道了如何在 Android 项目中接入 Rust,可以看到其实并不复杂,所以我们可以大胆的用起来,那么 Rust 有哪些应用场景呢?
首先我们可以把原本需要用 c++ 实现的 Native 代码都用 Rust 来实现,Rust 完全可以替代 c++,它的性能不弱于 c++,并且安全性更高,也有大量的语法题支持,写起来更顺手。 我们可以所有数据相关的逻辑都放在 Rust 中处理,比如网络请求,数据库的读写,因为 Rust 支持协程,所以通过 Rust 的协程来进行数据操作,对性能也能有极大的提升。 有安全要求的代码可以用 Rust 来实现,上层中 Java 或者 Kotlin 写的代码通过反编译都能很容易的看到业务代码逻辑,但是通过 Rust 打包出来的 so 库就没法看到代码实现了。 对稳定性有高要求的业务可以用 Rust 来实现,因为足够的安全性就是 Rust 语言最有价值的特性之一。 跨平台的需求,通过一套Rust我们可以打包多个平台的库 其他如复杂的计算逻辑代码,对性能有高要求的代码等等都可以用 Rust 来实现。
架构设计
上面讲的只是一个简单的接入案例,通过这个案例我们初步了解了如何在 Android 中接入 Rust,在前面我们也知道了,Android 中可以使用 Rust 的场景非常的多,我们甚至可以把除 UI 渲染以外的所有逻辑都放在 Rust 侧来做。所以在实际项目中,我们还需要设计好 Rust 层的架构,只有一个好的项目架构,才能满足在项目中大规模的使用 Rust。
想要在项目中大规模的使用 Rust,我们主要需要解决大量 JNI 接口的问题,如果我们为每个业务都去新增一个 JNI 接口,那么 JNI 接口就会非常的庞大,而且我们在项目开发中也会变得非常繁琐。如下图所示,随着项目越来越大,Rust 的使用越来越多,JNI 接口也越来越庞大和臃肿,其次我们需要解决Rust中各个业务之间的解耦问题。
要如何解决这些问题呢?我们可以通过「事件分发机制+Protobuf 数据协议」来设计 Rust 层的架构,从而解决上面出现的问题。
具体的架构设计如下图所示,我们只需要定义一个 invoke 函数的 JNI 接口,上层在进行 invoke 接口调用时,只需要和 Rust 层定义好 command 接口,然后通过 ProtoBuf 来封装需要传给 Rust 层的数据,Rust 在执行完逻辑后,也将上层需要的数据结果以 ProtoBuf 进行封装后进行返回。
在 Rust 层中,当 invoke 函数被调用时,会将 command 和数据传递给 looper,looper 是我们在 Rust 层设计的一个观察者模式的数据分发器,各个业务需要注册 command 以及 command 对应的数据回调接口调到 looper 的 servicemap 中,loooper 解析 invoke 函数传递过来的 command,然后将数据回调给该 command 对应的业务。
如果业务越来越复杂,一个 invoke 接口依然是不够用的,我们可以根据业务需要再扩展几个接口,比如 init 接口,专门用于初始化,invokeSync 用于同步调用,invokeAsync 用于异步调用,如果是异步调用,则需要传入一个 jobject 类型的 callback 函数,用于业务在执行完逻辑后通过 callback 进行异步回调。架构设计如下
同步调用我们很容易理解,直接在方法逻辑执行完成后返回数据即可,在前面演示的简单的 Rust 接入的 demo 中,我们可以看到 Rust 中 add 函数在末尾返回的函数可以直接传递给上层。异步调用我在这里专门解释下如何使用,其实这也是 JNI 的知识,我们首先需要再 Java 层定义一个 Callback 接口
// RustCallback.java
package com.example.myapp;public interface RustCallback {
void onResponse(byte[] response);
}
然后在 invokeAsync 这个 JNI 函数中传递 callback 接口,传递的类型是 jobject 类型。在 c++ 或 Rust 层中,在通过 env->GetMethodID 获取该 callback 的 method_id,最后通过 env.call_method 方法既能实现对上层的 callback 回调,代码实现如下:
pub extern "C" fn Java_com_example_myapp_RustBridge_invokeAsync(
env: JNIEnv,
_: JClass,
command:jint,
data:jbyterArray,
java_callback: jobject
) {
let env = env.clone();
let java_callback = env.new_global_ref(java_callback).unwrap();
let runtime = RUNTIME.clone(); runtime.lock().unwrap().spawn(async move {
//逻辑处理并封装respond数据
……
//通过env获取回调接口的method_id
let method_id = env.get_method_id(
"com/example/myapp/RustCallback",
"onResponse",
"([B)V",
).unwrap();
//callback回调ProtoBuf协议的数据
env.call_method(
java_callback.as_obj(),
method_id,
result_data,
).unwrap();
});
}
通过上面的演示,我们了解到了 JNI 的异步回调的使用,实际上,我们可以在Rust的 init 函数中就将 java_callback 传递下来,然后将获取的 java_callback 的 method_id 设置成全局常量,这样后续流程中上层和 Rust 层在进行异步调用的时候,也可以不需要传 callback 这个参数,简化了调用流程,并且在 Rust 方法逻辑中也不需要再重复获取该 callback 的 method_id,直接使用全局的 method_id 即可。
此时也许会有人担心上层那么多业务用同一个 callback 不会有问题么?实际上在上层我们也可以设计一个消息时机分发机制,虽然所有的 Rust 的异步调用的数据都通过这个 callback 返回,但是上层 callback 在收到回调的数据后,通过 command 以及订阅者模式机制去进行分发。所以一个完整的架构流程如下所示:
到这里我就讲完了如何在 Android 中使用 Rust 了,大家可以尽量去尝试和使用,亲自体验一下 Rust 的魅力!