2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Composable関数がIR上でどうTransformされるのか

2
Last updated at Posted at 2025-12-12

DroidKaigi.collect { #28@Fukuoka } でゲスト登壇してきました。

「Visualizing Compose IR Transformations」と題して、「Composable関数のIR変換ロジック」をテーマに、図や疑似コードを用いて視覚的に紹介させていただきました。

当日の発表スライドはSpeaker Deckにて公開しています。

本記事は上記スライドの補足資料です。口頭で補完していた情報に加えて、紹介したロジックの根拠となるプラグイン実装コードも紹介します。

もし興味がございましたら、最後までお付き合いいただけると嬉しいです。

本記事およびスライドに記載している内容は Kotlin 2.2.21 時点の実装コードを独自調査したものです。

万が一誤っている点を見つけましたら、コメントなどでご一報いただけますと幸いです。

Transforming value parameters(6〜12ページ)

Compose Compilerは、Composable関数がCompose Runtime上で正しく振る舞えるように、以下3種類の引数を追加します。

  • $composer
  • $changed
  • $default ※デフォルト引数が存在する場合のみ
コンパイル前
@Composable
fun A(
    x: Int = 10
)
コンパイル後
@Composable
  fun A(
-     x: Int = 10,
+     x: Int,
+     $composer: Composer?,
+     $changed: Int,
+     $default: Int,
  )

こちらの改変は ComposerParamTransformer が担います。

$composer: Composer?

ComposerはComposition Treeのノード管理などCompose Runtimeの根幹を担うクラスです。

$composerが引数に追加されることで、setContentなどComposable関数のエントリポイントで提供されるComposerをバケツリレーできるようになります。

// $composer
val composerParam = fn.addValueParameter {
    name = ComposeNames.ComposerParameter
    type = composerType.makeNullable()
    origin = IrDeclarationOrigin.DEFINED
    isAssignable = true
}

$changed: Int

仮引数など入力パラメータの変更を軽量に検出するために使われる 32 bit の bitmask です。

パラメータ1つあたり3bitで状態を表現するため、1つの$changedにはパラメータ10個分の情報しか持たせられません。

パラメータの数が増えてくると連番で $changed1,$changed2, ... が生えていきます。

fun changedParamCount(realValueParams: Int, thisParams: Int): Int {
    val totalParams = realValueParams + thisParams
    if (totalParams == 0) return 1 // There is always at least 1 changed param
    return ceil(
        totalParams.toDouble() / SLOTS_PER_INT.toDouble()
    ).toInt()
}
// $changed[n]
val changed = ComposeNames.ChangedParameter.identifier
for (i in 0 until changedParamCount(realParams, fn.thisParamCount)) {
    fn.addValueParameter(
        if (i == 0) changed else "$changed$i",
        context.irBuiltIns.intType
    )
}

SLOTS_PER_INTは定数10

$default: Int

関数にデフォルト引数がある場合、デフォルト値を削除して$defaultパラメータを生成します。$default には各パラメータのデフォルト値を使うかどうかどうか、1bit単位で指定されます。

Kotlin標準のデフォルト引数をそのまま使わず、なぜこんな遠回りな実装をしているのかと疑問に思うかもしれませんが、これには明確な理由があります。

Kotlinのデフォルト引数は関数の呼び出し側で評価されますが、この挙動がComposable関数にとっては都合が悪いです:

  • 関数を呼び出すたびデフォルト値が評価され、再コンポーズのスキップができない
  • Composerのノード管理単位(後に説明するGroup)と整合性が合わずメモリ構造を壊す

意図的に関数のインタフェースからデフォルト引数を消去して、特別なパラメータ$defaultを用いて内部的に制御する形へ改変している、というわけです。

この設計には、「Javaからの呼び出し方を考慮する必要がなくなる」という副次的なメリットもあります。

This is also a win because we can handle the default arguments without generating an additional function since we do not need to worry about callers from java.

参考: ComposableFunctionBodyTransformer.kt#L262-264

Javaはデフォルト引数をサポートしないので、デフォルト引数があるKotlinの関数をJavaをターゲットにコンパイルすると、複数のオーバーロード宣言が生成されてしまいます。

デフォルト引数を削除することで、Composable関数を単一の宣言としてコンパイルすることができ、後続フェーズのGroup生成などで整合性が取りやすくなります。

こちらも$changedと同様、パラメータ1つあたりに格納できる情報に上限があるので、必要に応じて、$default1, $default2, ...が増えていきます。

fun defaultParamCount(valueParams: Int): Int {
    return ceil(
        valueParams.toDouble() / BITS_PER_INT.toDouble()
    ).toInt()
}
// $default[n]
if (fn.requiresDefaultParameter()) {
    val defaults = ComposeNames.DefaultParameter.identifier
    for (i in 0 until defaultParamCount(currentParams)) {
        fn.addValueParameter(
            if (i == 0) defaults else "$defaults$i",
            context.irBuiltIns.intType,
            IrDeclarationOrigin.MASK_FOR_DEFAULT_FUNCTION
        )
    }
}

BITS_PER_INT31の定数です。32じゃない理由はうまく裏が取れなかったので、わかる方教えてください :pray:

Bitmask allocation for $default(15〜17ページ)

ここからは bitmask の割り当てについて解説していきます。まずは、$default の bitmask 割り当てについてです。

ここまでで把握している通り、以下のComposable関数Aは

デフォルト引数を持つ関数のインタフェース(コンパイル前)
@Composable
fun A(
   x: Int = 10,
   y: Int,
   z: Int = 20,
)

こんなインタフェースに改変されます:

デフォルト引数を持つ関数のインタフェース(コンパイル後)
@Composable
fun A(
   x: Int,
   y: Int,
   z: Int,
   $composer: Composer?,
   $changed: Int,
   $default: Int,
)

ここで、$defaultの bit は、最下位ビットから順に、x, y, z の順番で埋まっていきます。

デフォルト値を採用する(1) or 採用しない(0) のビットになるので、例えば:

A(y = 1)        // $default: 0b101, xとzはデフォルト値
A(x = 1, y = 1) // $default: 0b100, zはデフォルト値
A(y = 1, z = 1) // $default: 0b001, xはデフォルト値

という具合に値が決定します。$default の割り当てロジックは、そんなに難しくはないと思います。

Bitmask allocation for $changed(18〜24ページ)

$changedのビット割り当ては少し特殊です。まず最下位ビットは強制再コンポーズビットとして予約されています。

特定スロット用のbit列を生成するbitsForSlotが固定1の左ビットシフトを含むこと、irRestartFlagsが、先頭の$changedパラメータの最下位ビットを読み取ることからも、この仕様を伺えます。

fun bitsForSlot(bits: Int, slot: Int): Int {
    val realSlot = slot.rem(SLOTS_PER_INT)
    return bits shl (realSlot * BITS_PER_SLOT + 1)
}
// The restart flag is always in the first parameter flags (or the implied changed parameter for 0 parameters)
override fun irRestartFlags(): IrExpression = irAnd(irGet(params[0]), irConst(1))

なお、irRestartFlagsの実装を見てわかる通り、予約された最下位ビットは1つ目の$changed引数に対してのみ意味があります。2つ目以降の$changed[n]については、予約されて使われない1bitになります。

トラッキングの対象となるのは、関数にわたっているパラメータ全てです。以下の優先順位で下から3ビットずつ埋まっていきます:

  • context parameter(context(p1: P1)
  • extension receiver parameter (fun E.hoge()Eの部分)
  • value parameter(fun hoge(x: Int)x の部分)
  • dispatch receiver parameter(Composable関数の親classのインスタンス)

この順序の根拠となるのは以下のコードで、allTrackedParamsの使用箇所を辿っていくと、このリストのインデックスがそのままスロット番号として使われることを確認できます(参考)。

val allTrackedParams = buildList {
    var parameterCount = realValueParamCount
    // reorder to match $changed: [context, extension, value, dispatch]
    function.parameters.fastForEach {
        if (it.kind == IrParameterKind.Context || it.kind == IrParameterKind.ExtensionReceiver) {
            add(it)
        }
    }
    function.parameters.fastForEach {
        if (parameterCount > 0 && it.kind == IrParameterKind.Regular) {
            parameterCount--
            add(it)
        }
    }
    function.parameters.fastForEach {
        if (it.kind == IrParameterKind.DispatchReceiver) {
            add(it)
        }
    }
}

スロットの割り当て方を一通り把握できる簡単な例を用意しました:

class C {
    context(z: Z)
    @Composable
    fun B.A(
        x: Int,
        y: Int,
        $composer: Composer?,
        $changed: Int,
    )
}

コンパイラのテストを書くと、実際にこの順番で埋まっていくことをIRレベルで確認できます。記事全体に一通り目を通した後、もし興味があれば下のアコーディオンを展開してみてください。

コンパイラテストを書いてスロット割り当てをIRで検証する方法

まず以下のようなコンパイラテストのコードを書きます:

$changedのスロット順を検証するためのコンパイラテストコード
// LANGUAGE: +ContextParameters
// DUMP_IR

// MODULE: main
// FILE: A.kt
import androidx.compose.runtime.Composable

class Z

class B

class C {
    context(z: Z)
    @Composable
    fun B.A(
        x: Int,
        y: Int,
    ) {
        f(x) // ここの引数を書き換えてbitmaskの位置を探る
    }
}

@Composable
fun f(x: Any) {}

f(x) に渡すものを変えながら bitmask のどこを見ているか、IRを確認します。

$composer.changed または $composer.changedInstancetrue になった場合に現れる 010(Different) のパターンが、どこのビットに来てるかを確認すればスロット位置を把握することができます。

以下は関数本体がf(x)となっている場合のIRコードを抜粋したものです:

f(x)呼んでる場合のIRコード抜粋
if: CALL 'public open fun changed (value: kotlin.Int): kotlin.Boolean declared in androidx.compose.runtime.Composer' type=kotlin.Boolean origin=null
    ARG <this>: GET_VAR '$composer: androidx.compose.runtime.Composer? declared in <root>.C.A' type=androidx.compose.runtime.Composer? origin=null
    ARG value: GET_VAR 'x: kotlin.Int declared in <root>.C.A' type=kotlin.Int origin=null
then: CONST Int type=kotlin.Int value=256

一番最後の then で入っているのが 010 のビットを何回か左へビットシフトさせた値です。

256 = 2^8 = 0b|010|000|000|0 なので、x は下から3番目のパラメータスロットということがわかります。

同じ要領で順に調べていきます。

yは「2048 = 2^11 = 0b|010|000|000|000|0」なので、下から4番目のスロット。

f(y)呼んでる場合のIRコード抜粋
if: CALL 'public open fun changed (value: kotlin.Int): kotlin.Boolean declared in androidx.compose.runtime.Composer' type=kotlin.Boolean origin=null
    ARG <this>: GET_VAR '$composer: androidx.compose.runtime.Composer? declared in <root>.C.A' type=androidx.compose.runtime.Composer? origin=null
    ARG value: GET_VAR 'y: kotlin.Int declared in <root>.C.A' type=kotlin.Int origin=null
then: CONST Int type=kotlin.Int value=2048

Bは「32 = 2^5 = 0b|010|000|0」なので、下から2番目のスロット。

f(this※B)呼んでる場合のIRコード抜粋
if: CALL 'public abstract fun changed (value: kotlin.Any?): kotlin.Boolean declared in androidx.compose.runtime.Composer' type=kotlin.Boolean origin=null
    ARG <this>: GET_VAR '$composer: androidx.compose.runtime.Composer? declared in <root>.C.A' type=androidx.compose.runtime.Composer? origin=null
    ARG value: GET_VAR '<this>(index:2): <root>.B declared in <root>.C.A' type=<root>.B origin=null
then: CONST Int type=kotlin.Int value=32

zは「4 = 2^2 = 0b|010|0」なので、下から1番目のスロット。

f(z)呼んでる場合のIRコード抜粋
if: CALL 'public abstract fun changed (value: kotlin.Any?): kotlin.Boolean declared in androidx.compose.runtime.Composer' type=kotlin.Boolean origin=null
    ARG <this>: GET_VAR '$composer: androidx.compose.runtime.Composer? declared in <root>.C.A' type=androidx.compose.runtime.Composer? origin=null
    ARG value: GET_VAR 'z: <root>.Z declared in <root>.C.A' type=<root>.Z origin=null
then: CONST Int type=kotlin.Int value=4

最後にdispatch receiverのC。「16384 = 2^14 = 0b|010|000|000|000|000|0」なので、下から5番目のスロット。

f(this@C)呼んでる場合のIRコード抜粋
if: CALL 'public abstract fun changed (value: kotlin.Any?): kotlin.Boolean declared in androidx.compose.runtime.Composer' type=kotlin.Boolean origin=null
    ARG <this>: GET_VAR '$composer: androidx.compose.runtime.Composer? declared in <root>.C.A' type=androidx.compose.runtime.Composer? origin=null
    ARG value: GET_VAR '<this>(index:0): <root>.C declared in <root>.C.A' type=<root>.C origin=null
then: CONST Int type=kotlin.Int value=16384

ということで、間違いなさそうです。コンパイラのテストの動かし方は後日別記事にて公開予定です。

各パラメータ3bitで表現されるパラメータ状態は、ComposableFunctionBodyTransformer.ktにParamStateとして定義されています。

この enum class には、以下6種類のエントリが定義されています。

ParamStateの定義(簡略版)
enum class ParamState(val bits: Int) {
    Uncertain(0b000),
    Same(0b001),
    Different(0b010),
    Static(0b011),

    @Suppress("unused")
    Unknown(0b100),
    Mask(0b111);
}

重要なのは以下の4つになります。

  • Uncertain(000
    • パラメータ状態が直前の実行時から変わったかもしれないし、変わっていないかもしれない。わからない
  • Same(001
    • パラメータ状態が直前の実行時から変わっていない
  • Different(010
    • パラメータ状態が直前の実行時から変わった
  • Static(011
    • コンパイルタイムで不変なことがわかっている

Composeランタイムはこのようなbit列のパターンを用いて、パラメータの状態を判定します。

次のセクションで紹介する $dirty ビットマスクの計算でたくさん出てくるので、頭の片隅に置いておいてください。

Evaluating State changes with $dirty bitmask(25〜35ページ)

$default$changed の bitmask を使って、Composable関数本体の再実行を決定する $dirty ビットマスクを計算するロジックを紹介します。

内部で別のComposable関数f(x)を読んでいる簡単な関数Aを考えます:

関数Aのインタフェース(コンパイル前)
@Composable
fun A(x: Int = 0) {
    f(x) // Composable関数
}

まずこの関数は、以下のように引数がtransformされます。

関数Aのインタフェース(コンパイル後)
@Composable
fun A(
    x: Int,
    $composer: Composer?,
    $changed: Int,
    $default: Int,
) {
    // ...
}

ここからこの内部でどのように $dirty が計算されるかを見ていきます。

最初に、$dirty$changed で初期化された変数として宣言されます。

$dirtyビットマスクの初期化
  @Composable
  fun A(
      x: Int,
      $composer: Composer?,
      $changed: Int,
      $default: Int,
  ) {
+     var $dirty = $changed
  }

次に、xにはデフォルト値が存在するので、$default and 0b1 を計算してxがデフォルト値なのか、もしくは明示的な実引数があるのかを判別します。

xのデフォルト値利用判定
  @Composable
  fun A(
      x: Int,
      $composer: Composer?,
      $changed: Int,
      $default: Int,
  ) {
      var $dirty = $changed
+     if ($default and 0b0001 != 0) { // default value presents
+     }
  }

デフォルト値であれば、絶対にxはこのスコープでは不変なので、Staticを表すビット列011xのスロットを埋めます。最下位ビットは予約ビットなので、xのスロットは下から2〜4bit目であることに注意してください。

xがデフォルト値の場合はStatic(011)でマーク
  @Composable
  fun A(
      x: Int,
      $composer: Composer?,
      $changed: Int,
      $default: Int,
  ) {
      var $dirty = $changed
      if ($default and 0b0001 != 0) { // default value presents
+         $dirty = $dirty or 0b0110 // Static
      }
  }

次に、$changedxのスロットをチェックして、Uncertain(000)またはUnknown(100)であれば、xが変わったのかという情報をこのスコープで計算する必要があります。

状態の変更を検出するには $composer.changed を呼び出します。その結果、xが変わっていたら Different(010)、変わっていなければ Same(001) で x のスロットを埋めます。

xの状態を計算してDifferent(010)かSame(001)でマーク
  @Composable
  fun A(
      x: Int,
      $composer: Composer?,
      $changed: Int,
      $default: Int,
  ) {
      var $dirty = $changed
      if ($default and 0b0001 != 0) { // default value presents
          $dirty = $dirty or 0b0110 // Static
-     }
+     } else if ($changed and 0b0110 == 0) { // Uncertain or Unknown
+         $dirty = $dirty or
+             if ($composer.changed(x)) 0b0100 // Different
+             else 0b0010 // Same
+     }
  }

ここまでで $dirty ビットマスクの計算ができました。パラメータが複数ある場合は各パラメータに対して同様の計算を行うことになります。

続いて $dirty ビットマスクの値を $composer.shouldExecute に渡し、関数本体を実行すべきか判定させます。

shouldExecuteは2つの引数を受け取りますが、1つ目にはxの状態が変わったかを渡し、2つ目にはforce recompositionのbitが立っているかを渡します。

$composer.shouldExecuteで関数の再実行すべきか判定
  @Composable
  fun A(
      x: Int,
      $composer: Composer?,
      $changed: Int,
      $default: Int,
  ) {
      var $dirty = $changed
      if ($default and 0b0001 != 0) { // default value presents
          $dirty = $dirty or 0b0110 // Static
      } else if ($changed and 0b0110 == 0) { // Uncertain or Unknown
          $dirty = $dirty or
              if ($composer.changed(x)) 0b0100 // Different
              else 0b0010 // Same
      }
+     if (
+         $composer.shouldExecute(
+             $dirty and 0b0011 != 0b0010, // state x was changed
+             $dirty and 0b0001 // force recomposition
+         )
+     ) {
+     }
  }

古い実装だと、$composer.shouldExecute(...) の代わりに以下の判定が使われる場合があります。

if ($dirty and 0b0011 != 0b0010 || !$composer.skipping) { ... }

書籍Jetpack Compose Internalsに掲載されていたのも上記のパターンです。細かい話が気になる方は、以下のアコーディオンを展開して読んでみてください。

なぜ今は $composer.shouldExecute(...) なのか?

$composer.shouldExecute$composer.skippingのどちらを使って制御するかを決定するコードはirShouldExecuteにあります。

private fun irShouldExecute(parametersChanged: IrExpression, flags: IrExpression): IrExpression {
    val shouldExecuteFunction = shouldExecuteFunction
    return if (shouldExecuteFunction != null) {
        irMethodCall(irCurrentComposer(), shouldExecuteFunction).apply {
            // 0th is receiver
            arguments[1] = parametersChanged
            arguments[2] = flags
        }
    } else {
        irOrOr(
            parametersChanged,
            irNot(irIsSkipping())
        )
    }
}

shouldExecuteFunctionが非nullであれば $composer.shouldExecute のパターンが、nullであれば$composer.skippingのパターンのIRが生成されます。

ここで、shouldExecuteFunctionの定義を見てみます。

private val shouldExecuteFunction by guardedLazy {
    if (FeatureFlag.PausableComposition.enabled)
        composerIrClass
            .functions
            .firstOrNull {
                it.name == ComposeNames.ShouldExecute &&
                        it.parameters.size == 3 &&
                        it.parameters[1].type.isBoolean() &&
                        it.parameters[2].type.isInt()
            }
    else null
}

PausableCompositionのFeatureFlagが有効(デフォルトで有効)かつシンボルが見つかった場合に非nullとなる実装になっているようです。

ここで気になるのが、PausableCompositionがなんなのか、shouldExecuteが何をやっているのか、の2つでしょう。

まずPausableCompositionを調べてみると、MediumにShreyas Patilさんという方がいい感じに内部的な話を整理してくれていました(Exploring PausableComposition internals in Jetpack Compose)。

ざっと目を通すと以下のようなことが書いてあります。

  • Compose 1.9.X のリリースから入ったExperimentalなFeature
  • PausableCompositionはフレームドロップによるジャンク、スタッタリングを軽減する目的で導入された
  • Idleなフレームを活用して、UI表示前にCompositionしてレンダー準備しておく仕組み

具体例を用いてPausableCompositionがどのように起こるかを説明している箇所を抜粋します↓

As scrolling occurs, let’s say items A, B, C, D, and E are already visible on the screen, and the next item is F. If item F has a complex layout or structure that requires more time for layout computation or other pre-processing before rendering on the UI, this pre-processing happens in chunks within the frame timeline (i.e., 16ms). So, if it requires 2 frames, the necessary pre-processing for F is completed over 2 frames in the idle times without causing any jank to the frames. Finally, it is drawn on the UI when it needs to be visible. The same process applies to items G and H.

「画面外の要素Fをレンダーするのに2フレームかかる場合は、1フレームずつchunkを作ってCompositionする」というニュアンスが記されています。

PausableCompositionは空きフレームを活用するが、空きフレームは1フレームしかなかったりするので、毎フレームpauseできるように設計されている、みたいです。

また、記事内にPausableCompositionのスコープにおけるshouldExecute$composer.shouldExecuteとは別物)に関する言及がありました。

!shouldExecute(...): This is the core decision. It compares the availableTimeNanos against a budget. This budget is a smart estimate: the average time it takes to do another chunk of work plus the average time it takes to pause. If there isn't enough time, pauseRequested becomes true.

フレームドロップを起こさずcompositionを進めるのに十分な時間があるか、を判定するロジックのようです。

PausableCompositionのコンセプトが大体わかったところで、Composer.shouldExecute のインタフェースを見てみます。

    /**
     * A Compose compiler plugin API. DO NOT call directly.
     *
     * Generated by the compile to determine if the composable function should be executed. It may
     * not execute if parameter has not changed and the nothing else is forcing the function to
     * execute (such as its scope was invalidated or a static composition local it was changed) or
     * the composition is pausable and the composition is pausing.
     *
     * @param parametersChanged `true` if the parameters to the composable function have changed.
     *   This is also `true` if the composition is [inserting] or if content is being reused.
     * @param flags The `$changed` parameter that contains the forced recompose bit to allow the
     *   composer to disambiguate when the parameters changed due the execution being forced or if
     *   the parameters actually changed. This is only ambiguous in a [PausableComposition] and is
     *   necessary to determine if the function can be paused. The bits, other than 0, are reserved
     *   for future use (which would required the bit 31, which is unused in `$changed` values, to
     *   be set to indicate that the flags carry additional information). Passing the `$changed`
     *   flags directly, instead of masking the 0 bit, is more efficient as it allows less code to
     *   be generated per call to `shouldExecute` which is every called in every restartable
     *   function, as well as allowing for the API to be extended without a breaking changed.
     */
    @InternalComposeApi public fun shouldExecute(parametersChanged: Boolean, flags: Int): Boolean

第二引数のflagsの説明に、PausableCompositionに対する言及があるので抜粋します:

This is only ambiguous in a [PausableComposition] and is necessary to determine if the function can be paused. The bits, other than 0, are reserved for future use (which would required the bit 31, which is unused in $changed values, to be set to indicate that the flags carry additional information). Passing the $changed flags directly, instead of masking the 0 bit, is more efficient as it allows less code to be generated per call to shouldExecute which is every called in every restartable function, as well as allowing for the API to be extended without a breaking changed.

PausableCompositionの文脈でpause可能かを判定するために必要なパラメータであり、flags の他のビットに追加の情報を持たせるかも、というのも示唆されています。

$composer.skipping の方も念のため見てみると、

   /**
     * A Compose compiler plugin API. DO NOT call directly.
     *
     * Reflects whether the [Composable] function can skip. Even if a [Composable] function is
     * called with the same parameters it might still need to run because, for example, a new value
     * was provided for a [CompositionLocal] created by [staticCompositionLocalOf].
     */
    @ComposeCompilerApi public val skipping: Boolean

単純な「スキップできるか」を返すプロパティのようでした。

ということで、shouldExecuteは新しい機能PausableCompositionをサポートするために後から追加された内部APIで、skippingは従来のCompose Compilerから使われていたコアロジックという説明が丸いと思います。

shouldExecutetrue を返したら再コンポーズです。デフォルト値を使うならここでxにデフォルト値を詰めて、f(x)を実行します。

  @Composable
  fun A(
      x: Int,
      $composer: Composer?,
      $changed: Int,
      $default: Int,
  ) {
      var $dirty = $changed
      if ($default and 0b0001 != 0) { // default value presents
          $dirty = $dirty or 0b0110 // Static
      } else if ($changed and 0b0110 == 0) { // Uncertain or Unknown
          $dirty = $dirty or
              if ($composer.changed(x)) 0b0100 // Different
              else 0b0010 // Same
      }
      if (
          $composer.shouldExecute(
              $dirty and 0b0011 != 0b0010, // state x was changed
              $dirty and 0b0001 // force recomposition
          )
      ) {
+         if ($default and 0b0001 != 0) x = 10
+         f(x, $composer, 0b0110) // x is Static from here
      }
  }

以上が再コンポーズのロジックになります。

ぱっと見難しいbit演算が多いですが、Composeがかなりパフォーマンスを意識して設計されているのが伝わってきます。

Group(36〜40ページ)

Composable関数は内部的にGroupというものを作ってノードのデータを管理しており、以下3種類のグループが存在します。

  • Restart(able) Group
  • Replace(able) Group
  • Movable Group

Restart Group

再起動可能なグループ Restart Group は、再コンポーズの境界となりスキップが可能なグループを指します。

基本的に通常のComposable関数はRestartable(再起動可能)で、IrFunction.shouldBeRestartableで再起動可能かを判定しています。

    protected fun IrFunction.shouldBeRestartable(): Boolean {
        // Only insert observe scopes in non-empty composable function
        if (body == null || this !is IrSimpleFunction)
            return false

        if (isLocal && parentClassOrNull?.origin != JvmLoweredDeclarationOrigin.LAMBDA_IMPL) {
            return false
        }

        // Do not insert observe scope in an inline function
        if (isInline)
            return false

        if (hasNonRestartableAnnotation)
            return false

        if (hasExplicitGroups)
            return false

        // Do not insert an observe scope if the function has a return result
        if (!returnType.isUnit())
            return false

        if (isComposableDelegatedAccessor())
            return false

        // Virtual functions with default params are called through wrapper generated in
        // ComposableDefaultParamLowering. The restartable group is moved to the wrapper, while
        // the function itself is no longer restartable.
        if (isVirtualFunctionWithDefaultParam()) {
            return false
        }

        // Open functions cannot be restartable since restart logic makes a virtual call (todo: b/329477544)
        if (modality == Modality.OPEN && parentClassOrNull?.isFinalClass != true) {
            return false
        }

        // Check if the descriptor has restart scope calls resolved
        // Lambdas should be ignored. All composable lambdas are wrapped by a restartable
        // function wrapper by ComposerLambdaMemoization which supplies the startRestartGroup/
        // endRestartGroup pair on behalf of the lambda.
        return origin != IrDeclarationOrigin.LOCAL_FUNCTION_FOR_LAMBDA
    }

簡単に整理すると、以下に当てはまる場合のみ、NonRestartable(再起動不可能) になります。

  • ラムダ内関数のローカル関数
  • インライン関数
  • @NonRestartableComposableでマークされた関数
  • @ExplicitGroupsComposableでマークされた関数
  • 戻り値がUnitじゃない関数(UI Composableじゃないもの)
  • isComposableDelegatedAccessortrue
  • isVirtualFunctionWithDefaltParamtrue
  • openな関数で宣言の親クラスがfinalクラスでない
  • ローカルのラムダ関数

ぱっと見謎なisComposableDelegatedAccessorisVirtualFunctionWithDefaltParamについて、調べてみました。詳細が興味ある方は以下のアコーディオンを展開して読んでみてください。

isComposableDelegatedAccessor、isVirtualFunctionWithDefaltParam が true になるパターンはどんな時か

isComposbleDelegatedAccessor

だいぶニッチで普通なら書かない書き方にマッチする条件です。

訳すなら「Composableなデリゲートアクセサ」ですが、これだけではよくわからないので実装コードを見てみます。

/*
 * Delegated accessors are generated with IrReturn(IrCall(<delegated function>)) structure.
 * To verify the delegated function is composable, this function is unpacking it and
 * checks annotation on the symbol owner of the call.
 */
fun IrFunction.isComposableDelegatedAccessor(): Boolean =
    origin == IrDeclarationOrigin.DELEGATED_PROPERTY_ACCESSOR &&
            body?.let {
                val returnStatement = it.statements.singleOrNull() as? IrReturn
                val callStatement = returnStatement?.value as? IrCall
                val target = callStatement?.symbol?.owner
                target?.hasComposableAnnotation()
            } == true

端的に言うと以下のプロパティagetterが、isComposableDelegatedAccessorに該当します。

isComposableDelegatedAccessorにマッチするソースコード
class A

@Composable
operator fun <T> A.getValue(thisRef: T, prop: KProperty<*>) {
}

val a by A()
    @Composable
    get

このコードはCompose Compiler未適用の状態でコンパイルすると以下のようなIRになります(val aの部分だけ抜粋):

  PROPERTY name:a visibility:public modality:FINAL [delegated,val]
    FIELD PROPERTY_DELEGATE name:a$delegate type:<root>.A visibility:private [final,static]
      EXPRESSION_BODY
        CONSTRUCTOR_CALL 'public constructor <init> () declared in <root>.A' type=<root>.A origin=null
    FUN DELEGATED_PROPERTY_ACCESSOR name:<get-a> visibility:public modality:FINAL returnType:kotlin.Unit
      annotations:
        Composable
      correspondingProperty: PROPERTY name:a visibility:public modality:FINAL [delegated,val]
      BLOCK_BODY
        RETURN type=kotlin.Nothing from='public final fun <get-a> (): kotlin.Unit declared in <root>'
          CALL 'public final fun getValue <T> (<this>: <root>.A, thisRef: T of <root>.getValue, prop: kotlin.reflect.KProperty<*>): kotlin.Unit declared in <root>' type=kotlin.Unit origin=null
            TYPE_ARG T: kotlin.Nothing?
            ARG <this>: GET_FIELD 'FIELD PROPERTY_DELEGATE name:a$delegate type:<root>.A visibility:private [final,static] declared in <root>' type=<root>.A origin=null
            ARG thisRef: CONST Null type=kotlin.Nothing? value=null
            ARG prop: PROPERTY_REFERENCE 'public final a: kotlin.Unit declared in <root>' field=null getter='public final fun <get-a> (): kotlin.Unit declared in <root>' setter=null type=kotlin.reflect.KProperty0<kotlin.Unit> origin=PROPERTY_REFERENCE_FOR_DELEGATE

isComposableDelegatedAccessor はまさにこの構造のプロパティgetterを検出するための条件になってます。

5行目の DELEGATED_PROPERTY_ACCESSOR<get-a>agetter関数)は一つ目のoriginの条件を満たします。

その上で、BLOCK_BODYbody)の中身をみると、RETURN > CALL という構造をとっていて、CALL の対象となる getValueComposable です。

このようなパターンでは NonRestartable となり、Compose Compilerを有効にすると以下のようなIRとしてコンパイルされます(グループ自体生成されません)。

  PROPERTY name:a visibility:public modality:FINAL [delegated,val]
    FIELD PROPERTY_DELEGATE name:a$delegate type:<root>.A visibility:private [final,static]
      EXPRESSION_BODY
        CONSTRUCTOR_CALL 'public constructor <init> () declared in <root>.A' type=<root>.A origin=null
    FUN DELEGATED_PROPERTY_ACCESSOR name:<get-a> visibility:public modality:FINAL returnType:kotlin.Unit
      VALUE_PARAMETER kind:Regular name:$composer index:0 type:androidx.compose.runtime.Composer? [assignable]
      VALUE_PARAMETER kind:Regular name:$changed index:1 type:kotlin.Int
      annotations:
        Composable
        JvmName(name = "getA")
      correspondingProperty: PROPERTY name:a visibility:public modality:FINAL [delegated,val]
      BLOCK_BODY
        CALL 'public final fun sourceInformationMarkerStart (composer: androidx.compose.runtime.Composer, key: kotlin.Int, sourceInformation: kotlin.String): kotlin.Unit declared in androidx.compose.runtime' type=kotlin.Unit origin=null
          ARG composer: GET_VAR '$composer: androidx.compose.runtime.Composer? declared in <root>.<get-a>' type=androidx.compose.runtime.Composer? origin=null
          ARG key: CONST Int type=kotlin.Int value=-79328922
          ARG sourceInformation: CONST String type=kotlin.String value="C(<get-a>)14@255L3:A.kt"
        WHEN type=kotlin.Unit origin=IF
          BRANCH
            if: CALL 'public final fun isTraceInProgress (): kotlin.Boolean declared in androidx.compose.runtime' type=kotlin.Boolean origin=null
            then: CALL 'public final fun traceEventStart (key: kotlin.Int, dirty1: kotlin.Int, dirty2: kotlin.Int, info: kotlin.String): kotlin.Unit declared in androidx.compose.runtime' type=kotlin.Unit origin=null
              ARG key: CONST Int type=kotlin.Int value=-79328922
              ARG dirty1: GET_VAR '$changed: kotlin.Int declared in <root>.<get-a>' type=kotlin.Int origin=null
              ARG dirty2: CONST Int type=kotlin.Int value=-1
              ARG info: CONST String type=kotlin.String value="<get-a> (A.kt:14)"
        CALL 'public final fun getValue <T> (<this>: <root>.A, thisRef: T of <root>.getValue, prop: kotlin.reflect.KProperty<*>, $composer: androidx.compose.runtime.Composer?, $changed: kotlin.Int): kotlin.Unit declared in <root>' type=kotlin.Unit origin=null
          TYPE_ARG T: kotlin.Nothing?
          ARG <this>: GET_FIELD 'FIELD PROPERTY_DELEGATE name:a$delegate type:<root>.A visibility:private [final,static] declared in <root>' type=<root>.A origin=null
          ARG thisRef: CONST Null type=kotlin.Nothing? value=null
          ARG prop: PROPERTY_REFERENCE 'public final a: kotlin.Unit declared in <root>' field=null getter='public final fun <get-a> ($composer: androidx.compose.runtime.Composer?, $changed: kotlin.Int): kotlin.Unit declared in <root>' setter=null type=kotlin.reflect.KProperty0<kotlin.Unit> origin=PROPERTY_REFERENCE_FOR_DELEGATE
          ARG $composer: GET_VAR '$composer: androidx.compose.runtime.Composer? declared in <root>.<get-a>' type=androidx.compose.runtime.Composer? origin=null
          ARG $changed: CONST Int type=kotlin.Int value=48
        WHEN type=kotlin.Unit origin=IF
          BRANCH
            if: CALL 'public final fun isTraceInProgress (): kotlin.Boolean declared in androidx.compose.runtime' type=kotlin.Boolean origin=null
            then: CALL 'public final fun traceEventEnd (): kotlin.Unit declared in androidx.compose.runtime' type=kotlin.Unit origin=null
        CALL 'public final fun sourceInformationMarkerEnd (composer: androidx.compose.runtime.Composer): kotlin.Unit declared in androidx.compose.runtime' type=kotlin.Unit origin=null
          ARG composer: GET_VAR '$composer: androidx.compose.runtime.Composer? declared in <root>.<get-a>' type=androidx.compose.runtime.Composer? origin=null

ただのdelegateなので、わざわざComposableのGroupを生成する必要がないというわけですね。

isVirtualFunctionWithDefaltParam

virtualなデフォルト引数を持つ関数については、ComposableDefaultParamLoweringがwrapper関数を生成するので、Restartableにしない、風なことが書いてあります。

// Virtual functions with default params are called through wrapper generated in
// ComposableDefaultParamLowering. The restartable group is moved to the wrapper, while
// the function itself is no longer restartable.
if (isVirtualFunctionWithDefaultParam()) {
    return false
}

こちらも実装コードを見てみます:

protected fun IrFunction.isVirtualFunctionWithDefaultParam(): Boolean =
    this is IrSimpleFunction &&
            (isVirtualFunctionWithDefaultParam == true ||
                    overriddenSymbols.any { it.owner.isVirtualFunctionWithDefaultParam() })

自身がoverrideしている親の関数までたどっていき、どれか1つでもisVirtualFunctionWithDefaultParamtrue になる関数があれば、true、というロジックになっています。

isVirtualFunctionWithDefaultParam は Compose Compiler のプロジェクト内で追加されている拡張プロパティです:

internal var IrSimpleFunction.isVirtualFunctionWithDefaultParam: Boolean? by irAttribute(copyByDefault = true)

この拡張プロパティに値を設定しているのは、shouldBeRestartable関数内のコメントでも言及があった
ComposableDefaultParamLoweringというクラスです。

このクラスはvirtualな関数のデフォルト引数をサポートするためのIR変換を担っており、以下のような変換をします(参考)。

デフォルト引数が定義されたabstract関数(コンパイル前)
abstract class Test {
    @Composable abstract fun doSomething(arg1: Int = remember { 0 })
}

@Composable fun callWithDefaults(instance: Test) {
    instance.doSomething()
    instance.doSomething(0)
}
デフォルト引数が定義されたabstract関数(コンパイル後)
abstract class Test {
    @Composable abstract fun doSomething(arg1: Int)
    class ComposeDefaultsImpl {
         /* static */ fun doSomething$composable$default(
             instance: Test,
             arg1: Int = remember { 0 },
         ) {
             return instance.doSomething(arg1)
         }
    }
}

@Composable fun callWithDefaults(
    instance: Test,
    $composer: Composer,
    $changed: Int
) {
    Test$DefaultsImpl.doSomething(instance)
    Test$DefaultsImpl.doSomething(instance, 0)
}

virtualな関数とは、サンプルコードのTest.doSomethingのようにabstractopenな関数を指します。上記のようなwrapperを生成する対象となった関数は、isVirtualFunctionWithDefaultParamの値がtrueになり、NonRestartableとなります。

余談ですが、コミットログを見る限り、Kotlin 2.2.20から入った比較的新しい機能だと思われます。それより古いのバージョンでは、virtualなComposable関数にデフォルト引数を書くことは許されていませんでした(例えば2.0.20ではコンパイルが通りません)。

Replace Group

置換可能なグループ Replace Group は、if-elseなど分岐の制御フローに基づいて生成されます。

@Composable
fun A(flag: Boolean) {
    if (flag) {
        Text("true")
    } else {
        Text("false")
    }
}

であれば、

のように根本のRestart Groupに2つのReplace Groupがぶら下がる形状になります。

こうすることで、条件が変わった時にメモリ内に保持されたグループの順序を壊さずに、UI要素を差し替えることができます。

Movable Group

移動可能なグループ Movable Group は、key関数により生成することができます。

for文のように実行順などの影響を受けてノードの同一性が簡単に失われてしまうパターンでも、ノードの状態を一意のkeyを用いて維持する使い方ができます。

@Composable
fun A(items: List<Int>) {
    Column {
        for (x in items) {
            key(x) {
                Text("$x")
            }
        }
    }
}

例えば、↑のコードでは、入力の items の先頭に新しい要素が追加されると、通常は全ノードが作り直されますが、keyを使っているのでスロットのデータを維持することができ、再コンポーズのコストを抑えられます。

こちらのコードの場合、以下のようなグループ構造が内部的に生成されています(※Composableなラムダ関数はReplace Groupになります)。

Groups in IR(41〜49ページ)

ここからは簡単な例とともに、Composable関数がどうRestartableにコンパイルされていくのか、IRレベルで見ていきます。

以下の例を思い出してください。

Composable関数A(コンパイル前)
@Composable
fun A(x: Int) {
    f(x) // Composable
}

この関数が、以下のように変換されるというところまでは、すでに解説しています。しかし、見て分かる通り、このコードには、まだ関数を再起動する仕組みがありません。

Composable関数A(途中までコンパイル後)
@Composable
fun A(
    x: Int,
    $composer: Composer?,
    $changed: Int,
    $default: Int,
) {
    var $dirty = $changed
    if ($default and 0b0001 != 0) { // default value presents
        $dirty = $dirty or 0b0110 // Static
    } else if ($changed and 0b0110 == 0) { // Uncertain or Unknown
        $dirty = $dirty or
            if ($composer.changed(x)) 0b0100 // Different
            else 0b0010 // Same
    }
    if (
        $composer.shouldExecute(
            $dirty and 0b0011 != 0b0010, // state x was changed
            $dirty and 0b0001 // force recomposition
        )
    ) {
        if ($default and 0b0001 != 0) x = 10
        f(x, $composer, 0b0110) // x is Static from here
    }
}

コンパイラはまず、関数の先頭で startReplaceGroup を呼び出し、再起動可能なグループの境界を開始します。

引数として渡っている数値 -1635445003DurableFunctionKeyTransformer により関数シグネチャをもとに生成される一意なキーとなっています。

再起動可能なグループの開始
  @Composable
  fun A(
      x: Int,
      $composer: Composer?,
      $changed: Int,
      $default: Int,
  ) {
+     $composer.startRestartGroup(-1635445003)
      var $dirty = $changed
      if ($default and 0b0001 != 0) { // default value presents
          $dirty = $dirty or 0b0110 // Static
      } else if ($changed and 0b0110 == 0) { // Uncertain or Unknown
          $dirty = $dirty or
              if ($composer.changed(x)) 0b0100 // Different
              else 0b0010 // Same
      }
      if (
          $composer.shouldExecute(
              $dirty and 0b0011 != 0b0010, // state x was changed
              $dirty and 0b0001 // force recomposition
          )
      ) {
          if ($default and 0b0001 != 0) x = 10
          f(x, $composer, 0b0110) // x is Static from here
      }
  }

$composer.shouldExecutefalse を返し、関数の再実行が不要となるケースでは、Groupの終端まで実行をスキップするため、$composer.skipToGroupEndを呼び出します。

再コンポーズのスキップ
  @Composable
  fun A(
      x: Int,
      $composer: Composer?,
      $changed: Int,
      $default: Int,
  ) {
      $composer.startRestartGroup(-1635445003)
      var $dirty = $changed
      if ($default and 0b0001 != 0) { // default value presents
          $dirty = $dirty or 0b0110 // Static
      } else if ($changed and 0b0110 == 0) { // Uncertain or Unknown
          $dirty = $dirty or
              if ($composer.changed(x)) 0b0100 // Different
              else 0b0010 // Same
      }
      if (
          $composer.shouldExecute(
              $dirty and 0b0011 != 0b0010, // state x was changed
              $dirty and 0b0001 // force recomposition
          )
      ) {
          if ($default and 0b0001 != 0) x = 10
          f(x, $composer, 0b0110) // x is Static from here
      } else {
+         $composer.skipToGroupEnd()
      }
  }

最後に、関数の末尾で $composer.endRestartGroup を呼び出し、Composerが再コンポーズ時に実行できるラムダ関数を、updateScopeを用いて登録します。

再起動可能なグループの終了と更新用ラムダの登録
  @Composable
  fun A(
      x: Int,
      $composer: Composer?,
      $changed: Int,
      $default: Int,
  ) {
      $composer.startRestartGroup(-1635445003)
      var $dirty = $changed
      if ($default and 0b0001 != 0) { // default value presents
          $dirty = $dirty or 0b0110 // Static
      } else if ($changed and 0b0110 == 0) { // Uncertain or Unknown
          $dirty = $dirty or
              if ($composer.changed(x)) 0b0100 // Different
              else 0b0010 // Same
      }
      if (
          $composer.shouldExecute(
              $dirty and 0b0011 != 0b0010, // state x was changed
              $dirty and 0b0001 // force recomposition
          )
      ) {
          if ($default and 0b0001 != 0) x = 10
          f(x, $composer, 0b0110) // x is Static from here
      } else {
          $composer.skipToGroupEnd()
      }
+     $composer.endRestartGroup()?.updateScope { $composer, $force ->
+         A(x, $composer, $changed or 0b1)
+     }
  }

以上が、Restart GroupのIR上での生成ロジックとなります。

Stability(50〜55ページ)

直前のセクションで、$composer.shouldExecuteを呼び出して、実行が不要な場合は $composer.skipToGroupEnd を呼び出すことを紹介しました。

これはパラメータが安定している前提のロジックです(xは安定型のInt)。

パラメータが安定している関数のIR
@Composable
fun A(x: Int, $composer: Composer?, $changed: Int, ) {
    $composer.startRestartGroup(-1635445003)
    var $dirty = 
    if ($composer.shouldExecute()) {
        if ($default and 0b0001 != 0) x = 10
        f(x, $composer, 0b0110)
    } else {
        $composer.skipToGroupEnd()
    }
    $composer.endRestartGroup()?.updateScope { $composer, $force ->
        A(x, $composer, $changed or 0b1)
    }
}

ここで、xが不安定なUnstableTypeであったとします。この場合、$changedの値を完全に信用することができないので、関数の実行をスキップすることができません。

再コンポーズがあれば問答無用で関数本体を再実行することになります。

パラメータが不安定な関数のIR(StrongSkipping無効)
@Composable
fun A(x: UnstableType, $composer: Composer?, $changed: Int, ) {
    $composer.startRestartGroup(1096661310)

    f(x, $composer, 0)

    $composer.endRestartGroup()?.updateScope { $composer, $force ->
        A(x, $composer, $changed or 0b1)
    }
}

つまり、「Stabilityは何か」というと「再コンポーズを安全にスキップできるかを判断する基準」といえます。

Stableであれば、$changedのbitmaskを使った高速な等価判定ができ、なおかつ状態変化を安全にトラッキングできるので、スキップ可能です。

一方Unstableだと、確実かつ高速な比較手段がないため、原則としてスキップ不可能となります。

ただし、Strong Skipping Modeが有効な場合は、参照比較によって等価性が検証されるようになるので、スキップ可能になります。

直前の例でUnstableTypeを入力とするAは、StrongSkippingが有効であれば、$composer.changedInstanceという特別な関数を使って等価判定され、通常のStableなパラメータと同様のスキップロジックが生成されます。

パラメータが不安定な関数のIR(StrongSkipping有効)
@Composable
fun A(x: UnstableType, $composer: Composer?, $changed: Int, ) {
    $composer.startRestartGroup(1096661310)
    var $dirty = $changed
    if ($dirty and 0b0110 == 0) {
        $dirty = dirty or 
             if ($composer.changedInstance(x)) 0b0100
             else 0b0010
    }
    if ($composer.shouldExecute()) {       
        f(x, $composer, 0)
    } else {
        $composer.skipToGroupEnd()
    }
    $composer.endRestartGroup()?.updateScope { $composer, $force ->
        A(x, $composer, $changed or 0b1)
    }
}

現在は Strong Skipping がデフォルトで有効なので、不安定な型が混ざっていても過剰にパフォーマンスを気にする必要は無くなってきているのかもしれないです。

Stability Inference(57〜69ページ)

Compose Compilerは、関数の入力値が安定しているかどうか、IRノードを解析することで判定しています。

内部的には安定性は5種類の型に分類されています。

  • Certain コンパイルタイムに静的に決定するもの
  • Runtime 実行時に決定するもの
  • Unknown 不明なもの
  • Parameter 型引数に依存するもの
  • Combined 複数のStabilityが複合したもの
sealed class Stability {
    // class Foo(val bar: Int)
    class Certain(val stable: Boolean) : Stability() { ... }

    // class Foo(val bar: ExternalType) -> ExternalType.$stable
    class Runtime(val declaration: IrClass) : Stability() { ... }

    // interface Foo { fun result(): Int }
    class Unknown(val declaration: IrClass) : Stability() { ... }

    // class <T> Foo(val value: T)
    class Parameter(val parameter: IrTypeParameter) : Stability() { ... }

    // class Foo(val foo: A, val bar: B)
    class Combined(val elements: List<Stability>) : Stability() { ... }
}

Stabilityを推論する専用のクラスStabilityInferencerが実装されていて、このクラスは IrType(IR上での型表現)を解析 → Stability を出力します。

class StabilityInferencer(...) {
    fun stabilityOf(irType: IrType): Stability =
        stabilityOf(irType, emptyMap(), emptySet())

    private fun stabilityOf(...) { ... }
}

ここからは、StabilityInferencerの型安定性推論アルゴリズムを解説します。

発表スライドでは尺の問題で単純化した箇所もあるので、丁寧に実装ベースで辿っていきます。

型安定性推論のアルゴリズム

1. IrErrorTypeIrDynamicType

最初にチェックするのは IrType の型です。以下にマッチする場合はUnstableCertain(false))として判定されます。

  • IrErrorType: 型解決に失敗している型
  • IrDynamicType: Kotlin/JSのdynamic関数が生成する型
type is IrErrorType -> Stability.Unstable
type is IrDynamicType -> Stability.Unstable

ここはコンパイラの内部に偏った話なので流してしまって良いです。

2. Unit型、プリミティブ型、関数型、String

次に、Unit、プリミティブ型、関数型、コンパイラ内部でのComposable関数型、String型を StableCertain(true)) として判定します。

