Expo Modules 2.0 の新しい JNI レイヤー
Kolibri は Expo Modules 2.0 が Android で使う JNI ライブラリだ。安全ラッパーを任意にし、C++ ポインタをそのまま Kotlin に渡し、JS データは共有バイナリバッファ経由で流す。
日本語
コピー

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 は Java オブジェクトを JNI 呼び出しの繰り返しで組み立てる代わりに、共有バイナリバッファの中で 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 と 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 のコードを書く方法は実際には2つあり、両者はうまく共存できる。
- 何をしているか分かっている場合。 安全機能を自分から切る。自動メモリ管理も例外チェックもない。後始末はすべて自分の責任で、その代わりに 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++ 側で例外を投げず、例外を保留状態にする。呼び出しの後に自分で確認しなければならない。このチェック自体が1回の JNI 呼び出しで、やはり時間がかかる。デフォルトでは、Kolibri が毎回の呼び出し後にこのチェックを行い、保留中の Java 例外を C++ 例外として投げ直す。
多くの Java メソッドはそもそも例外を投げない。それは分かっている話だ。Kolibri はそれをコードに書かせてくれる。Java メソッドは2つのテンプレート引数で記述する。メソッド名(文字列)とシグネチャ(普通の 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 で済む。呼び出し全体の4分の1に相当する。
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 だ。だからランタイムに束縛されたものはすべて、ランタイムが消えるときに一緒に解放されなければならない。

スコープはまさにこのためにある。各ハイブリッドオブジェクトを、それが属する 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、つまりアドレスを表す数値だ。C++ オブジェクトを所有しているのは Java オブジェクトである。
ここで、Java オブジェクトのメソッド x を呼び出し、その x が C++ のメソッドにバインドされているとしよう。JNI は native メソッドを通常の関数にバインドするだけで、this はない。だから C++ 側はこのオブジェクトを何とかして見つけなければならない。手段はすでにある。ポインタは Java オブジェクトの中にあるのだ。fbjni の従来のハイブリッドオブジェクトでは、このポインタを取り出すのにさらに 5 回の JNI 呼び出しが必要だった。3 回のフィールド読み取りを順に行い、加えて最初の 2 回の読み取りで生じたローカル参照を削除する呼び出しが 2 回。その後、ポインタは C++ の型に変換し直される。

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 が同じバイト列を扱うことになる。

Kolibri はこのための基本プリミティブを提供する。スレッドごとにバッファを 1 つ確保し、C++ で割り当て、BinaryBuffer.shared() を通じてダイレクト ByteBuffer として Kotlin に公開する。バイナリ形式も定義している。真偽値、数値、文字列、リスト、map、バイト配列にそれぞれタグが付き、利用側が独自のスキーマを定義するためのタグも 1 つある。この形式を両側で読み書きするコードも提供している。
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 関数を呼び出す処理を計測した。この関数は引数を 1 つ受け取り、Int を返す。引数の渡し方は 2 通り:
- JNI で Java オブジェクトを構築: C++ が JNI 経由で値ごとに Java オブジェクトを生成する。数値ごとに
Double、真偽値ごとにBoolean、文字列ごとにString、配列ごとにArrayList、オブジェクトごとにHashMap。addとputのたびに JNI 呼び出しが 1 回増える。 - バイナリバッファに書き込む: C++ が引数をバッファに書き込み、Kotlin がバッファから読み取って同じ Java オブジェクトを構築する。JNI 呼び出しは一切ない。
どちらの方式も Kotlin 関数に渡る値は同じで、結果も同じように返る。違うのは引数がどう Kotlin に届くかだけだ。文字列、オブジェクト、配列を計測した。数値はすべて、JS から Kotlin へ行って戻るまでの呼び出し 1 回分の所要時間である:
| JS の引数 | JNI で Java オブジェクトを構築 | バイナリバッファに書き込み | 高速化率 |
|---|---|---|---|
| 16 文字の文字列 1 つ | 0.31 µs | 0.20 µs | 1.5× |
| 4 フィールドのオブジェクト 1 つ | 2.14 µs | 0.71 µs | 3.0× |
| 12 フィールドのオブジェクト 1 つ | 5.68 µs | 1.71 µs | 3.3× |
| 48 個の値を持つネストしたオブジェクト 1 つ | 27.8 µs | 5.8 µs | 4.8× |
| 4 フィールドのオブジェクト 100 個からなる配列 | 211 µs | 52 µs | 4.0× |
| 混在した値 100 個からなる配列 | 23.9 µs | 2.7 µs | 8.8× |
| 数値 100 個からなる配列 | 27.7 µs | 2.2 µs | 12.4× |
| 数値 1,000 個からなる配列 | 272 µs | 21 µs | 13.0× |
これが Expo Modules 2.0 でデータをやり取りするときの形式だ。次回はその仕組みを詳しく見ていく。
パフォーマンス
これが何をもたらすのかを確かめるため、Kolibri を素の JNI および fbjni と、C++ から Java を呼ぶ方向と Kotlin から C++ オブジェクトを呼ぶ方向の両方でベンチマークした。3 つの方式は同じオブジェクトに対して同じ処理を行い、どれも実際のコードと同じように、冒頭でクラス・メソッド・フィールドの検索を一度だけ済ませる。素の JNI をベースラインとする。
すべてのテストは Apple M3 Pro、JDK 17 で実行した。C++ から Java を呼ぶ側では、各数値は 10 ラウンド、1 ラウンドあたり 200 万回の呼び出しにおける 1 呼び出しあたりの時間の中央値で、ウォームアップは除外してある。ベンチマークは 3 回実行し(メソッド呼び出しの表は 6 回)、各実行の中央値を取った。Kotlin から C++ を呼ぶ側は JMH で計測している。これはデスクトップ JVM であり Android 端末ではないので、スマートフォン上の絶対値は変わる。
Kotlin から C++ オブジェクトを呼ぶ
差が最も大きいのはここだ。各ベンチマークはハイブリッドオブジェクトのメソッドを呼び、そのメソッドは C++ 側に置かれた値を返す。

表の 2 列目では別のハイブリッドオブジェクトも引数として渡すため、C++ 側は 2 つのオブジェクトを探す必要がある。
| C++ 側がオブジェクトを見つける方法 | オブジェクト 1 つ | オブジェクト 2 つ |
|---|---|---|
| 素の JNI、ポインタを引数で渡す | 2.5 ns | 2.5 ns |
| Kolibri、@NativeMethod | 2.7 ns | 2.7 ns |
| 素の JNI、GetLongField でポインタを読む | 4.1 ns | 6.6 ns |
| fbjni、HybridClassBase | 76 ns | 143 ns |
| fbjni、従来の mHybridData | 138 ns | 272 ns |
コンパイラプラグインがあれば、Kolibri オブジェクトの呼び出しコストはポインタを渡す手書きの素の JNI とほぼ同じで、同じ呼び出しを fbjni 経由で行うより 28〜51 倍速い。C++ オブジェクトを見つけるために、fbjni の cthis() は 2〜3 個のフィールドを読む。最後の 1 つを除いてこれらのフィールドは Java オブジェクトを保持しているため、読み取るたびにローカル参照が作られ、あとで削除しなければならない。新しい方の HybridClassBase は読み取りが 1 回、削除も 1 回少なくて済むので、JNI 呼び出しは 5 回から 3 回に減り、従来の書き方の約 2 倍速くなる。
ポインタを渡す方法は GetLongField で手動で読むより速いが、デスクトップ JVM では差が小さい。2.7 ns 対 4.1 ns だ。
呼び出し自体も安く済む。int の引数を 2 つ取り、オブジェクトを取らない静的 native メソッドは、素の JNI でも Kolibri でも 2.2 ns、fbjni では 9.6 ns かかる。
C++ から Java を呼ぶ
| 呼び出し | ネイティブ JNI | Kolibri | fbjni | Kolibri 対 fbjni |
|---|---|---|---|---|
| ArrayList.size() | 98 ns | 96 ns | 134 ns | −29% |
| ArrayList.get(i) | 142 ns | 140 ns | 160 ns | −12% |
| HashMap.put("key", value) | 240 ns | 241 ns | 302 ns | −20% |
| Integer.MAX_VALUE(静的フィールドの読み取り) | 25 ns | 25 ns | 31 ns | −18% |
これらの場面では Kolibri はネイティブ JNI と同じくらい速い。各行の差は 2% 前後で、ベンチマークのノイズの範囲に収まっている。ネイティブ JNI のコードは例外の確認とローカル参照の削除を手作業で行う必要があるが、Kolibri は同じ処理を代わりにこなし、その分のコストを上乗せしない。
fbjni との差が最も大きいのは size() だ。このメソッドは Kolibri では noexcept と指定されているため例外チェックを一切行わないが、fbjni は依然として1回チェックする。このチェックのコストを単独で測るため、チェックを戻した状態で size() を呼び出した。ネイティブ JNI は 97 ns から 121 ns に、Kolibri は noexcept ありの 96 ns からなしの 121 ns になった。常にチェックする fbjni は 134 ns を要する。
日常的な操作の差もほぼ同様だ。
| 操作 | ネイティブ JNI | Kolibri | fbjni |
|---|---|---|---|
| 静的メソッド呼び出し(Integer.valueOf) | 144 ns | 140 ns | 164 ns |
| コンストラクタ(new、int 引数1つ) | 163 ns | 170 ns | 159 ns |
| int 引数4つのメソッド | 125 ns | 126 ns | 132 ns |
| int フィールドの読み取り | 2.1 ns | 2.1 ns | 7.4 ns |
| int フィールドの書き込み | 26 ns | 26 ns | 31 ns |
| ローカル参照の作成と削除 | 55 ns | 53 ns | 96 ns |
| グローバル参照の作成と削除 | 78 ns | 82 ns | 120 ns |
| int[256] を std::vector にコピー | 115 ns | 113 ns | 136 ns |
| 現在の JNIEnv の取得 | 3.2 ns | 1.6 ns | 5.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 はアプリに追加するライブラリであり、私たちが保守するライブラリが1つ増えるということでもある。
ただしこのコストは見た目より小さい。fbjni を必要な速度まで引き上げるには、多くの箇所で fbjni を迂回しなければならない。それも相応の追加コードになり、最終的なサイズはおそらく同程度になる。結局のところ、このトレードオフは割に合うと判断している。
今後
Kolibri は土台だ。次の記事では、Expo Modules 2.0 がその上にどう構築されているか、そして残りの高速化がどこから来ているかを説明する。
Android 向けのネイティブモジュールを書いている方は、Expo Discord の #creating-expo-modules チャンネルで意見を聞かせてほしい。