Kotlin コンパイラプラグインが Android の初回レンダリング時間を 30% 短縮する仕組み

SDK 56 で追加された Kotlin コンパイラプラグインは、Android 上で Expo Modules のリフレクション呼び出しを排除した。初期化が 70% 高速になり、アプリ開発者側のコード変更は不要だ。

日本語
コピー
How a Kotlin compiler plugin cut Android time to first render by 30%

Expo SDK 56 には Kotlin コンパイラプラグインが同梱され、Android 上の Expo Modules がリフレクションに依存しなくなった。数字で言えば、モジュールの初期化は 70% 高速化し、初回レンダリングまでの時間は 30% 短縮され、Record の変換は SDK 55 の約 6 倍速くなっている。

アプリを開発する側なら、この恩恵は自動的に受けられる。プラグインはコンパイル時に動くため、こちらのコードを変更する必要はない。モジュールをメンテナンスする側なら、Record の高速化まであとアノテーション 1 つだ。

この記事では、どうやってここまで来たのか、そしてなぜこの方式がうまくいき、他の方式がうまくいかなかったのかを書く。Apple 側では現在 Swift が直接 JSI と通信している。詳しくは姉妹記事の Talking to JSI in Swift を参照してほしい。

リフレクションと、その歴史

Expo Modules が登場する前は Unimodules があった。使い方は古い React Native bridge modules とよく似ていた。公開したいメソッドにアノテーションを付け、実行時にリフレクションで全部を発見する。

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 では、そのコストが起動時間に直接のしかかる。実行時にモジュールを 1 つ内省するたび、ユーザーが画面を見るまでの待ち時間が数ミリ秒増える。

今の Expo Modules API を作り始めたとき、欲しかったのは 2 つだった。使いやすさの向上と、リフレクションの削減。Kotlin DSL は楽に取れた一局で、使いやすさの問題を一気に解決し、リフレクションの大部分も取り除いた。だが取り除けなかったものがある。関数の引数と Record プロパティの型情報だ。これを解析するには依然として実行時リフレクションが必要で、具体的には typeOf<T>() の呼び出しとその裏のメタデータ検索が発生する。DSL だけでは解消できないコストだった。

リフレクションの実際のコスト

残ったコストは 2 つに分かれる。1 つ目は型パラメータの再構築。DSL は typeOf<T>() を通じて引数と戻り値の型を読むが、これが成り立つのは Treified だからだ。JVM は通常ジェネリクスを消去するので、実行時に T が何なのかを問い合わせることはできない。コードが動く時点で、その情報はもう残っていない。Reified 型パラメータはこれを回避し、具体型を読めるようにする。これが可能なのは typeOf が inline 関数の中にあり、コンパイラがそれを各呼び出し箇所にコピーして実際の型に置き換えるからだ。この方法での型情報取得は多くの場合わずかなコストで済むが、モジュールに多数の関数があったりジェネリクスが深くネストしていたりすると、コストが積み上がる。

2 つ目の問題はもっと厄介で、Record の変換だ。Record はネイティブ側における JS オブジェクトの型付き表現だ。Record を変換するには、実行時にその構造を突き止めなければならない。どのプロパティが宣言されているか、どれが JS に公開されているか、各プロパティの型は何か。

この突き止め作業はコストが高い。複数層のリフレクションが絡むからだ。JVM にクラスの memberProperties を要求し、プロパティごとにアノテーションと型を調べ、フィールドを書き込み可能にする。しかもこれらの情報がすべてバイトコードから直接取れるわけではない。JVM はクラスとそのメンバーを把握しているが、Kotlin の型システムは把握していない。Kotlin リフレクションライブラリは @Metadata アノテーションを解析することでしかこれらの情報を復元できない。それはコンパイラが生成するバイナリデータの塊だ。

このうちいくつかは回避できる。たとえばトップレベルの null 許容性に完全なリフレクションは要らない。reified な T を使えば、簡単な null is T チェックで判定できる。ネストしたケース(List<T> の中の T など)はそうはいかない。JVM はジェネリクスを消去するので、実行時のバイトコードに型パラメータは残っておらず、Kotlin の null 許容性も理解しない。これらの情報が残っている唯一の場所が @Metadata アノテーションで、これを読む近道は存在しない。メタデータを解析するしかなく、それこそが避けたかったコストだ。