type.isUnit() ||
    type.isPrimitiveType() ||
    type.isFunctionOrKFunction() ||
    type.isSyntheticComposableFunction() ||
    type.isString() -> Stability.Stable
enum class PrimitiveType(typeName: String) {
    BOOLEAN("Boolean"),
    CHAR("Char"),
    BYTE("Byte"),
    SHORT("Short"),
    INT("Int"),
    FLOAT("Float"),
    LONG("Long"),
    DOUBLE("Double"),
    ;
}

3. @StableMarker@Stable or @Immutable)のチェック

ここから、実装的にはIrClassを引数に受け取るstabilityOf関数の内部のロジックを説明していきます。

まず最初に、StableMarkerの存在をチェックします。StableMarkerとは@Stable@Immutabieなど@StableMarkerでアノテートされたアノテーションを指します。

if (declaration.hasStableMarkedDescendant()) return Stability.Stable

※関数名にDecendantとあるように、スーパータイプにStableMarkerがついていてもStableと判定されます。

4. EnumクラスとEnumエントリ

Enumクラスとそのエントリ(要素)は、コンパイルタイムに確定するコンスタントなのでStableです。

if (declaration.isEnumClass || declaration.isEnumEntry) return Stability.Stable

5. Protobuf生成型

Protobufで生成された型もイミュータブルなのでStableです。

