深入 Expo Modules 2.0 的新 JNI 层

Kolibri 是 Expo Modules 2.0 在 Android 上的 JNI 库。它把安全封装做成可选的,让 C++ 指针直接传进 Kotlin,并让 JS 数据走共享二进制缓冲区。

中文
复制
纯黑背景上,左上角一枚立体金属质感的 C++ 六边形图标,正中是半透明的玻璃质感 JNI 三个字母,右下角露出 Kotlin 的 K 形折角,背后叠着细线画的圆形与网格草图

Kolibri 是 Expo Modules 2.0 在 Android 上的 JNI 库。调用 C++ 对象比用 fbjni 快最多 51 倍。JS 对象传到 Kotlin 快 3 到 5 倍,因为 Kolibri 用二进制缓冲区传递它们,而不是通过 JNI 逐个构建。

在 Expo Modules 2.0 的早期预览 里,我们展示了在 iOS 上编写原生模块的新方式,并提到 Android 版本仍在开发中。本文介绍 Kolibri,它是 Expo Modules 2.0 在 Android 上使用的 JNI 库,我们构建它是为了取代 fbjni。

概要

在 Android 上,Kotlin 与 C++ 之间的每次调用中,JNI 都是最慢的一环,而 fbjni 这类封装会让每次调用更慢,因为它们添加的每一项安全特性都要在每次调用时付出时间代价。Kolibri 是 Expo Modules 2.0 底层的 JNI 库,它贴近原生 JNI,并允许你在不需要的地方关掉这些特性。对于不会抛异常的方法,它跳过异常检查;它把 JNIEnv 作为参数传入,而不是去查找它。对于混合对象,Kotlin 编译器插件把 C++ 指针直接传进原生代码,作用域则一次性释放与某个 JS 运行时绑定的所有内容。Kolibri 不再通过一次次 JNI 调用构建 Java 对象,而是在共享二进制缓冲区中把 JS 数据传给 Kotlin。结果是:从 C++ 调用 Java 与原生 JNI 一样快,调用 C++ 对象比用 fbjni 快 28 到 51 倍,JS 对象通过二进制缓冲区到达 Kotlin 比逐次 JNI 调用构建快 3 到 5 倍。

Kolibri 由三部分组成:一个只依赖 <jni.h> 的 C++ 库、一个小型 Kotlin 运行时,以及一个在编译期消除 JNI 样板代码的 Kotlin 编译器插件。在讲它的工作原理之前,需要先介绍几个概念。如果你已经了解 JNI 和 fbjni,可以直接跳到为什么我们自己造一个。

什么是 JNI?

Java Native Interface(JNI)是原生代码与 Java 虚拟机通信的方式。在 C 或 C++ 中,你可以用它调用 Java 方法、创建 Java 对象、读写对象字段,以及改变 VM 的状态。它也让 Kotlin 和 Java 代码可以调用用 C++ 编写的函数。

JNI 示意图:一侧是 Kotlin 或 Java,另一侧是 C++

JNI 历史悠久,设计时的首要目标是安全,而非速度。在 Kotlin 与 C++ 之间的一次典型调用中,JNI 这一段是最慢的环节。在 Android 上没有别的选择:C++ 要与 Java 通信,就得走 JNI。

下面是用原生 JNI 写一次方法调用的样子。它向一个 Java List 询问其大小:

jclass listClass = env->FindClass("java/util/List");
jmethodID sizeMethod = env->GetMethodID(listClass, "size", "()I");
jint size = env->CallIntMethod(list, sizeMethod);
if (env->ExceptionCheck()) {
  // A Java exception is pending. Handle it before the next JNI call.
}
env->DeleteLocalRef(listClass);

按名称和字符串签名查找方法,调用后检查异常,最后还要清理一个引用。

JNI 引用

C++ 不是托管语言:内存由你分配,也由你释放。Java 是托管语言:垃圾回收器决定一个对象何时不再需要。当 C++ 想持有一个 Java 对象时,两套模型必须在某处对接,JNI 用引用来完成这件事。

