Rust 学习笔记 - JNI 调用篇

1,382 阅读5分钟

背景

最近在搞NDK相关的工作,去查看Android系统源码的时候,发现已经有些系统代码使用Rust来编写了,之前也了解过一些关于Rust的高性能与安全性的特性,比较感兴趣,所以尝试一下Java & Rust通过JNI通信。

项目创建

我这边使用的是VS Code作为编译Native侧的代码编写工具,因为在 Android Studio 上我没有找到很好支持 Rust 语言的插件,如果读者你有很好的插件推荐请评论一下,谢谢。 在Rust语言中,可以使用命令去构建一个项目。

cargo new android_ndk_rust_sample

这样我们就在当前命令行所在的路径下,创建了一个名为android_ndk_rust_sample的 Rust 项目。 我们看一下项目结构。

image.png

每个文件和目录的作用和含义如下:

  • .cargo/: 这个目录通常用来存放Cargo的配置文件。Cargo是Rust的包管理器和构建工具。

  • config: 这里边会配置到android构建elf文件的链接器上。

  • src/: 这是源代码目录,包含了项目的Rust源文件。

    • greeting.rs: 这个是我写了一个rs文件,类似Java的.java文件。
    • lib.rs: 这个文件通常是库项目的根文件,如果这是一个库项目,那么这个文件将包含库的主要接口和模块定义。
  • target/: 这是编译后的文件目录,包含了编译过程的输出,例如二进制文件和库。

    • aarch64-linux-android/: 这个目录表明编译的目标平台是基于ARM架构的Android系统。
    • debug/: 在这个目录下通常包含了调试模式下编译的输出。
    • .rustc_info.json: 这是一个内部文件,用于存储编译器版本信息等元数据。
    • CACHEDIR.TAG: 这是用来指示某些缓存管理工具这个目录是用于缓存的。
  • .gitignore: 这是Git版本控制系统的配置文件,它告诉Git哪些文件或目录是不需要添加到版本控制中的。

  • Cargo.toml: 这是Cargo的清单文件,它定义了项目的依赖项和一些其他的元数据。

  • Cargo.lock: 这个文件锁定了项目依赖项的版本,以确保所有开发者和部署都使用相同版本的依赖项。

上面的U标志通常表示这些文件或文件夹在版本控制中有未提交的修改。

配置项目

我们在 Android Studio 中可以直接创建一个 NDK 项目,但是 Rust 目前使用 VS Code 不太好一键生成支持 JNI 的项目,所以需要手动配置。

配置 Cargo.toml

  • Cargo.toml 文件中添加相关依赖
[dependencies]
jni = "0.19" // 对 JNI 支持
log = "0.4" // 日志打印
android_logger = "0.9" // 支持对 Android 的打印

之后还有一个配置很重要。

[lib]
crate-type = ["cdylib"]

cdylib: 这个库类型用于创建一个C兼容的动态库,只有在 lib.rs 中配置的 Rust 代码才会编译成动态库,最后使用命令编译成.so文件。

配置 Andorid NDK

.cargo/config中指定 Android SDK 中的 NDK 路径。

[target.aarch64-linux-android]
linker = "/Users/edisonli/Library/Android/sdk/ndk/25.2.9519653/toolchains/llvm/prebuilt/darwin-x86_64/bin/aarch64-linux-android21-clang"

[target.armv7-linux-androideabi]
linker = "/Users/edisonli/Library/Android/sdk/ndk/25.2.9519653/toolchains/llvm/prebuilt/darwin-x86_64/bin/armv7a-linux-androideabi21-clang"

这个配置后续在编译成.so动态链接库时用到32位 & 64位的编译器。

编写 demo 代码

我们先随便创建一个叫greeting.rs的文件,这里说明一下:

在Rust中,术语"类"并不适用,因为Rust并不是一个面向对象编程语言(OOP)的典型代表,它更多是一种多范式编程语言,支持过程式、函数式和泛型编程特性。Rust中并没有类(class)的概念,相反,它使用结构体(structs)和枚举(enums)来创建复杂的数据类型,并通过实现(impl)块来定义与这些类型相关的方法。

use jni::JNIEnv;
use jni::objects::{JClass};
use log::{info, Level};
use android_logger::Config;

#[no_mangle]
pub extern "C" fn Java_com_example_rust_RustHelper_callHelloWorld(_env: JNIEnv, _: JClass) {
    android_logger::init_once(Config::default().with_min_level(Level::Trace)
);
    info!("Hello, world from Rust!");
}

是不是与 C++ 的 JNI 写法很相似?唯一区别是方法名称,

Rust JNI调用必须指定以 Java_开头,J 必须大写。 之后是包名 + 类名 + 方法名

小坑

上述代码最后需要执行两个命令,

rustup target add aarch64-linux-android armv7-linux-androideabi i686-linux-android
cargo build --target aarch64-linux-android  // 64位

image.png

执行第二个命令时,会触发编译,理论上会得到一个 项目名.so的文件。

class RustHelper {

    external fun callHelloWorld()

    companion object {

        fun init() {
            System.loadLibrary("android_ndk_rust_sample");
        }
    }
}

之后我将它挪动到 AS 的项目中,包名和 JNI 方法的包名设置一致,并且类名也同样设置相同。 执行调用方法时报错,找不到这个JNI 方法。

搞了好久,使用 readelf -Ws android_ndk_rust_sample.so | grep Java_com_example_rust_RustHelper_callHelloWorld 然而并没有,所以说明方法并未编译到 so 中。

然后查资料,其实这时候需要 lib.rs 文件登场了,它类似于 android 清单文件,需要将 JNI 方法注册到这里边。

mod greeting;

pub use greeting::Java_com_example_rust_RustHelper_callHelloWorld;

ok,信心十足又跑了一下

cargo build --target aarch64-linux-android  // 64位

然而还是找不到,我惊了。

又搞了一会儿,还是 GPT4 牛逼,GPT4 告诉我:

对 JNI 方法之上需要加上 #[no_mangle] 的标记,这个类似于 Java 中的注解,标记这个方法告诉编译器不要对这个方法进行函数名修改。在 JNI 方法中 如果一旦为了确保唯一性对其进行名称修饰,那么 Java 侧调用时就会找不到该方法。

ok, 至此编译器打印出来

Hello, world from Rust!

后续我会尝试把一些 C/C++ 库尝试用 Rust 实现一下,Rust 无法于 C++ 直接混编,但是 Rust 可以支持 C 代码相互调用。可以尝试使用 C 作为一个 bridge 然后去调用 C++ 的一些第三方 SDK,作为临时方案。

如果 Rust 后续可以规避一些安全问题 性能又与 C++ 差不多的话,其实 Android Native 侧可以主 Rust 开发。如果你有更好的思考或者上述我有错误请告诉我~ 灰常感谢~

总结

如果有 Java&Kotlin&C++ 基础的话,Rust还是比较好上手的,但是还有很多的高阶内容需要学习,后续会每学习一点记录一下。推荐一个学习的网站 - Rust语言圣经