if (declaration.isProtobufType()) return Stability.Stable
private fun IrClass.isProtobufType(): Boolean {
    // Quick exit as all protos are final
    if (!isFinalClass) return false
    val directParentClassName =
        superTypes.lastOrNull { !it.isInterface() }
            ?.classOrNull?.owner?.fqNameWhenAvailable?.toString()
    return directParentClassName == "com.google.protobuf.GeneratedMessageLite" ||
            directParentClassName == "com.google.protobuf.GeneratedMessage"
}

6. Stability推論可能型・外部安定型の安定性推論

ここまで通過すると、やや複雑な計算処理に移行します。

Stability推論可能な型や外部モジュールの安定型については、追加で安定性を推論していきます。

if (canInferStability(declaration) || declaration.isExternalStableType()) {
    // 安定性の推論
}

6-1. 既知のStable型に関連する型の安定性の判定

事前に安定性が判明している型は、KnownStableConstructs というオブジェクトにパラメータマスクと合わせて定義されています。

まず第一に、この KnownStableConstructs の中に定義ある型なのかを調べ、パラメータマスク(型引数のマスク)を取り出します。

if (KnownStableConstructs.stableTypes.contains(fqName)) {
    mask = KnownStableConstructs.stableTypes[fqName] ?: 0
    stability = Stability.Stable
}