JNI 引用是一个垃圾回收器能够识别的句柄。引用存活期间,它背后的 Java 对象无法被回收,会被固定在原地。正因如此,从 C++ 使用这个对象才是安全的。代价也在这里:你创建的每一个引用,迟早都得删除,而在真实代码里,引用的数量非常多。

JNI 的引用主要有两种。局部引用是默认得到的:原生方法的每个参数、JNI 函数返回的几乎每个对象,都是局部引用。局部引用只在当前这次原生方法调用期间有效,而且只在创建它的线程上有效。全局引用则需要你自己用 NewGlobalRef 创建,用于在调用返回后继续持有对象,或在另一个线程上使用它。

fbjni 与 jni-bind

封装库让 JNI 更好用。最流行的两个是 Meta 的 fbjni 和 Google 的 jni-bind。两者主要都是为安全而设计的:它们替你管理引用,所有东西都会自动清理。

两者的思路不同。jni-bind 更简单,专注于从 C++ 调用 Java。fbjni 更全面,它也处理反方向的调用,即从 Java 进入 C++,并且引入了混合对象:同时存在于边界两侧的对象,一个 Java 部分和一个 C++ 部分协同工作。这些我们后面再讲。

在 React Native 生态中,fbjni 是默认之选。它随 React Native 一同发布,TurboModules 在用,生态中许多其他库也在用。

为什么要自己造一个

Kolibri 保留了安全网,但允许你把它摘掉。实际编写 Kolibri 代码有两种方式,二者可以很好地共存:

  • 你清楚自己在做什么。 你主动关闭安全特性:没有自动内存管理,没有异常检查。清理工作全由你负责,换来的是 JNI 允许的最快代码。
  • 你想要安全网。 你得到和 fbjni 一样的东西:托管引用、自动异常检查等等。

真实代码两种都需要。有些路径很热,必须尽可能快;另一些路径很棘手,性能没那么重要,你希望泄漏风险越小越好。fbjni 是个好库,但在裸 JNI 和 fbjni 之间来回切换并不顺畅。你最后总是在绕开这个库,因为它当初就不是为这种用法设计的。Kolibri 则是为此设计的。

以引用为例。Kolibri 有一个 Ref 类,它持有一个 JNI 引用,并在 Ref 销毁时删除该引用。在 C++ 里描述一个 Java 类时就会用到它:

using namespace expo::kolibri;

struct JGreeter : JavaClass<JGreeter> {
  static constexpr std::string_view descriptor = "com/example/Greeter";

  static constexpr Method<"greet", Ref<jstring>(jstring)> greet{};
};

// `greeter` and `name` come from Java, as arguments of a native method.
// Java owns them, so there is nothing to manage on the C++ side.
std::string greetFromJava(JNIEnv* env, jobject greeter, jstring name) {
  // The result is a Ref, so its local reference is deleted at the end of the scope.
  Ref<jstring> greeting = JGreeter::greet(env, greeter, name);
  return greeting.toStdString(env);
}

调用 greet 时,你不必把 greeter 或 name 包进 Ref。Kolibri 直接接受原样的 JNI 引用。这是最常见的情况:参数来自 Java,或者引用会由别处释放,你不想让 C++ 管理它们,只想把它们传下去,也不想浪费时间在它们外面套一层包装。Kolibri 与这种模式配合得很好,许多类似的模式也一样。

Kolibri 的不同之处

Kolibri 用 C++20 编写,API 建立在现代 C++ 特性之上。再加上一个 Kotlin 编译器插件,它能做到其他库做不到的事。

跳过异常检查

在裸 JNI 的例子里,调用 Java 之后紧跟着 ExceptionCheck()。Java 方法抛出异常时,JNI 不会在 C++ 里抛异常,而是把异常挂起,你必须在调用之后自己去查。这个检查本身就是一次 JNI 调用,同样要花时间。默认情况下,Kolibri 在每次调用之后都做这个检查,并把挂起的 Java 异常作为 C++ 异常重新抛出。

