はじめに
Jetpack Compose が強力なのは、「状態が変わったところだけ UI を再実行する賢い再コンポーズ(Recomposition)」 にある。
しかし、「どこが・どう・どの順番で再実行されているか?」は表からは見えないため、ブラックボックスに感じる開発者も多い。
この記事では、Recomposition の内部処理を、SlotTable・Snapshot・依存関係の追跡ロジックまで踏み込んで解説する。
単なる「状態が変わると UI が更新される」ではなく
Compose がどのように 最小限の処理で UI を再構築しているのか を説明する。
1. Recomposition の全体構造をまず俯瞰する
Compose UI の実行は、大きく分けて3つのステージで構成されている:
-
Composition
→ 初回の Composable 実行で UI ツリーを構築し SlotTable に記録 -
Recomposition
→ 状態変化に応じて必要な部分だけ再実行 -
Applier による差分反映
→ 実際の UI(LayoutNode など)へ更新
これらを司るのが Recomposer であり、
状態管理の要となるのが Snapshot と SlotTable である。
2. 初回 Composition で行われること
setContent { MyScreen() } が呼ばれたときの処理は想像以上に複雑だが、重要なのは次の2点。
① Composable の実行結果が UI ツリーとして構築される
- LayoutNode(Compose UI の低レベル構造)が生成され
- Applier が UI に適用する
② SlotTable に「Composable の構造と remember の保存領域」を記録する
SlotTable には:
- Composable の階層(Group)
- キー (
key()) - remember の領域
- 前回実行時の値
- 引数情報
などが保存される。
SlotTable は言うなれば Compose の“巨大なノート”。
3. 状態を読むと「依存関係」が登録される
Compose の最大の特徴は:
State を“読んだ瞬間に”
「どの Composable がこの値に依存しているか」
が Snapshot システムに記録されること。
例:
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) }
Text("Count: $count") // ← ここで依存関係が登録される
}
Snapshot システムは:
- “誰が何を読んだか(Read Tracking)”
- “誰が何を変更したか(Write Tracking)”
の物流を常時監視している。
4. 状態更新 → 再コンポーズ決定までの流れ
count++ が起きたとき、内部では次が起きる:
① mutableState の値が Snapshot に書き込まれる
内部処理:
- 値が変化
- 「このStateは変更された」とマーク
② Recomposer が変更通知(invalidation)を受け取る
Snapshot システムは
- この State を読んでいた Composable のリスト
- それが属するグループの位置(SlotTable の範囲)
を Recomposer の 再コンポーズ候補キュー に追加する。
③ 次のフレームで Recomposer が動き出す
Android の Choreographer(UI フレーム)と同期して、
「今 invalidation が来てるのはこの Composable グループだな」
→ SlotTable のその範囲だけ再実行
という極めて効率的な再実行が行われる。
④ Applier が差分を UI ツリーに反映
最終的に UI に適用されるのは:
- 更新された Text
- 再配置された Column
- 新しい LayoutNode
など “必要な差分だけ”。
5. Recomposition は「部分的」で「スキップ可能」
Compose の Recomposition は次のような最適化を行う:
① Group 単位の部分的な再コンポーズ
Composable は内部的に Group という単位に分解される。
Column {
Header() // Group A
Content() // Group B
Footer() // Group C
}
もし Content() の中の State が変わった場合:
- A … 再コンポーズなし
- B … 再コンポーズされる
- C … 再コンポーズなし
UI の更新はこのグループ単位で行われる。
② 引数が変わっていない Composable はスキップされる
Compose は:
- 前回実行時の引数
- 今回実行時の引数
- その型の安定性(@Stable / @Immutable)
を比較して、
「この Composable はもう一度実行しなくていい」
と判断できるなら、まるごとスキップする。
③ remember が SlotTable の位置でマッピングされる
val state = remember { mutableStateOf(0) }
remember は SlotTable に次のような形で保存される:
- 何番目の slot にあるか
- その値を再コンポーズで再利用するか
- 初期化ブロックを実行すべきか
このため、
remember を条件分岐内に置くと SlotTable の構造が変わり崩壊する
ので危ない。
6. derivedStateOf と再コンポーズ最適化
val filtered by remember {
derivedStateOf { items.filter { it > 0 } }
}
derivedStateOf の特徴:
- 依存している State が変わったときだけ再計算
- その derived 値を読んでいる Composable だけ再コンポーズ対象になる
重い計算の最適化に最適。
7. 副作用(LaunchedEffect / SideEffect)の Recomposition ルール
副作用は Recomposition と密接に結びついている。
LaunchedEffect(key)
- key が変わったときだけ再起動
- 再コンポーズでは restart しない(key が同じなら)
LaunchedEffect(userId) {
loadUser(userId)
}
SideEffect
- Recomposition が正常に UI へ commit された瞬間に呼ばれる
- ログや外部 API 通知に使う
DisposableEffect(key)
- key 変更時、または Composable が Composition から外れる時
→onDisposeが呼ばれる
8. Recomposition が“速い理由”まとめ
Compose が速い理由は次の5つ:
① SlotTable により「構造の差分」だけ追跡できる
DOM のようにツリー全体を探索しない。
“どこのグループがどこに対応しているか” を全て記録している。
② Snapshot による効率的な依存関係追跡
誰が何を読んだかが常に把握されているため:
→ “必要な部分だけ”
→ “必要なときだけ”
再実行される。
③ スキップ最適化が強力
引数比較 + Stable/Immutable アノテーションにより
- 実行不要な Composable は完全スキップ
- その子 Composable もまるごとスキップ
④ UI 差分のみを適用(リアル DOM diff より効率的)
Compose の Applier は “変わったところだけ差し替える” ことができる。
しかも SlotTable により探索が最小限。
⑤ 再コンポーズは idempotent & pure な関数として設計
Composable は UI を返す純粋関数であり、
副作用は外だし(Effect)という明確な分離がある。
まとめ
最後に Recomposition を一言でまとめると:
Compose は SlotTable と Snapshot で
「状態と UI の依存関係」を精密に管理し、
必要なグループだけをスキップ判定しながら
再実行する“差分更新エンジン”である。
この仕組みのおかげで、
- 高速
- 効率的
- UI ロジックが明瞭
- 副作用管理が楽
- アーキテクチャが健全
という Compose の強みが成立している。