KnownStableConstructs.stableTypesの定義は以下のようになっていて、安定している型と、その型が持つ型引数のマスクを定義したマップ構造をとります。

0b1だったら型引数が1個、0b11だったら型引数が2個あることになります。

val stableTypes = mapOf(
    Pair::class.qualifiedName!! to 0b11,
    Triple::class.qualifiedName!! to 0b111,
    Comparator::class.qualifiedName!! to 0b1,
    Result::class.qualifiedName!! to 0b1,
    ClosedRange::class.qualifiedName!! to 0b1,
    ClosedFloatingPointRange::class.qualifiedName!! to 0b1,
    // Guava
    "com.google.common.collect.ImmutableList" to 0b1,
    "com.google.common.collect.ImmutableEnumMap" to 0b11,
    "com.google.common.collect.ImmutableMap" to 0b11,
    "com.google.common.collect.ImmutableEnumSet" to 0b1,
    "com.google.common.collect.ImmutableSet" to 0b1,
    // Kotlinx immutable
    "kotlinx.collections.immutable.ImmutableCollection" to 0b1,
    "kotlinx.collections.immutable.ImmutableList" to 0b1,
    "kotlinx.collections.immutable.ImmutableSet" to 0b1,
    "kotlinx.collections.immutable.ImmutableMap" to 0b11,
    "kotlinx.collections.immutable.PersistentCollection" to 0b1,
    "kotlinx.collections.immutable.PersistentList" to 0b1,
    "kotlinx.collections.immutable.PersistentSet" to 0b1,
    "kotlinx.collections.immutable.PersistentMap" to 0b11,
    // Dagger
    "dagger.Lazy" to 0b1,
    // Coroutines
    EmptyCoroutineContext::class.qualifiedName!! to 0,
    // Java types
    BigInteger::class.qualifiedName!! to 0,
    BigDecimal::class.qualifiedName!! to 0,
    Locale::class.qualifiedName!! to 0,
)