很多 Java 方法根本不会抛异常,这一点你心里有数。Kolibri 让你把这件事写出来。它用两个模板参数描述一个 Java 方法:方法名(字符串)和签名(一个普通的 C++ 函数类型)。在函数类型里加上 noexcept,签名就表示这个方法不抛异常。Kolibri 是这样描述 java.util.Iterator 的:

struct JIterator : JavaClass<JIterator> {
  static constexpr std::string_view descriptor = "java/util/Iterator";

  static constexpr Method<"hasNext", jboolean() noexcept> hasNext{};
  static constexpr Method<"next", Ref<>()> next{};
};

hasNext() 不抛异常,所以它的异常检查在编译期就被去掉了。next() 会抛异常,检查保留。Java 标准库里很多方法都适用这一套。只有对确实不会抛异常的方法才安全,但既然你确定,就没必要为这个检查付出代价。

这个检查并不便宜。在我们的基准测试里,它给一次 ArrayList.size() 调用增加了约 24 ns,而不带检查时这次调用耗时 97 ns。相当于整个调用的四分之一。

显式传入 JNIEnv

每个 JNI 操作都要经过一个 JNIEnv,也就是用来和 VM 交互的句柄。它属于某个线程,所以用之前必须先拿到正确的那一个。

在 fbjni 里这是自动的。函数需要 env 时,会自己取当前的 env。这很方便,但代价是每次 fbjni 调用都要多做一点工作去找 env。

带作用域的混合对象

Kolibri 对混合对象有自己的做法,叫 NativeObject。概念和 fbjni 一样:一个 Java 对象和它对应的 C++ 对象,两者可以互相通信。区别在于如何释放:Kolibri 引入了作用域的概念。

作用域是一组共享生命周期的对象。创建 C++ 对象时,你可以把它绑定到一个作用域:当前作用域,或者你选定的任意一个。作用域失效时,Kolibri 会通知绑定到它的每个对象,各自释放依赖该作用域的资源。

这对 Expo Modules 很重要,因为我们在那里就是这么用混合对象的。它们让 Kotlin 能访问 JS 对象。于是就有了三方:状态存在 JS 里,Kotlin 代码想操作它,C++ 把两边粘起来。C++ 对象持有 JS 对象,同时是某个 Kotlin 对象的原生部分。两者绑定在一起,所以 Kotlin 对象被释放时,它的 C++ 部分也随之释放,JS 对象一并释放。

不过,很多情况下是 JS 侧先消失。比如一次页面重载就会销毁整个 JS 运行时和其中的所有 JS 对象,而指向它们的 Kotlin 对象还在等着垃圾回收。

我们不能留下任何残留。如果运行时销毁后 C++ 对象仍持有它的 JS 对象,就等于持有了一个已经失效的引用。之后 Kotlin 对象被垃圾回收时,C++ 对象会去清理一个早已释放的 JS 值,这是典型的 use-after-free。所以,凡是与运行时绑定的东西,都必须在运行时消失时一并释放。

Kolibri 作用域把混合对象绑定到 JS 运行时生命周期的示意图

作用域就是为此而生的。我们把每个混合对象绑定到它所属的 JS 运行时,运行时销毁时就使该作用域失效。下面是我们在 Expo Modules 2.0 中使用的类的简化版本:

class JavaScriptObject : public kolibri::ScopedNativeObject<JavaScriptObject> {
public:
  JavaScriptObject(jsi::Runtime* runtime, jsi::Object object)
    : runtime_(runtime), object_(std::move(object)) {
    bindToScope(runtime_);
  }

  // Called when the scope is invalidated.
  void onScopeInvalidated() { object_.reset(); }

private:
  jsi::Runtime* runtime_;
  std::optional<jsi::Object> object_;
};

// When the JS runtime is torn down:
kolibri::invalidateScope(runtime);

Kolibri 的混合对象从一开始就围绕这一机制构建,因此比 fbjni 里要简单得多。

