本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!
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 函数的调用。
我们通过一个简单的demo了解了如何接入Rust,我们接着开始了解一些Rust的基础知识
Rust基础知识
环境、配置、编译
- Rustup 是官方推荐的安装方式,可以在官网上找到安装指南。安装完成后,可以在终端中执行
rustc --version
命令来检查 Rust 是否安装成功。 - Cargo 是 Rust 的构建系统和包管理器,可以使用 Cargo 来创建、构建和管理 Rust 项目。在 Cargo 中,可以通过在项目根目录下创建
Cargo.toml
文件来配置项目的依赖项、构建选项、目标等信息。Cargo会随着rust一起进行安装,通过cargo --version
命令来检查 Cargo 是否安装成功。 - Rust 的编译方式主要有两种,使用 Rustc 直接编译或者使用 Cargo 构建。Cargo 构建可以更方便地管理 Rust 项目的依赖项、构建选项、目标等信息,所以推荐使用Cargo来进行编译。
rust-sdk的编译除了上面需要安装上面提到的项,还需要安装git-lfs(用于文件管理),protobuf(用于和客户端,server端直接传递和解析数据),ndk(用于编译出Android的so库)等。
语言特性
Rust的语言主要有下面几个特点:
- 内存安全:Rust 在编译时就能够检测出内存安全问题,避免了 C/C++ 等语言常见的内存问题,例如空指针引用、野指针等。Rust 通过所有权系统和借用检查器来实现内存安全,使得开发者可以写出更加健壮和可靠的代码。
- 零成本抽象:Rust 支持高级语言特性,例如泛型、函数式编程、模式匹配等,但是不会对程序的性能产生影响,因为 Rust 编译器可以将这些高级特性优化为低级语言的代码。
- 并发安全:Rust 在语言层面上支持并发编程,提供了原生的线程和通道等机制,并且使用所有权系统和借用检查器来保证并发安全。
- 垃圾回收:Rust 不需要垃圾回收机制,而是通过所有权系统来管理内存,使得程序在运行时的性能更加可控和可预测
- 可移植性:Rust 的代码可以在不同的操作系统和硬件平台上运行,因为 Rust 的编译器可以生成与平台无关的机器码。同时,Rust 还提供了跨平台的标准库和工具链,使得开发者可以更加方便地进行跨平台开发。
这里讲一讲Rust相比于Java或者Kotlin,独有的几个特性:
- 变量的默认不可变性
//变量声明,a的值是不能被修改的
let a = 123;
//可变变量声明,a的值在后面可被修改
let mut a = 123;
不可变性有助于避免数据竞争和并发问题。当一个变量的值在绑定之后不能被改变时,那么多个线程可以同时访问该变量且不会产生竞争条件。不可变性可以在编译时进行静态检查,帮助开发者在代码级别捕获潜在的并发问题。此外,不可变性还有助于代码的可维护性和可理解性。通过将变量标记为不可变,开发者可以明确地表达其意图,即该变量不应该被修改。这使得代码更易于推理和调试,并降低了引入错误的可能性。在需要修改变量的情况下,通过关键字mut来声明可变变量。这种显式声明的方式有助于提醒开发者注意潜在的副作用和改变数据的后果,进一步提高代码的可靠性和可维护性。
- 所有权
Rust 中的数据类型包括基本类型(例如整型、浮点型、布尔型等)和复合类型(例如数组、元组、结构体、枚举等),因为Rust不是面向对象型语言,所以没有对象这种说法。基本类型都是固定大小的简单值,通过栈来存储,不需要在堆上分配内存。因此,这些基本类型的值没有所有权的概念,也不需要在程序中显式地管理其内存。对于所有的复合类型,需要在堆上分配内存,Rust通过所有权机制来解决内存泄漏、悬垂指针等问题。
-
作用域
{ // 在声明以前,变量 s 无效 let s = "runoob"; // 这里是变量 s 的可用范围 } // 变量范围已经结束,变量 s 无效 ```
-
移动
let s1 = String::from("hello"); let s2 = s1; println!("{}, world!", s1); // 错误!s1 已经失效 ```
-
引用与租借
//1. 引用不会获得值的所有权。只能租借(Borrow)值的所有权。所以下面的例子会报错 let s1 = String::from("hello"); let s2 = &s1; let s3 = s1; println!("{}", s2); //2.不可变引用不具有所有权,即使它租借了所有权,它也只享有使用权 let s1 = String::from("run"); let s2 = &s1; println!("{}", s2); s2.push_str("oob"); // 不可行,禁止修改租借的值 //3.可变引用可修改值,但是只能有一个引用 let mut s1 = String::from("run");// s1 是可变的 let s2 = &mut s1;// s2 是可变的引用 s2.push_str("oob"); //可行 let r2 = &mut s1;//不可行 ```
-
悬垂引用
//改代码会导致编译器报错,因为s在函数结束后就会释放,s的引用是没有意义的,不允许被当做返回值。 fn dangle() -> &String { let s = String::from("hello"); &s } ```
- 智能指针
c++也有智能指针,并不是rust才有的,但是Java和Kotlin并没有类似的概念,所以这里也单独讲一下。虽然rust有作用域的概念,但是有些情况下依然需要智能指针,比如动态分内存,递归数据结构,多个所有者等情况下仍然需要智能指针来管理内存。
-
Box: Box是最常用的智能指针类型,它可以用来在堆上分配内存,并且可以拥有唯一的所有权。通过 Box,可以将一个值移动到堆上,并在需要时将其移动回栈上或者传递给其他函数,类似于c++的std::unique_ptr。Box通常用于以下情况:
- 在编译时无法确定对象大小的情况下,需要动态分配内存。例如,当需要使用可变长度的数据结构(如Vec)时,需要使用Box来动态分配内存。
- 当需要从函数中返回一个拥有所有权的对象时,可以使用Box将对象放入堆上,并将Box作为返回值返回。这通常用于创建可变长度的数据结构或可变状态的对象。
- 当需要在多个线程之间共享数据时,可以使用Box将数据放在堆上,并使用多个智能指针拥有其所有权。
fn main() { let s = Box::new(String::from("hello")); // ... } ```
-
Rc 和 Arc:它们都可以用来实现多个所有者对同一个值的共享访问,采用共享引用计数的方式来管理所有权。其中
Rc
用于单线程环境,而Arc
用于多线程环境。类似于c++的std::shared_ptruse std::rc::Rc; fn main() { let v = Rc::new(vec![1, 2, 3]); let v1 = Rc::clone(&v); let v2 = Rc::clone(&v); // ... } ```
-
Cell: 可以用来在值之间共享可变状态。
-
RefCell:可以用来在运行时进行借用检查,避免出现数据竞争等问题。
代码组织
rust项目的代码组织方式主要有crate,package和module这三类,详细的介绍如下:
-
箱(crate):一个 crate 可以包含一个或多个 Rust 源文件(.rs 文件),以及一个可选的配置文件 Cargo.toml。在 Rust 中,一个 crate 的根源文件必须命名为 main.rs(用于二进制 crate)或者 lib.rs(用于库 crate)。crate 是 Rust 中代码组织和分发的基本单元,它可以被其他 crate 引用和使用。crate是一个独立的 Rust 代码单元。Cargo.toml 和我们在Android中使用的 build.gradle 的异同点主要如下:
相同点:
- 都是项目的配置文件,用于管理依赖项和构建过程。
- 都可以定义项目的版本号、作者、许可证等元数据信息。
- 都支持使用变量来配置项目。
不同点:
- Cargo.toml 中的依赖项通过 dependencies 字段来声明, build.gradle 中的依赖项通过 dependencies 块来声明。
- Cargo.toml 中的构建选项通过 build 字段来声明,build.gradle 中的构建选项通过 android 块来声明。
- Cargo.toml 中的脚本通过 workspace 字段来声明, build.gradle 中的脚本可以通过 task 块来声明。
- Cargo.toml 支持多个子包(即可以在同一个仓库中管理多个包),而 build.gradle 不支持这种情况。
-
包(package):package 是用于组织多个 crate 的概念,是一个包含一个或多个 crate 的逻辑单元,一个 package 通常包含以下文件和目录:
- Cargo.toml:用于描述整个 package 的元信息和依赖项。
- src/ 目录:包含一个或多个 Rust 源文件,作为 package 中的 crate 的源代码。
- tests/ 目录:包含测试代码。
- examples/ 目录:包含示例代码。
-
模块(Module):module 是用于组织代码的一种机制,它可以将相关的代码封装在一个逻辑单元中,使得代码更加清晰、易于维护和复用,通过模块可以使rust实现面向对象的语言特性。
应用场景
通过这个简单的案例,我们就知道了如何在 Android 项目中接入 Rust,可以看到其实并不复杂,所以我们可以大胆的用起来,那么 Rust 有哪些应用场景呢?
- 首先我们可以把原本需要用 c++ 实现的 Native 代码都用 Rust 来实现,Rust 完全可以替代 c++,它的性能不弱于 c++,并且安全性更高,也有大量的语法题支持,写起来更顺手。
- 我们可以所有数据相关的逻辑都放在 Rust 中处理,比如网络请求,数据库的读写,因为 Rust 支持协程,所以通过 Rust 的协程来进行数据操作,对性能也能有极大的提升。
- 有安全要求的代码可以用 Rust 来实现,上层中 Java 或者 Kotlin 写的代码通过反编译都能很容易的看到业务代码逻辑,但是通过 Rust 打包出来的 so 库就没法看到代码实现了。
- 对稳定性有高要求的业务可以用 Rust 来实现,因为足够的安全性就是 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 的魅力!