ここに定義される型それ自体はStableなので、あとは型引数さえ安定であればStableと判定されます。

return when {
    mask == 0 || typeParameters.isEmpty() -> stability
    else -> stability + Stability.Combined(
        typeParameters.mapIndexedNotNull { index, irTypeParameter ->
            if (index >= 32) return@mapIndexedNotNull null
            if (mask and (0b1 shl index) != 0) {
                val sub = substitutions[irTypeParameter.symbol]
                if (sub != null)
                    stabilityOf(sub, substitutions, analyzing)
                else
                    Stability.Parameter(irTypeParameter)
            } else null
        }
    )
}

Stableではなく、Stability.Combinedを返していることを不思議に思うかもしれません。

stabilityOf関数の内部では正規化(normalize)までは行いません。

正規化するかは stabilityOf の呼び出し側に委ねられていて、以下のような拡張関数 Stability.normalize() が定義されています。

fun Stability.normalize(): Stability {
    when (this) {
        // if not combined, there is no normalization needed
        is Stability.Certain,
        is Stability.Parameter,
        is Stability.Runtime,
        is Stability.Unknown,
        -> return this

        is Stability.Combined -> {
            // if combined, we perform the more expensive normalization process
        }
    }
    val parameters = mutableSetOf<IrTypeParameterSymbol>()
    val parts = mutableListOf<Stability>()
    val stack = mutableListOf<Stability>(this)
    while (stack.isNotEmpty()) {
        when (val stability: Stability = stack.removeAt(stack.size - 1)) {
            is Stability.Combined -> {
                stack.addAll(stability.elements)
            }

            is Stability.Certain -> {
                if (!stability.stable)
                    return Stability.Unstable
            }

            is Stability.Parameter -> {
                if (stability.parameter.symbol !in parameters) {
                    parameters.add(stability.parameter.symbol)
                    parts.add(stability)
                }
            }

            is Stability.Runtime -> parts.add(stability)
            is Stability.Unknown -> {
                /* do nothing */
            }
        }
    }
    return Stability.Combined(parts)
}