调用混合对象更快

在 Kolibri 中,调用混合对象也更快。原因要从它们的工作方式说起。

在 C++ 里创建对象,拿到一个指向它的指针。这个指针被传给 Java,保存在 Java 对象的一个字段里:一个普通的 long,一个表示地址的数字。Java 对象拥有这个 C++ 对象。

现在假设你在 Java 对象上调用方法 x,而 x 绑定到了一个 C++ 方法。JNI 把 native 方法绑定到普通函数上,没有 this。所以 C++ 侧必须想办法找到这个对象。办法是现成的:指针就在 Java 对象里。在 fbjni 的经典混合对象中,取到这个指针还要再经过五次 JNI 调用:三次字段读取,依次进行,外加两次调用,用来删除前两次读取产生的局部引用。然后指针被转换回 C++ 类型。

fbjni 混合对象指针查找与 Kolibri 以参数传递指针的对比示意图

Kolibri 通过 Kotlin 编译器插件跳过这些调用。这与 SDK 56 中 Expo Modules 移除反射属于同一类构建期改写。你写的代码与使用 fbjni 时别无二致:在 Kotlin 类上定义一个 external 方法,再用 @NativeMethod 标注它:

class JavaScriptObject(pointer: NativePointer) : NativeObject(pointer) {
  @NativeMethod
  external fun hasProperty(name: String): Boolean
}

插件在底层改写这个方法,让指针作为第一个参数传给 C++:

// The equivalent of what the compiler emits:
fun hasProperty(name: String): Boolean = hasProperty(nativePointer, name)

private external fun hasProperty(pointer: Long, name: String): Boolean

C++ 侧直接注册成员函数,Kolibri 将指针转回对象,全程不经过 JNI 调用:

kolibri::registerNative<JavaScriptObject>(env)
  .method<&JavaScriptObject::hasProperty>("hasProperty")
  .commit();

参数也是同样的道理。对于本身就是 NativeObject 的参数,用 @AsNativePointer 标注后,其指针便以同样的方式跨过边界。

在我们的基准测试中,调用一个混合对象从 fbjni 的 76–138 ns 降至 2.7 ns。

用二进制格式传递数据

Kolibri 并不只服务于 C++ 和 Kotlin 之间的通信。它的主要任务是支撑 Expo Modules 2.0,而那里的数据来自 JS。因此必须审视整条路径。

以一个 JS 对象为例。它是一个键值存储,到了 Kotlin 侧就变成一个 map。常规做法是在 C++ 里构建这个 map:对每个键和每个值,都通过 JNI 创建对应的 Java 对象。TurboModules 和 Expo Modules 1.0 就是这么做的。这能跑通,但会产生大量 JNI 调用,而正如本文通篇所示,JNI 正是调用中最慢的一环。

我们问自己:真的需要这么多次调用吗?于是对替代方案做了基准测试:不同的格式,以及不同的数据传递方式。最终我们选择了放在直接字节缓冲区里的二进制格式。直接 ByteBuffer 是一个指向原生内存的 Java 对象,因此 C++ 和 Kotlin 操作的是同一份字节。

JS 数据经由共享二进制缓冲区进入 Kotlin 的示意图

Kolibri 为此提供了基础原语。每个线程分配一个缓冲区,在 C++ 中分配,再通过 BinaryBuffer.shared() 以直接 ByteBuffer 的形式暴露给 Kotlin。Kolibri 还定义了二进制格式:布尔值、数字、字符串、列表、map 和字节数组都带标签,另有一个标签供使用方定义自己的 schema。两侧读写该格式的代码也一并提供。

Expo Modules 2.0 在 C++ 中把 JS 对象写入缓冲区的做法如下(略有简化):

jsi::Array names = object.getPropertyNames(rt);
size_t size = names.size(rt);

out.writeTag(Tag::MAP);
out.write(static_cast<int32_t>(size));
for (size_t i = 0; i < size; i++) {
  jsi::String key = names.getValueAtIndex(rt, i).getString(rt);
  writeJsiString(rt, key, out);
  encodeToBufferDynamic(rt, object.getProperty(rt, key), out);
}

