本文へスキップ
すべての記事

Jetpack Composeのリコンポジション:本当のトリガーは何か、そして推測をやめた理由

2026年版Jetpack Composeリコンポジション実践ガイド — 型の安定性、不安定なラムダ、LazyColumnのキー、そしてコンパイラ自身のメトリクスレポートの読み方。

MFKAPPS 1 分で読めます

引っかかりのあるComposeの画面には、明確な原因がほとんどない。スタックトレースもクラッシュもlintの警告もなく、ただリストが本来より一段階遅く感じたり、テキストフィールドがキー入力のたびに半フレーム遅れたりするだけだ。よくある犯人は、必要以上に広いUIツリーでリコンポジションが走っていることだが、厄介なのはComposeがそれを教えてくれない点だ。自分から探しに行くまで、毎フレーム静かに余計な仕事をし続ける。ここでは、composableがスキップされるか再実行されるかを実際に決めているものと、推測の代わりに読むための2つのツールを説明する。

リコンポジションとは実際どういうものか

Composeは変数ではなくstateの読み取りを追跡する。composable関数がState<T>を読むと——直接であれremember { mutableStateOf(...) }を通してであれ——ランタイムはその読み取りを現在のcompositionスコープに対して記録する。stateの値が変わると、実際にそれを読んでいたスコープだけが再実行対象としてスケジュールされる。理論上、ツリー内の残りはそのまま触れられない。この文で仕事をしているのは「実際にそれを読んでいたスコープ」という言葉であり、どのスコープがそれに当たるのかを突き止めることこそが、事がおかしくなる場所だ。

コンパイラの本当の問い:この型は安定か

すべてのcomposable関数のパラメータは、Composeコンパイラによって安定または不安定に分類される。安定な型とは、compositionに通知せずに変化しないことをコンパイラが証明できる型で、値が前回とequals()で等しければComposeは関数の再実行を丸ごとスキップする。不安定な型はそこまで信頼できないため、それを使うcomposableは親のリコンポジションのたびに再実行される——実際に読んでいるものが変わったかどうかに関わらず。

// 安定:すべてのプロパティがvalで、String/Intは定義上安定している。
data class PantryItem(val id: Long, val name: String, val quantity: Int)

// 不安定:varプロパティがあると、composableが参照を保持している間に
// このインスタンスが変異しないことをコンパイラが証明できなくなる。
data class PantryItemDraft(var name: String, var quantity: Int)

var版は単なるスタイルの好みではない——それはComposeがスキップできるcomposableとできないcomposableの違いそのものだ。同じ問題は単純なList<T>Map<K, V>パラメータでも起きる。コンパイラはListインターフェースを不安定として扱う。呼び出し側がMutableListを渡して後で変異させることを妨げるものが何もないからだ。安定したvalフィールドで満たされたdata classであっても、単純なListでラップされていれば、それでも不安定なパラメータのままであり、これは実際のコードベースで安定性が静かに壊れる最も一般的な単一の場所だ。

気づかないうちに紛れ込む場所

自分の画面で追跡した不要なリコンポジションの多くは、次の2つのパターンに集約される。

hoistされていないラムダ。 composableの本体内でインラインに定義されたラムダは、キャプチャした値が変わっていなくても、親のリコンポジションのたびに新しいオブジェクトになる。子composableに渡されると、この新しいインスタンスは等価性チェックに失敗し、実際に読んでいるものが変わったかどうかに関わらず子を強制的に再コンポーズさせる。

// PantryScreenが再コンポーズするたびに再生成される — ItemRowでのskipを台無しにする。
PantryList(items = items, onDelete = { id -> viewModel.delete(id) })

// 一度だけhoistされ、PantryScreenのリコンポジションを通じて安定している。
val onDelete = remember(viewModel) { { id: Long -> viewModel.delete(id) } }
PantryList(items = items, onDelete = onDelete)

リポジトリから直接渡されるコレクション。 collectAsStateWithLifecycle()で収集されたFlow<List<PantryItem>>は、中身が同一であっても発行のたびに新しいListインスタンスを渡す——これは、無関係なテーブルへの書き込みの直後にRoomクエリが再実行される際によく起こる。そのリストが複数の子composableに渡されると、すべてが発行のたびに再コンポーズする。本当に気にしているのが生のリストそのものではなく計算された値である場合、読み取り側をderivedStateOfでラップするとこれを解決できる。

val isEmpty by remember {
    derivedStateOf { items.isEmpty() }
}

derivedStateOf計算された出力が変わったときだけリコンポジションを発火させ、入力が変わったときには発火させない——つまり40個から41個に増えたリストは、リストが空かどうかだけを気にするcomposableを再発火させない。

リスト:行単位でリコンポジションを絞り込むキー

LazyColumnはデフォルトでアイテムの位置を識別キーとして使う。末尾以外の場所で並べ替え、挿入、削除を行うと、そのインデックス以降のすべての行が「変化した」として扱われる——Composeが追跡しているのが内容ではなく位置だからだ。明示的で安定したキーを与えることで、実際に内容が変わった行だけにリコンポジションを絞り込み、これを解決できる。

LazyColumn {
    items(items = pantryItems, key = { it.id }) { item ->
        PantryItemRow(item)
    }
}

これはまさにStockyのパントリーリストの背後にあるパターンで、バーコードスキャンやレシート取り込みから数百行を日常的に保持している——安定したキーがなければ、1つのアイテムの数量を編集するだけで、変更された1行ではなく表示中のリスト全体が再コンポーズされてしまう。

コンパイラ自身のレポートを読む

どのcomposableが不安定かを推測する必要はない——Composeコンパイラが直接教えてくれる。Gradleビルドにメトリクスフラグを追加すると、すべてのcomposable関数と、それがスキップ可能かどうか、そしてどのパラメータが原因で不安定になったのかを列挙したレポートが生成される。

// build.gradle.kts
composeCompiler {
    metricsDestination = layout.buildDirectory.dir("compose_metrics")
    reportsDestination = layout.buildDirectory.dir("compose_metrics")
}

生成される*-composables.txtレポートは、フラグを立てたすべての関数について不安定なパラメータの名前を示す。これにより「なぜこの画面は遅いのか」という問いは、プロファイリングセッションからgrep一発に変わる。リストや頻繁に更新される値を含む画面をリリースする前には必ずこれを実行している——安価で、正確で、data class内のvarミスをファイルを開くのとほぼ同じ時間で見つけてくれる。

まとめ

リコンポジション自体は本質的にパフォーマンス問題ではない——Composeは関数を安価に、そして頻繁に再実行するよう設計されている。問題は絞り込まれていないリコンポジションだ。不安定なパラメータ1つ、hoistされていないラムダ1つ、あるいは欠けたリストキー1つが、コンパイラに「何も安全にスキップできると証明できない」と告げてしまい、画面全体が再実行されてしまう。この3つを直せば——valフィールドによる安定した型、hoistされたラムダ、そしてすべてのLazyColumnアイテムへの明示的なキー——Composeのパフォーマンス問題に見えるものの大半は、実はすでに解決されていたことがわかる。