実装を読み解くとざっくり以下のような変換をしています。

正規化前後比較(例1)
// 正規化前
Combined(
    Certain(true),
    Parameter(T),
    Combined(
        Parameter(T),
        Runtime(Foo),
        Certain(false)
    ),
    Unknown(Bar)
)

// 正規化後
Stability.Unstable
正規化前後比較(例2)
// 正規化前
Combined(
    Certain(true),
    Parameter(T),
    Combined(
        Parameter(T),
        Runtime(Foo),
    ),
    Unknown(Bar)
)

// 正規化後
Combined(
    Parameter(T),
    Runtime(Foo)
)

6-2. 外部モジュールに定義された型の安定性推論

外部モジュールに定義された型に関しては、stability-config.confファイル上に定義があるかを探して判定します。

} else if (declaration.isExternalStableType()) {
    mask = externalTypeMatcherCollection
        .maskForName(declaration.fqNameWhenAvailable) ?: 0
    stability = Stability.Stable
}

こちらも同様に、型引数を受け付けるものであれば、型引数の安定性が影響してくるので、型引数が安定していればStableとなります。

6-3. @StabilityInferred のチェック

Compose Compilerを適用してコンパイルすると、外部モジュールからでも型の安定性を判断できるよう、publicまたはinternalなクラスの内部に $stable フィールドが作られ、@StabilityInferred アノテーションが付与されます。