Kotlin 从同一块内存读回来的做法如下:

fun readDynamicMap(buffer: BinaryBuffer): HashMap<String, Any?> {
  val count = buffer.getMapSize()
  val map = HashMap<String, Any?>(count * 4 / 3 + 1)
  repeat(count) {
    map[buffer.getString()] = readDynamic(buffer)
  }
  return map
}

两个循环都没有发起 JNI 调用。为了看清这省下了什么,我们测量了一次从 JS 调用 Kotlin 函数的过程:该函数接收一个参数,返回一个 Int。参数用两种方式传入:

  • 用 JNI 构建为 Java 对象: C++ 通过 JNI 为每个值创建一个 Java 对象:每个数字一个 Double,每个布尔值一个 Boolean,每个字符串一个 String,每个数组一个 ArrayList,每个对象一个 HashMap。每次 add 和 put 都是一次额外的 JNI 调用。
  • 写入二进制缓冲区: C++ 把参数写入缓冲区,Kotlin 从缓冲区读取并构建同样的 Java 对象,全程没有 JNI 调用。

两种方式传给 Kotlin 函数的值相同,结果也以同样的方式返回,唯一的差别是参数如何到达 Kotlin。我们测量了字符串、对象和数组。每个数字都是整次调用的耗时,从 JS 到 Kotlin 再返回:

JS 参数用 JNI 构建为 Java 对象写入二进制缓冲区加速比
一个 16 字符的字符串0.31 µs0.20 µs1.5×
一个含 4 个字段的对象2.14 µs0.71 µs3.0×
一个含 12 个字段的对象5.68 µs1.71 µs3.3×
一个含 48 个值的嵌套对象27.8 µs5.8 µs4.8×
一个含 100 个 4 字段对象的数组211 µs52 µs4.0×
一个含 100 个混合值的数组23.9 µs2.7 µs8.8×
一个含 100 个数字的数组27.7 µs2.2 µs12.4×
一个含 1,000 个数字的数组272 µs21 µs13.0×

这就是 Expo Modules 2.0 传递数据所用的格式,下一篇我们会更详细地看它的工作原理。

性能

为了看清这一切带来了什么,我们在两个方向上把 Kolibri 与裸 JNI 和 fbjni 做了基准测试:C++ 调用 Java,以及 Kotlin 调用 C++ 对象。三种方式在相同的对象上做同样的工作,并且都像真实代码那样,在开头一次性查找类、方法和字段。裸 JNI 作为基线。

我们在 Apple M3 Pro 上运行了全部测试,JDK 17。对于从 C++ 调用 Java,每个数字是 10 轮、每轮 200 万次调用的单次调用耗时中位数,且已排除预热阶段。该基准测试跑了三次(方法调用表跑了六次),取各次运行的中位数。从 Kotlin 调用 C++ 的部分用 JMH 测量。这是桌面 JVM,不是 Android 设备,手机上的绝对耗时会有差异。

从 Kotlin 调用 C++ 对象

差异最大的地方就在这里。每个基准测试都会调用一个混合对象上的方法,该方法返回一个存放在 C++ 部分的值。

raw JNI、Kolibri 和 fbjni 的混合对象调用耗时基准图

表中第二列还会把另一个混合对象作为参数传入,因此 C++ 侧需要查找两个对象。

C++ 侧如何找到对象一个对象两个对象
raw JNI,指针作为参数传入2.5 ns2.5 ns
Kolibri,@NativeMethod2.7 ns2.7 ns
raw JNI,用 GetLongField 读取指针4.1 ns6.6 ns
fbjni,HybridClassBase76 ns143 ns
fbjni,经典的 mHybridData138 ns272 ns