なぜコード生成を選ばなかったか

この種の問題の標準的な解決策はコード生成で、Java にも Kotlin にも成熟したツールチェーンがある。アノテーションプロセッサ(kapt)と Kotlin Symbol Processing API(KSP)はビルド時に動き、型メタデータをすべて事前に計算したソースファイルを生成できるので、実行時にリフレクションに触れずに済む。コンパイル前に動く独立したコード生成ツール、たとえば React Native が TurboModules に使っているものも検討した。

調べた結果、我々は納得しなかった。1 つ目の問題は、生成されたコードがプロジェクトの一部になることだ。呼び出しスタックに現れ、デバッグ時にステップインしなければならない。JS とネイティブの間のブリッジで問題が起きれば、読むのは機械生成のコードだ。誰もそんなものをデバッグしたくない。2 つ目の問題は、kapt と KSP はファイルを追加できるだけで、既存のファイルを変更できないことだ。ある Record クラスをその場で拡張することはできず、完全に平行なクラスをゼロから生成するしかない。独立したツールはこれらの問題を別の問題に置き換えるだけだ。ビルドのステップが増え、ツールチェーンとの結合が強くなり、メンテナンスするものがまた 1 つ増える。

しばらくの間、我々はここで足踏みしていた。リフレクションのコストを受け入れつつ、もっと良い方法がないか目を光らせながら。

K2 がもたらしたもの

その後 Kotlin 2.0 が新しい K2 コンパイラとともにリリースされ、状況が変わった。kapt と KSP はコードを追加することしかできないが、K2 はちょうどこの制限を取り払った。新しいコンパイラプラグイン API を使えば、コンパイラが生成する中間表現(IR)を取得できる。コンパイラから見たコード、まだバイトコードに降格される前のコードに対して変更を加えるのだ。不正なものに書き換えてしまえばコンパイラがエラーを出し、変換後の IR に対してテストも書ける。コード生成と違い、結果は長く付き合わされる平行コードの層ではなく、明確に定義された位置への小さく正確な置き換えだ。

ずっと前からバイトコードを直接書き換えられることは分かっていた。だが、そんな手法を保守するつもりは一度もなかった。壊れやすすぎるし、特定の Android バージョンで動かしたときだけクラッシュするようなコードを書いてしまうリスクが高すぎる。コンパイラプラグイン API は同じ能力を、しかも本物の安全網付きで与えてくれる。

プラグインがやっていること

考え方は単純だ。リフレクションが実行時に見つけるものを、コンパイラはビルド時にすでに知っている。プラグインは K2 API で構築され、この点を利用して、以前触れた最もコストの高い 2 つの処理に切り込む。

1. 型記述子の事前構築

Expo Module が型情報を必要とするたびに typeDescriptorOf<T>() を呼ぶ。この関数自体はスタブで、実際に実行されれば例外を投げる:

fun <T> typeDescriptorOf(): PTypeDescriptor =
  throw NotImplementedError(
    "typeDescriptorOf<T>() should be replaced by the compiler plugin"
  )

存在するのはコードをコンパイルを通すためだけであり、実行されてはならない。コンパイル中、プラグインは typeDescriptorOf<T>() の呼び出しをすべて横取りし、事前に計算済みの型記述子オブジェクトへの直接参照に置き換える:

// What you write:
typeDescriptorOf<List<Int>>()

// The equivalent of what the compiler emits:
PTypeDescriptorRegistry.getOrCreateParameterized(
  List::class.java,
  isNullable = false,
  parameters = arrayOf(
    PTypeDescriptorRegistry.getOrCreateConcrete(
      Int::class.java, 
      isNullable = false
    )
  )
)

typeDescriptorOf<T>() は、我々が独自に作ったより簡素な typeOf<T>() だと考えればいい。どちらも型を記述するオブジェクトを返すが、typeOf が完全な KType を返すのに対し、こちらは PTypeDescriptor(P は Pika、プラグインの内部コードネーム)を返す。含むのは実際に使うものだけ——Class<?> への参照、null 許容フラグ、引数記述子のリストであり、Kotlin リフレクションライブラリには依存しない。