StabilityInferredアノテーションの付与と$stableフィールドの生成の例
+ @StabilityInferred(parameters = 1)
  class StableClass {
+     val $stable: Int = 0
      val a: Int = 10
  }

+ @StabilityInferred(parameters = 0)
  class UnstableClass {
+     val $stable: Int = 0b1000
      var a: Int = 10
  }

こちらのIR改変はClassStabilityTransformerによって行われます。

val annotation = IrConstructorCallImpl(
    UNDEFINED_OFFSET,
    UNDEFINED_OFFSET,
    StabilityInferredClass.defaultType,
    StabilityInferredClass.constructors.first(),
    typeArgumentsCount = 0,
    constructorTypeArgumentsCount = 0,
    origin = null
).also {
    it.arguments[0] = irConst(parameterMask)
}

if (useK2 && cls.hasFirDeclaration()) {
    context.metadataDeclarationRegistrar.addMetadataVisibleAnnotationsToElement(
        cls,
        annotation,
    )
} else {
    cls.annotations += annotation
    classStabilityInferredCollection?.addClass(cls, parameterMask)
}
if (declaration.hasStableMarker()) {
    metrics.recordClass(
        declaration,
        marked = true,
        stability = Stability.Stable,
    )
    cls.addStabilityMarkerField(irConst(STABLE))
    return cls
}
if (cls.visibility.isPublicAPI || cls.visibility == DescriptorVisibilities.INTERNAL) {
    cls.addStabilityMarkerField(stableExpr)
}