借助编译器插件,调用 Kolibri 对象的开销与手写 raw JNI 传指针基本相当,比通过 fbjni 做同样的调用快 28 到 51 倍。为了找到 C++ 对象,fbjni 的 cthis() 要读取两到三个字段。除最后一个之外,这些字段都持有 Java 对象,因此每次读取都会创建一个局部引用,之后还得再删掉。较新的 HybridClassBase 写法少一次读取、少一次删除,于是 JNI 调用从五次降到三次,速度约为经典写法的两倍。

传指针也比手动用 GetLongField 读取更快,不过在桌面 JVM 上差距更小:2.7 ns 对 4.1 ns。

调用本身也更便宜。一个带两个 int 参数、不带对象的静态 native 方法,用 raw JNI 和 Kolibri 都是 2.2 ns,用 fbjni 则是 9.6 ns。

从 C++ 调用 Java

调用原生 JNIKolibrifbjniKolibri 对比 fbjni
ArrayList.size()98 ns96 ns134 ns−29%
ArrayList.get(i)142 ns140 ns160 ns−12%
HashMap.put("key", value)240 ns241 ns302 ns−20%
Integer.MAX_VALUE(读取静态字段)25 ns25 ns31 ns−18%

在这些场景下,Kolibri 和原生 JNI 一样快:每一行的差距都在 2% 左右,落在基准测试的噪声范围内。原生 JNI 代码需要手动检查异常、手动删除局部引用,Kolibri 替你完成同样的工作,不额外付出代价。

与 fbjni 差距最大的是 size()。这个方法在 Kolibri 里标记为 noexcept,因此完全不做异常检查,而 fbjni 仍然会检查一次。为了单独测量这次检查的开销,我们又把检查加回去调用了 size()。原生 JNI 从 97 ns 变成 121 ns,Kolibri 从带 noexcept 的 96 ns 变成不带它的 121 ns。而始终做检查的 fbjni 需要 134 ns。

其余日常操作的差距也差不多:

操作原生 JNIKolibrifbjni
静态方法调用(Integer.valueOf)144 ns140 ns164 ns
构造函数(new,一个 int 参数)163 ns170 ns159 ns
带四个 int 参数的方法125 ns126 ns132 ns
读取 int 字段2.1 ns2.1 ns7.4 ns
写入 int 字段26 ns26 ns31 ns
创建并删除局部引用55 ns53 ns96 ns
创建并删除全局引用78 ns82 ns120 ns
把 int[256] 复制进 std::vector115 ns113 ns136 ns
查找当前 JNIEnv3.2 ns1.6 ns5.6 ns

除了 JNIEnv 查找这一行 Kolibri 更快之外,其余每一行 Kolibri 与原生 JNI 的差距都在 5% 左右。fbjni 除了构造函数与原生 JNI 持平之外,其余各项都更慢。

为什么不改进 fbjni?

Kolibri 是为我们的用例量身定做的,不是通用 JNI 库。它只包含我们需要的东西,API 刻意保持精简。fbjni 覆盖的范围大得多,其中大部分我们并不需要。比如 Kolibri 几乎没有为 java.lang 类提供封装,需要的话得自己写。

要让 fbjni 达到同样的效果,就意味着对库做大改。Kolibri 覆盖了与 fbjni 相同的范围,但针对同一个问题走了另一条路。

取舍

Kolibri 并非没有代价。React Native 已经自带 fbjni,所以基于 fbjni 的代码不需要额外付出任何代价:不用引入新库,也不会增加应用体积。而 Kolibri 是你应用里多出来的一个库,也是我们要多维护的一个库。

不过这个代价比看上去要小。要让 fbjni 达到我们需要的速度,我们得在很多地方绕开它。那同样是一大堆额外的代码,体积最后大概也差不多。归根结底,我们认为这个取舍是值得的。

接下来

Kolibri 是基础。下一篇我们会讲 Expo Modules 2.0 如何构建在它之上,以及其余的提速来自哪里。

如果你为 Android 编写原生模块,欢迎到 Expo Discord 的 #creating-expo-modules 频道聊聊你的看法。

来源: Expo Blog← 返回首页