構造が簡素なぶん、アロケーションのコストも下がる。StringInt のような単純な型では、レジストリが事前確保済みの静的フィールドをそのまま返すので、アロケーションは一切発生しない。引数を持つジェネリクスでは、記述子をキャッシュしモジュール間で重複排除するため、コストは一度しか払わない。JVM のマイクロベンチマークでは、Map<String, List<Int?>> のような複雑な型の記述子構築が typeOf より約 2 倍速い。

2. コンパイル時に固定する Record メタデータ

先ほど触れたリフレクションによる変換は、アノテーション 1 つで解決する。Record に @OptimizedRecord を付ければ、プラグインが引き受ける:

@OptimizedRecord
class UserRecord : Record {
  @Field val name: String = ""
  @Field val age: Int = 0
  @Field val address: AddressRecord? = null
}

このアノテーションがスイッチだ。@OptimizedRecord が付いたクラスに対し、プラグインは SDK 55 が起動時にやっていたことをコンパイル時に行う。プロパティ名・型・アノテーションを読み取り、それらを通常のオブジェクトとしてそのままバイトコードに書き込み、インデックスベースのディスパッチで直接アクセサを用意する。フィールドの設定は「まずリフレクションでアクセス可能にしてから代入」から、単なる代入へと変わる。

コンパイル済みメタデータがあれば実行時は速い経路を通り、なければ——アノテーションを付け忘れたか、プラグインが動いていないか——SDK 55 と同じリフレクション変換にフォールバックする。どちらの場合もモジュールは正常に動作する。

Record と同様、@OptimizedComposeProps を付けた Jetpack Compose の props も同じ扱いを受ける。ただし作用するのはフィールド変換ではなく prop の解決だ。これは重要で、expo-ui のように Android の宣言的 UI に大きく依存するパッケージでは、prop 解決こそ最大のボトルネックのひとつだからだ。

パフォーマンス

どれだけ効くかは、アプリがいくつの Expo Module を使い、それらがどんな型をエクスポートしているかによる。モジュールを大量に使うテストアプリ(公式 Expo モジュールすべてと、最も普及しているサードパーティ TurboModules を含む)のコールドスタートを、2 台の端末(OnePlus 9 Pro と旧めの Samsung Galaxy S9)で計測した:

  • Android のモジュール初期化が約 70% 高速化

  • 初回レンダリングまでの時間が約 30% 改善

  • Record 変換が約 6 倍高速化

以下がモジュール密集型テストアプリの生のコールドスタートデータだ(150 回のクリーンな平均、外れ値は除外済み):

指標SDK 55SDK 56変化
コールドスタート(Activity.onCreate)93 ms55 ms-41%
初回レンダリングまでの時間797 ms508 ms-36%
最初のアニメーションフレーム808 ms520 ms-36%

あなたがやるべきこと

アプリ開発者なら、何もする必要はない。コンパイラプラグインは SDK 56 で自動的に動き、typeDescriptorOf の置き換えはすべての型に適用される。コードの変更は不要だ。

Records を使う Expo モジュールを保守しているなら、@OptimizedRecord を付けるだけで高速な変換が有効になる:

@OptimizedRecord
class MyConfig : Record {
  @Field val apiUrl: String = ""
  @Field val timeout: Int = 30
}

Compose 統合で props を渡しているなら、@OptimizedComposeProps を付ける:

@OptimizedComposeProps
data class MyViewProps(
  val title: MutableState<String> = mutableStateOf(""),
  val count: MutableState<Int> = mutableIntStateOf(0)
) : ComposeProps

これらのアノテーションを付けなくても問題は起きない。モジュールは SDK 55 のリフレクションベースの変換に戻るだけで、Records の 6 倍の高速化は得られない。

次のステップ

ここで終わりではない。コンパイラプラグインが今担っているのは型メタデータと Record 変換だが、同じ考え方はモジュールライフサイクルの他の部分、たとえば関数ディスパッチにも使える。プラグイン自体もより強力で扱いやすくするべく磨き続けており、カバレッジを広げながら保守コストを増やさないようにしている。目標は変わらない。モジュール作者が今日使い慣れている API を保ちつつ、より多くの仕事をコンパイル時に移すことだ。

出典: Expo Blog← ホームへ戻る