@StabilityInferredparameters bitmaskは、以下の構造を取ります。

下から順番に、型引数の安定性に依存するかどうかが 0(依存しない)か 1(依存する)で埋まっていき、最後の型パラメータの上のbitに、その型が型パラメータに関係なくStableかどうか、すなわちKnownStableかどうかが入ります。

具体的な例を出して説明すると、

  • A は型引数を受け取りますが、型引数に安定性を依存しないので、KnownStableのbitに1が立ちます。
  • B は型引数を受け取り、一つ目の型引数に安定性を依存しているため、最下位のbitに1が立ち、KnownStableのbitは0となります。
StabilityInferredのparametersの例
// A T1 T2 の順番
@StabilityInferred(parameters = 0b100)
class A<T1, T2>

@StabilityInferred(parameters = 0b001)
class B<T1, T2>(val value: T1)

話を戻して、このフェーズでは @StabilityInferred を読み取って型の安定性を推論します。KnownStableであればもちろん Stable ですし、そうでなければランタイムに型の安定性が決定します。

} else {
    val bitmask = declaration.stabilityParamBitmask() ?: return Stability.Unstable

    val knownStableMask =
        if (typeParameters.size < 32) 0b1 shl typeParameters.size else 0
    val isKnownStable = bitmask and knownStableMask != 0
    mask = bitmask and knownStableMask.inv()

    // supporting incremental compilation, where declaration stubs can be
    // in the same module, so we need to use already inferred values
    stability = if (isKnownStable && declaration.isInCurrentModule()) {
        Stability.Stable
    } else {
        Stability.Runtime(declaration)
    }
}

なお、Stability.Runtimeのものは、ランタイムに $stable のフィールドを読み取って安定かどうか判定されるみたいです(参考)。

7. Java由来の外部宣言のチェック

設定ファイルに記述のないJava由来の外部宣言は Unstable になります。

} else if (declaration.origin == IrDeclarationOrigin.IR_EXTERNAL_JAVA_DECLARATION_STUB) {
    return Stability.Unstable
}

8. インターフェースのチェック

インターフェースは実装クラスがどんなフィールドを持つか確定しないので、Unstableです。

if (declaration.isInterface) {
    return Stability.Unknown(declaration)
}

9. メンバ宣言の安定性チェック

ここにきてようやく、メンバ宣言の安定性を検査します。

varのフィールドを持っていると不安定になる」とよく耳にすると思いますが、まさにここでそのチェックがされています。

var stability = Stability.Stable

for (member in declaration.declarations) {
    when (member) {
        is IrProperty -> {
            member.backingField?.let {
                if (member.isVar && !member.isDelegated) return Stability.Unstable
                stability += stabilityOf(it.type, substitutions, analyzing)
            }
        }

        is IrField -> {
            stability += stabilityOf(member.type, substitutions, analyzing)
        }
    }
}

10. スーパークラスの安定性を複合

最後に、スーパークラスの安定性をcombineして、最終的に推論されたStabilityとして出力します。

この Stabilitynormalize されて最終的な安定性が決定します。

declaration.superClass?.let {
    stability += stabilityOf(it, substitutions, analyzing)
}

return stability

以上がStability Inferenceの詳細な判定フローでした!お疲れ様でした。

まとめ

今回は、Composable関数がIR上でどう扱われているのか、を包括的に整理してみました。

特に、ビット演算の詳細ロジックなどは、書籍「Jetpack Compose Internals」でも詳しくは言及されておらず、整理するのに苦労しました。

解説した内容はJetpack Composeを支える技術のほんの一部です。Frontendコンパイラの話は一切していませんし、Compose Runtimeにもあまり触れられていません。IRでやっていることも、もっとたくさんあります。

参考までに、今回情報を整理するにあたって、Kotlin公式リポジトリ内に配置されている実際のプラグイン実装をコンパイラプラグインのエントリポイントを起点に読み進めていました。

テストコードとそのIR出力結果を照らし合わせて見てみたり、頑張ってローカルでCompose Compilerを動かしてみたりしているうちに、だんだんわかってきます。

本記事が Jetpack Compose の内部詳細が気になっている方の一助となれば幸いです。長くなりましたが、最後までお付き合いいただきありがとうございました!

当日福岡でイベントに来てくれたみなさんもありがとうございました。今後ともよろしくお願いします。

参考文献など

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?