Expo SDK 56 は、Android 上の Expo Modules から反射を排除する Kotlin コンパイラプラグインを搭載しています。結果は次のとおりです: モジュール初期化は約 70% 高速化、time to first render は約 30% 短縮、Record の変換は SDK 55 と比べて約 6 倍高速化しました。アプリをビルドしているだけで、これらの恩恵は自動的に得られます — プラグインはコンパイル時に動作し、あなた側でのコード変更は不要です。モジュールを保守している場合、Record の高速化は注釈を 1 つ追加するだけで済みます。本記事では、ここに至るまでの経緯と、なぜ他の手法でうまくいかなかったのかを説明します。Apple 側(Swift が JSI と直接やり取りするようになった件)については、対応する投稿 Talking to JSI in Swift を参照してください。
反射とその歴史
Expo Modules が存在する前、我々は Unimodules を使っていました。これらは古い React Native ブリッジモジュールに近い動作をしており、公開したいメソッドに注釈を散らしておくと、ランタイムが反射によってそれらを発見しました。例:
class ClipboardModule(context: Context) : ExportedModule(context) {
override fun getName() = "ExpoClipboard"
@ExpoMethod
fun getStringAsync(promise: Promise) {
val clip = clipboardManager.primaryClip?.getItemAt(0)
promise.resolve(clip?.text?.toString() ?: "")
}
@ExpoMethod
fun setStringAsync(content: String, promise: Promise) {
clipboardManager.setPrimaryClip(ClipData.newPlainText(null, content))
promise.resolve(true)
}
}
自分のコードに関するメタデータが必要な場合、反射は当然の選択肢です。どのメソッドをエクスポートしているか、引数の型は何か、JVM に聞けば分かります。しかし反射にはコストがあり、Android ではそのコストが起動時間に直接響きます。ランタイムが解析するモジュールが増えるごとに、ユーザーが何かを見るまでにミリ秒が追加されます。
Expo Modules API を構築し始めたとき、我々はふたつを求めました: より良いエルゴノミクス(使い勝手)と反射の削減。Kotlin の DSL は容易に導入でき、ほとんどの反射を一挙に取り除けたため即効性がありました。しかし DSL だけでは関数引数や Record プロパティの型情報を取り除けませんでした。これらを解決するには依然としてランタイムの反射(具体的には typeOf<T>() 呼び出しやその背後のメタデータ参照)が必要で、それが残ったコストでした。
反射の実コスト
残っていたコストは主に二つの部分から来ます。
-
型パラメータの再構築
DSL は typeOf<T>() 経由で引数や戻り値の型を読み取ります。T が reified であるため動作します。通常 JVM はジェネリクスを消去するため、ランタイムで T が何であったかを問うことはできません。reified 型パラメータはこれを回避しますが、typeOf は inline 関数として各コールサイトに実体がコピーされ、実際の型に置換されます。こうして取得した型情報は多くのケースで安価ですが、関数が多数あるモジュールやネストしたジェネリクスが深い場合には累積コストになります。
-
Record の変換(より重い方)
Record はネイティブ側での JS オブジェクトの型付き表現です。Record を変換するにはランタイムでその形状を発見する必要があります: どのプロパティを宣言しているか、どれが JS に公開されるか、それぞれの型は何か。これには複数層の反射が絡みます。クラスの memberProperties を JVM に問い合わせ、各プロパティの注釈と型を取得し、書き込み可能にするためにフィールドを accessible にしてから値を設定します。
しかもその情報の全部がバイトコードに直接含まれているわけではありません。JVM はクラスとそのメンバーを知っていますが、Kotlin の型システムに関する情報は持ちません。Kotlin の反射ライブラリはコンパイラが生成するバイナリブロブである @Metadata 注釈をパースしてその情報を再構築する必要があります。これが回避しにくいコストです。
トップレベルのヌラビリティ(nullable かどうか)のような単純なケースは、reified T を使った null is T のチェックで回避できますが、List<T> のようなネストしたケースは別です。JVM はジェネリクスを消去するため型引数がバイトコードに残らず、Kotlin のヌラビリティ情報も持っていません。唯一その情報が残っているのは @Metadata 注釈だけで、これを読む以外に近道はありません。つまり我々が避けたかった処理にどうしても戻されます。
コード生成を選ばなかった理由
通常この種の問題の標準的な解決策はコード生成です。Java/Kotlin には注釈プロセッサ(kapt) や KSP(Kotlin Symbol Processing)などビルド時にソースを生成して型メタデータを事前計算するツールがあります。React Native の TurboModules が使うような、コンパイル前に走るスタンドアロンのコード生成ツールも検討しましたが、いくつか問題がありました。
- 生成コードがプロジェクトの一部になってしまう。コールスタックに現れ、デバッガでステップインすることになり、JS とネイティブ間の橋渡しで何かが壊れたときに読むのは機械生成されたコードになります。
- kapt と KSP はファイルを追加することしかできず、既存ファイルを修正できない。Record クラスをその場で拡張するのではなく、まったく別の並列クラスを生成することになります。
- スタンドアロンツールはビルドに別のステップを追加し、ツールチェーンとの統合やメンテナンスの負担を増やします。
しばらくの間、我々はこれらの妥協を受け入れ、反射コストとともに生きるしかありませんでした。
K2 で何が変わったか
Kotlin 2.0 と新しい K2 コンパイラが登場し、可能性を変えました。kapt と KSP の「追加のみ」制限を変えるのが K2 のコンパイラプラグイン API です。これによりコンパイラが生成する中間表現 (IR) にアクセスでき、コンパイル過程でコードを編集できます。コードはバイトコードに下げられる前の形で編集され、もし無効なものを生成した場合はコンパイラが検出します。変換された IR に対してテストを書くことも可能です。
コード生成とは異なり、結果はプロジェクトに残る並列レイヤーではなく、限定的で外科的な差し替えです。バイトコードを直接改変する手もありますが、あまりにも壊れやすく、特定の Android バージョンでのみランタイム破壊が起こるような問題を生みやすい。K2 のプラグイン API はその力を安全網付きで提供します。
プラグインが行うこと
アイデアは単純です: ランタイムで反射が発見するものは、コンパイル時には既にコンパイラが知っている。K2 API を使ったプラグインは、それに対処して先述の二つの高コストな操作を解消します。
-
組み込みの型記述子(Baked-in type descriptors)
Expo Module が型情報を必要とするたびに typeDescriptorOf<T>() を呼び出します。この関数自体は実行されたら例外を投げるスタブです:
fun <T> typeDescriptorOf(): PTypeDescriptor = throw NotImplementedError(
"typeDescriptorOf<T>() should be replaced by the compiler plugin"
)
これはコードをコンパイルさせるために存在するだけで、実行されるべきではありません。コンパイル時にプラグインはすべての typeDescriptorOf<T>() 呼び出しをインターセプトし、事前計算された型記述子オブジェクトへの直接参照に置き換えます。例えば:
// あなたが書くもの:
typeDescriptorOf<List<Int>>()
// コンパイラが出力する等価コードの例:
PTypeDescriptorRegistry.getOrCreateParameterized(
List::class.java,
isNullable = false,
parameters = arrayOf(
PTypeDescriptorRegistry.getOrCreateConcrete(
Int::class.java,
isNullable = false
)
)
)
typeDescriptorOf<T>() は typeOf<T>() の軽量版と考えることができます。両者とも型を記述するオブジェクトを返しますが、typeOf が完全な KType を返すのに対し、我々の PTypeDescriptor は実際に使う情報だけを持ちます: Class<?> 参照、ヌラビリティフラグ、パラメータ記述子のリスト。Kotlin 反射ライブラリへの依存はありません。単純形状にすることでアロケーションも抑えられます。String や Int のような単純型の場合はレジストリが静的に確保されたフィールドを返すためアロケーションは発生しません。パラメータ化されたジェネリクスについては記述子をキャッシュ・重複排除するためコストは一度だけ支払われます。JVM マイクロベンチマークでは、複雑な型(例: Map<String, List<Int?>>)でもこの方法で記述子を構築する方が typeOf よりおおよそ 2x 高速でした。
-
組み込みの Record メタデータ(Baked-in Record metadata)
反射負荷の高い変換に対する対処は 1 つの注釈です。Record に @OptimizedRecord を付けるとプラグインが介入します:
@OptimizedRecord
class UserRecord : Record {
@Field val name: String = ""
@Field val age: Int = 0
@Field val address: AddressRecord? = null
}
この注釈は opt-in(明示的選択)です。@OptimizedRecord が付いたクラスについて、プラグインはコンパイル時に SDK 55 の起動時に行っていたことと同様の処理を行い、プロパティ名・型・注釈を読み取り、それらをプレーンなオブジェクトとしてバイトコードに焼き込みます。さらに、インデックスベースの単純なディスパッチを用いる直接アクセス器を生成します。フィールド設定は「反射でアクセス可能にしてから設定する」から単純な代入に変わります。コンパイル済みのメタデータが存在すればランタイムは高速パスを取ります。注釈が付いていないかプラグインが走らなかった場合は、SDK 55 と同じ反射ベースの変換にフォールバックします。どちらにしてもモジュールは動作を維持します。
同様に、@OptimizedComposeProps を付けた Jetpack Compose のプロップも同様の処理を受け、フィールド変換の代わりにプロップ解決への最適化が適用されます。これは Android の宣言的 UI を多用するパッケージ(例: expo-ui)にとって重要でした。
パフォーマンス
得られる改善量は、アプリが使う Expo Modules の数やエクスポートしている型に依存します。モジュール多めのテストアプリ(公式 Expo モジュールすべてと人気のあるサードパーティ TurboModules)についてクリーンなコールドスタートを計測した結果(150 回の平均、外れ値除去、2 台のデバイス: OnePlus 9 Pro と古い Samsung Galaxy S9):
- Android のモジュール初期化は約 70% 高速化
- Time to first render は約 30% 改善
- Record 変換は約 6x 高速化
(上の数値はモジュールの量・種類に依存します。実際のアプリでの効果はこれらに左右されます。)
何をする必要があるか
-
アプリ開発者: 何もしなくて大丈夫です。コンパイラプラグインは SDK 56 で自動的に実行され、typeDescriptorOf の置換はすべての型に対してコード変更なしに適用されます。
-
Expo モジュールを保守している人: Record を使っている場合は高速化のために @OptimizedRecord を追加してください:
@OptimizedRecord
class MyConfig : Record {
@Field val apiUrl: String = ""
@Field val timeout: Int = 30
}
Compose 統合でプロップを使っている場合は @OptimizedComposeProps を付けてください:
@OptimizedComposeProps
data class MyViewProps(
val title: MutableState<String> = mutableStateOf(""),
val count: MutableState<Int> = mutableIntStateOf(0)
) : ComposeProps
注釈を付けなくても壊れることはありません。モジュールは SDK 55 と同じ反射ベースの変換にフォールバックしますが、Record に対する 6x の高速化は得られません。
今後
これは終着点ではありません。現状プラグインは型メタデータと Record 変換を扱っていますが、同じアプローチは関数ディスパッチなどモジュールライフサイクルの他の部分にも適用できます。我々はプラグイン自体への投資も続け、機能を拡張しやすく、書きやすくすることで、メンテナンスコストを増やさずに適用範囲を広げたいと考えています。目標は変わりません: 今日モジュール作者が書いている使いやすい API を維持しつつ、さらに多くの作業をコンパイル時に移行することです。