optimizing-lazy-layouts について
Jetpack Compose 開発者向けの遅延リストとグリッドの最適化スキルです。安定したキー、コンテンツタイプ、項目アニメーション、割り当ての見直しを扱い、項目の安定性確認も重視します。
主な機能
- 01項目に安定したキーを設定
- 02混在リストにコンテンツタイプを設定
- 03Modifier.animateItem() の使用
- 04項目ブロック外への高コスト割り当ての移動
- 05項目のスキップ可能性を確認
- 06再コンポジションを調査
活用シーン
- 01LazyColumn のスクロール時のカクつきを軽減
- 02挿入や並べ替え後の項目状態を維持
- 03混在型フィードを最適化
- 04スクロール中の項目再コンポジションを診断
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ skydoves/ compose-performance-skills --skill optimizing-lazy-layoutsインストール後、Claude Code で/optimizing-lazy-layoutsを入力して読み込まれたか確認します。
詳細
Lazy レイアウトの最適化 — Keys、contentType、animateItem()
Lazy レイアウトは表示されているものだけをコンポーズしますが、それでもコストがかかることが2つあります。再利用されるはずのアイテムの再コンポーズ(key の指定漏れ)と、スクロール速度に応じて積み重なるアイテムごとの割り当て(contentType の指定漏れ、items { } 内での Modifier チェーンの作成)です。どちらも1行で修正できます。このスキルでは、Claude が修正を正しく適用し、アイテムのコンポーザブル自体がスキップ可能であることを検証する方法を説明します。プリフェッチの調整は別のトピックです。../configuring-lazy-prefetch/SKILL.md を参照してください。
このスキルを使う場面
- 開発者から、
LazyColumn、LazyRow、LazyVerticalGrid、LazyHorizontalGridでスクロール時のカクつき、フレーム落ち、または引っかかりが報告されている。 - アイテムの挿入、削除、並べ替え時に、スクロール位置、フォーカス、またはコンポジションの状態が失われる。
- 複数の種類が混在するフィード(カード+ヘッダー+広告+カルーセル)が、各行は個別には軽量なのに動作が重く感じられる。
Modifier.animateItem()を追加したが、挿入や削除時にアニメーションが実行されない。- コンパイラーレポートでアイテムのコンポーザブルが
unstable/スキップ不可と表示される。または@TraceRecompositionにより、スクロールのたびにアイテムのコンポーザブルが再コンポーズされることが分かる。
このスキルを使わない場面
- ボトルネックがプリフェッチの範囲にある(アイテムが重い、スクロール速度が速い、先読み/後読みの範囲を広げたい)→
../configuring-lazy-prefetch/SKILL.mdを使います。 - アイテムのコンポーザブル自体が不安定なパラメーター(
List<Foo>、Flow<Foo>、ドメインモデルのvar)を受け取っている → まず../../stability/diagnosing-compose-stability/SKILL.mdを実行し、次に../../stability/stabilizing-compose-types/SKILL.mdを実行します。 - アイテム内のアニメーションが Composition フェーズで
state.valueを読み取り、フレームごとに行を再コンポーズしている →../../recomposition/deferring-state-reads/SKILL.mdを使います。 - スクロール位置の導出(例:
firstVisibleItemIndex == 0)がホットパスになっている →../../recomposition/choosing-derivedstateof/SKILL.mdを使います。
前提条件
Modifier.animateItem()(試験的なanimateItemPlacementに代わるGA版)には、Compose Foundation 1.7+ が必要です。- Kotlin 2.0.0+ で、
org.jetbrains.kotlin.plugin.composeが適用されていること。Strong Skipping はデフォルトで有効です。スキップ不可のアイテムのコンポーザブルは、スクロール速度に応じて影響が増幅します。 - 測定には実機とリリースビルドを使います。Skydoves のホットテイク #5:デバッグビルドの結果は当てになりません(Live Literals、インタープリターモード)。測定を始める準備ができたら、
../../measurement/generating-baseline-profiles/SKILL.mdを参照してください。
ワークフロー
- 1. すべての
items(...)呼び出しを監査します。 すべてのLazyListScope.items(list)、items(count)、itemsIndexed(list)と、LazyGridScopeの同等の呼び出しを確認します。それぞれについて、各要素に単一のコンポジションを超えて存続する安定した ID があるか判断します。ある場合(ほとんどの場合はあります)は、サーバー側の安定した ID を使ってkey = { it.id }を指定します。リストのインデックス、エミッションごとに評価されるUUID.randomUUID()、可変オブジェクトのhashCode()を使用してはいけません。
// WRONG
LazyColumn { items(snacks) { snack -> SnackRow(snack) } }
// WRONG because: index-based identity → insert/remove discards composition state and breaks animateItem().
// RIGHT
LazyColumn {
items(
items = snacks,
key = { it.id },
contentType = { it::class },
) { snack ->
SnackRow(snack, Modifier.animateItem())
}
}
-
2. 異種リストには
contentTypeを追加します。 遅延レイアウトは、RecyclerView のビュータイプに似た、タイプごとのコンポジションキャッシュを維持します。項目 N + 1 が再利用対象のスロットと同じcontentTypeの場合、キャッシュされたコンポジションが再利用されます。異なる場合は破棄され、再構築されます。同種リストの場合、Compose は単一のコンテンツタイプを推定するため、contentTypeは任意です。カード、ヘッダー、広告、カルーセル、区切り線が混在するフィードでは、安定した型識別子を必ず指定します。 -
3. 項目のコンポーザブルの安定性を検証します。
../../stability/diagnosing-compose-stability/SKILL.mdを実行します。項目のコンポーザブルがunstableなパラメーターを受け取る場合、keyやcontentTypeの作業をどれだけ行っても効果はありません。スクロールによるスナップショットの更新ごとに、その行が再コンポーズされてしまいます。さらに調整を進める前に、../../stability/stabilizing-compose-types/SKILL.mdを使って修正します。 -
4. 割り当てコストの高い値を items ラムダの外に移動します。 items ラムダは、スクロールによる(再)コンポジションのたびに各項目について実行されます。ラムダ内で作成されたペインター、色の解決結果、シェイプ、
BorderStrokeインスタンスは、毎回再割り当てされます。定数を外に移動し、キャッシュにはrememberを使って、LazyColumnより上位か呼び出し元に配置します。Compose は Modifier チェーンを構造的に重複排除するため、それ自体は低コストです。プロファイリングで効果が確認できた場合に限り、Modifierを外に移動します。 -
5. 見た目の連続性を保つために
Modifier.animateItem()を追加します。 安定したkeyと組み合わせます。アニメーションは挿入、削除、並べ替え時に実行されます。keyがないと、アニメーションは ID に関連付けられず、何も起こらずに終了します。デフォルトのフェードイン/フェードアウト/配置スプリングは通常適切です。デザインシステムで必要な場合に限り、fadeInSpec、fadeOutSpec、placementSpecを調整します。 -
6. よく使うペインター/色/シェイプを items ブロックの外でキャッシュします。
painterResource(...)、MaterialTheme.colorScheme.surface、RoundedCornerShape(...)の各項目のコンポジションでの解決処理は、積み重なるとコストになります。画面レベルのコンポーザブルに移動して渡すか、LazyColumnの親で一度だけrememberします。 -
7.
@TraceRecompositionと Layout Inspector で確認します。 条件を揃えてスクロールしたとき、各項目のコンポーザブルは実際の状態変更ごとに最大でも一度再コンポーズされるのが期待されます。スクロールのたびに再コンポーズされてはいけません。Layout Inspector の「Recomposition Counts」列は、単調に増加するのではなく、一定値に落ち着くはずです。
パターン
パターン:key がない
// WRONG
LazyColumn {
items(snacks) { snack -> SnackRow(snack) }
}
// WRONG because: items default to index-based identity. On insert/remove/reorder, every position past the change point has a different "identity", composition state and scroll-restoration are lost, and Modifier.animateItem() has nothing to animate from.
// RIGHT
LazyColumn {
items(snacks, key = { it.id }) { snack -> SnackRow(snack) }
}
パターン:ランダムまたは不安定なキー
// WRONG
items(snacks, key = { UUID.randomUUID() }) { snack -> SnackRow(snack) }
// WRONG because: a fresh key on every recomposition guarantees the cached composition is discarded every time — strictly worse than no key.
// WRONG
items(snacks, key = { it.hashCode() }) { snack -> SnackRow(snack) }
// WRONG because: hashCode() of a mutable type changes when fields mutate, breaking identity continuity for the same logical item.
// RIGHT
items(snacks, key = { it.id }) { snack -> SnackRow(snack) }
パターン:contentType のない混在フィード
// WRONG
items(feed, key = { it.id }) { item ->
when (item) {
is FeedItem.Card -> CardRow(item)
is FeedItem.Ad -> AdRow(item)
is FeedItem.Header -> HeaderRow(item)
}
}
// WRONG because: cached compositions of one type are discarded when scrolled into a different type's slot — every row crossing a type boundary is a fresh build instead of a recycled update.
// RIGHT
items(
items = feed,
key = { it.id },
contentType = { it::class },
) { item ->
when (item) {
is FeedItem.Card -> CardRow(item)
is FeedItem.Ad -> AdRow(item)
is FeedItem.Header -> HeaderRow(item)
}
}
パターン:安定したキーのない Modifier.animateItem()
// WRONG
items(snacks) { snack ->
SnackRow(snack, Modifier.animateItem())
}
// WRONG because: animateItem() binds animation state to the item's key. With no key, identity is index-based, so an insert at position 0 looks like every-row-changed and nothing animates correctly.
// RIGHT
items(snacks, key = { it.id }) { snack ->
SnackRow(snack, Modifier.animateItem())
}
パターン:items ラムダ内での割り当て
// WRONG
items(snacks, key = { it.id }) { snack ->
val placeholder = painterResource(R.drawable.snack_placeholder)
val border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline)
Card(border = border) {
AsyncImage(snack.imageUrl, placeholder = placeholder)
}
}
// WRONG because: painterResource resolution and BorderStroke allocation happen on every item composition; at high scroll velocity these compound into measurable allocation pressure.
// RIGHT
@Composable
fun SnackList(snacks: ImmutableList<Snack>) {
val placeholder = painterResource(R.drawable.snack_placeholder)
val border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline)
LazyColumn {
items(snacks, key = { it.id }, contentType = { it::class }) { snack ->
Card(border = border) {
AsyncImage(snack.imageUrl, placeholder = placeholder)
}
}
}
}
注:Compose は構造的に等価な Modifier チェーンを内部で重複排除するため、項目ごとに Modifier.fillMaxWidth().padding(16.dp) を再割り当てすることは、微細な最適化にすぎません。プロファイリングでボトルネックだと確認できた場合に限り、Modifier を外に移動します。時期尚早に remember { Modifier.… } を使うと、測定可能な効果がないままコードが煩雑になります。
パターン:不安定な項目コンポーザブルがすべての改善効果を損なう
// WRONG
@Composable
fun SnackRow(snack: Snack, tags: List<String>) { /* ... */ }
// Caller:
items(snacks, key = { it.id }) { snack ->
SnackRow(snack, tags = snack.tags)
}
// WRONG because: List<String> is an unstable parameter under inference; every scroll-driven recomposition recomposes the row body even though the snack didn't change.
// RIGHT
@Immutable
data class Snack(val id: Long, val name: String, val tags: ImmutableList<String>)
@Composable
fun SnackRow(snack: Snack) { /* ... */ }
items(snacks, key = { it.id }, contentType = { it::class }) { snack ->
SnackRow(snack)
}
関連項目:../../stability/stabilizing-compose-types/SKILL.md。
パターン:混在するスパンを持つ LazyVerticalGrid
// RIGHT — keys + contentType apply to grids identically
LazyVerticalGrid(columns = GridCells.Fixed(2)) {
items(
items = feed,
key = { it.id },
contentType = { it::class },
span = { item -> if (item is FeedItem.Header) GridItemSpan(maxLineSpan) else GridItemSpan(1) },
) { item ->
when (item) {
is FeedItem.Header -> HeaderRow(item, Modifier.animateItem())
is FeedItem.Card -> CardCell(item, Modifier.animateItem())
}
}
}
必須ルール
- アイテムの識別情報が単一のコンポジションを超えて存続するすべての
items(...)ブロック(実質的には、ドメインオブジェクトを元にしたすべてのリスト)で、必ずkeyを指定してください。 - サーバー側の安定した ID をキーとして必ず使用してください。リストのインデックスを使用してはいけません。各エミッションで評価される
UUID.randomUUID()を使用してはいけません。可変オブジェクトのhashCode()を使用してはいけません。 - 異種リスト(カード+ヘッダー+広告など)では、必ず
contentTypeを指定してください。it::classや sealedenumなど、安定した型識別子を使用してください。 - 安定した
keyなしでModifier.animateItem()を使用してはいけません。アニメーションは何も起こさずに無効になります。 - 遅延レイアウトを原因と決めつける前に、
../../stability/diagnosing-compose-stability/SKILL.mdを使ってアイテムのコンポーザブルの安定性を必ず検証してください。アイテムのパラメーターが不安定だと、key/contentTypeによる改善効果はすべて打ち消されます。 - スキップ可能に「する」ために、
items { }を余分なインラインのコンポーザブルラッパー(Row { items { } })で囲んではいけません。Row/Column/Boxは、そもそも再起動可能でもスキップ可能でもありません(skydoves の辛口見解その 3)。 - 推奨: アイテム単位の修正を済ませた後に限り、高速スクロールする画面で
../configuring-lazy-prefetch/SKILL.mdと組み合わせてください。 - 推奨: 修正を完了と判断する前に、リリースビルド+R8+実機で計測してください(skydoves の辛口見解その 5)。
検証
- 実機のリリースビルドで元のスクロールのカクつきを再現してから、修正後に再度記録します。ドロップフレーム率が測定可能なほど低下していることを確認します。
- 挿入/削除/並べ替え操作後も、スクロール位置とアイテムごとの状態(フォーカス、展開状態、シークした動画位置)が維持されます。
- 挿入と削除の際に、
Modifier.animateItem()によって期待どおりのフェードアニメーションと配置アニメーションが実行されます。 - Layout Inspector → アイテムのコンポーザブルの Recomposition Counts 列の値が、スクロール中に単調増加せず、一定の値に落ち着きます。
- アイテムのコンポーザブルに対する
@TraceRecompositionで、スクロールによる無効化のたびではなく、実際の状態変更時にのみ再コンポジションが発生することが確認できます。 - コンパイラーレポート(
composables.txt)で、アイテムのコンポーザブルがrestartable skippableと表示され、すべてのパラメーターがstableまたはruntimeになっています。
参考資料
- Android Developers — リストとグリッド:https://developer.android.com/develop/ui/compose/lists
- Android Developers — パフォーマンスの概要:https://developer.android.com/develop/ui/compose/performance
- Android Developers — 実践的なパフォーマンスの Codelab:https://developer.android.com/codelabs/jetpack-compose-performance
- Android Developers — Jetpack Compose の新機能(2025 年 4 月、1.8):https://android-developers.googleblog.com/2025/04/whats-new-in-jetpack-compose-april-25.html
- Ben Trengrove — 再コンポジションのデバッグ:https://medium.com/androiddevelopers/jetpack-compose-debugging-recomposition-bfcf4a6f8d37
- Chris Banes — Compose パフォーマンスのタグ:https://chrisbanes.me/tags/jetpack-compose-performance/
- skydoves — Jetpack Compose の 6 つのガイドライン:https://medium.com/proandroiddev/6-jetpack-compose-guidelines-to-optimize-your-app-performance-be18533721f9
- skydoves — compose-performance ハブ:https://github.com/skydoves/compose-performance