avoiding-subcomposition-pitfalls について
高コストな SubcomposeLayout の使い方を診断し、測定フェーズでのコンポジションを減らす Android Compose 開発者向けスキルです。置き換えやネスト回避の判断に加え、必要な場合の調整方法も扱います。
主な機能
- 01ホットパス内の SubcomposeLayout の特定
- 02不要なサブコンポジションをレイアウト修飾子に置換
- 03サブコンポジションを行うレイアウトのネスト回避
- 04必要な場合のスロット再利用や事前コンポーズ
- 05Perfetto トレースによる変更の確認
活用シーン
- 01初回フレームや構成変更時の負荷軽減
- 02Lazy 項目内の BoxWithConstraints によるジッターへの対処
- 03ネストした Scaffold や BoxWithConstraints の簡素化
- 04測定制約が必要なレイアウトの調整
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ skydoves/ compose-performance-skills --skill avoiding-subcomposition-pitfallsインストール後、Claude Code で/avoiding-subcomposition-pitfallsを入力して読み込まれたか確認します。
詳細
サブコンポジションの落とし穴を避ける — 計測パスではコンポジションを行わない
SubcomposeLayout は、親のコンポジションパスではなく、計測パス中にコンテンツをコンポジションします。その代わり、子のコンポジション内で計測済みの制約を使えるようになります。BoxWithConstraints、material3.Scaffold、遅延レイアウトはいずれもこれをベースに構築されているため、ネストしたり、頻繁に実行される処理経路に配置したりするまでは、そのコストが表面化しません。このスキルでは、誤用を見つける方法、機能が不要な場合に SubcomposeLayout をより低コストなプリミティブに置き換える方法、必要な場合に効率を保つ方法を Claude に教えます。
このスキルを使う場合
- 開発者が、いくつかのコンポーザブルから選ぶために
maxWidth/maxHeightを読み取ることだけを目的としてコンテンツをBoxWithConstraintsで囲んでおり、初回フレームや構成変更(回転、IME の表示)時に画面が重く感じられる場合。 BoxWithConstraintsまたはScaffoldが別のBoxWithConstraintsまたはScaffoldの中にネストされ、サブコンポジションのコストが増大している場合。BoxWithConstraintsがLazyColumn/LazyRowのアイテム内にあり、ラップする前にはなかったスクロール時のカクつきが開発者から報告されている場合。- 開発者が
SubcomposeLayoutブロックを手動で記述しているが、制約を使うのは最終的な配置の計算だけで、子のコンポジションを制御するためには使っていない場合。 - Layout Inspector または Perfetto トレースで、親の制約変更(回転、ドラッグによるサイズ変更、コンテナサイズのアニメーション)に応じて増加する
Compose:applyChangesまたは計測フェーズ中のコンポジション回数が確認できる場合。 - カスタムの
SubcomposeLayoutのmeasurePolicyブロックが、測定のたびにsubcompose(slotId) { … }内で新しいコンポーザブルラムダを割り当てている場合。AndroidX は内部のリントチェックComposableLambdaInMeasurePolicy(compose/lint/internal-lint-checks/内)で、まさにこのパターンを自身のコードベースに対して検出しています。BoxWithConstraints、material.Scaffold、material3.TabRowなどの公開 API では、このトレードオフが公開 API の契約に不可欠なため、このチェックを抑制しています。同じパターンを再現するアプリ側のコードは、抑制する理由がないまま同じコストを負担します。
このスキルを使わない場合
- 子のコンテンツが別の子の計測済みサイズに依存しており、その子をコンポジションする必要がある場合。これは
SubcomposeLayoutの典型的なユースケースであり、より低コストなプリミティブはありません。SubcomposeLayoutは維持し、調整してください(「正当なSubcomposeLayoutの調整」パターンを参照)。 - 遅い処理経路が
LazyColumnのアイテム内にあるものの、原因がサブコンポジションではなく不安定なパラメーターである場合。まず../../stability/diagnosing-compose-stability/SKILL.mdを使って診断してください。 - 繰り返しの計測を引き起こす状態の読み取りが、構造的な制約ではなくアニメーション値によるものである場合。該当するのは
../deferring-state-reads/SKILL.mdです。 - ユーザーが遅延リストのプリフェッチを最適化したい場合。該当するのは
../../lists/configuring-lazy-prefetch/SKILL.mdです。
前提条件
- Compose の 3 つのフェーズ(Composition、Layout、Draw)に関する知識が必要です。不明な場合は、まず
../deferring-state-reads/references/three-phases.mdを読んでください。 - 最終的な測定には R8 を有効にした release ビルドが必要です。デバッグビルドが誤解を招く理由については、
../../measurement/testing-compose-in-release-mode/SKILL.mdを参照してください。 - Layout Inspector または Perfetto のトレースツールが必要です。計測フェーズの処理をシステムトレースに表示するための
androidx.compose.runtime:runtime-tracingの設定方法は、../../measurement/tracing-recompositions-at-runtime/SKILL.mdを参照してください。 SubcomposeSlotReusePolicy(maxSlotsToRetainForReuse: Int)ファクトリーと、遅延リストの経路における一時停止可能なサブコンポジションを公開する、最新の Compose UI バージョンが必要です。どちらも現在のandroidx.compose.ui:ui/androidx.compose.foundation:foundationアーティファクトに含まれています。古いリリースに固定する場合は、https://developer.android.com/jetpack/androidx/releases/compose-ui と https://developer.android.com/jetpack/androidx/releases/compose-foundation のリリースノートを確認してください。
ワークフロー
-
1. ホットパスにあるすべての
SubcomposeLayoutユーザーを特定します。 影響を受ける画面でBoxWithConstraints、Scaffold、SubcomposeLayoutと、それらに委譲するカスタムの@Composableを grep します。material3.Scaffoldとfoundation.layout.BoxWithConstraintsは、どちらもSubcomposeLayoutを使用して実装されており、コストは同じです。 -
2. 各ユーザーについて、制約がコンポジション中に実際に使用されているか判断します。
maxWidthを読み取り、パラメーターとして子に渡すだけのBoxWithConstraintsは、サブコンポジションの仕組みを無駄にしています。maxWidth < 600.dpに基づいて、まったく異なる2つの@Composableツリーから一方を選ぶBoxWithConstraintsは、適切に使用されています。 -
3. 正当な理由のない
BoxWithConstraintsを、より低コストなプリミティブに置き換えます。 使用側が最終的なサイズをピクセル単位で必要とするだけの場合(たとえば、オフセット、アルファ値、描画コマンドの計算)、代わりにModifier.layout { measurable, constraints -> … }またはModifier.onSizeChanged { … }を使用します。どちらも新しいコンポジションを開始せずに、レイアウトフェーズ中に実行されます。 -
4. サブコンポジションを行うレイアウトの入れ子を避けます。
Scaffoldを2つ入れ子にしたり、別のBoxWithConstraintsを含むScaffoldのスロット内にBoxWithConstraintsを配置したりすると、計測フェーズでのコンポジションが増大します。外側のレイアウトを画面のルートに引き上げ、内側の分岐には通常のBox/Column/Rowを使用します。 -
5.
LazyColumn/LazyRowのアイテムをBoxWithConstraintsで囲まないでください。 各アイテムは計測中にサブコンポジションを行います。アイテムで利用可能な幅が必要な場合は、親から一度だけ渡す(LazyColumnはすでに自身の制約を把握しています)か、1回だけサイズを取得するためにModifier.fillMaxWidth().onSizeChanged { … }を使用します。 -
6.
SubcomposeLayoutを使用する正当な理由がある場合は、SubcomposeLayoutState(SubcomposeSlotReusePolicy(n))を設定します。 state オブジェクトは、以前使用したスロットを最大n個保持するため、次のサブコンポジションでは新たにコンポジションを行う代わりに、保持したスロットテーブルとノードサブツリーを再利用できます。ファクトリーSubcomposeSlotReusePolicy(maxSlotsToRetainForReuse: Int)はandroidx.compose.ui.layoutに含まれています。 -
7. 次に使うスロットが予測できる場合は、前のフレームで
state.precompose(slotId, content)を呼び出します。PrecomposedSlotHandleが返されます。計測中に次のsubcompose(slotId, …)を呼び出すと、コンポジションの手順がスキップされます。これこそ、遅延レイアウトがアイテムのコンポジションを複数のフレームに分散する方法です。 -
8. Perfetto のシステムトレースで検証します。
androidx.compose.runtime:runtime-tracingを追加し、スクロールやサイズ変更のたびに、親の制約変更と同じ頻度でCompose:recomposeとCompose:applyChangesが発生しなくなったことを確認します。計測対象の画面では、毎フレームではなく、実際に構造が変化したとき(回転など)にのみサブコンポジションが発生するはずです。
パターン
パターン:サイズを読み取るために BoxWithConstraints を使用 — Modifier.onSizeChanged に置き換える
// WRONG
@Composable
fun ParallaxHeader(scrollOffset: () -> Float) {
BoxWithConstraints {
val widthPx = with(LocalDensity.current) { maxWidth.toPx() }
Image(
painter = painterResource(R.drawable.hero),
modifier = Modifier
.fillMaxWidth()
.height(220.dp)
.graphicsLayer {
translationX = scrollOffset() * widthPx * 0.2f
},
contentDescription = null,
)
}
}
// WRONG because: BoxWithConstraints subcomposes its content during the measure phase. Each
// new constraint (rotation, IME show, animated parent size) re-runs that subcomposition just
// to read maxWidth, when a Modifier.onSizeChanged would have given the same value with no
// extra composition.
// RIGHT
@Composable
fun ParallaxHeader(scrollOffset: () -> Float) {
var widthPx by remember { mutableStateOf(0f) }
Image(
painter = painterResource(R.drawable.hero),
modifier = Modifier
.fillMaxWidth()
.height(220.dp)
.onSizeChanged { widthPx = it.width.toFloat() }
.graphicsLayer {
translationX = scrollOffset() * widthPx * 0.2f
},
contentDescription = null,
)
}
onSizeChanged はレイアウトフェーズで実行され(サブコンポジションは発生しません)、基盤となる onRemeasured は各レイアウトパスで呼び出されます。計測されたサイズが前回のサイズと異なる場合に、ユーザーコールバックに通知します。graphicsLayer ブロックは Draw フェーズで実行され、最新の widthPx と最新のスクロールオフセットを使用します。
パターン:レイアウトを選択するために BoxWithConstraints を使用 — 維持して、上位に移動する
// WRONG
@Composable
fun ProductCard(product: Product) {
BoxWithConstraints {
if (maxWidth < 360.dp) {
CompactProductCard(product)
} else {
WideProductCard(product)
}
}
}
@Composable
fun ProductGrid(products: List<Product>) {
LazyVerticalGrid(columns = GridCells.Fixed(2)) {
items(products) { ProductCard(it) }
}
}
// WRONG because: every grid cell wraps its content in BoxWithConstraints. Each cell pays
// for a subcomposition during the grid's measure pass. With 200 cells visible across a long
// scroll session, that is 200 redundant subcompositions on every measurement.
// RIGHT
@Composable
fun ProductGrid(products: List<Product>) {
BoxWithConstraints {
val isCompact = maxWidth / 2 < 360.dp
LazyVerticalGrid(columns = GridCells.Fixed(2)) {
items(products) { product ->
if (isCompact) CompactProductCard(product) else WideProductCard(product)
}
}
}
}
グリッドのルートにある単一の BoxWithConstraints は、親の制約が変わるたびに1回サブコンポジションを行います。選択結果は、安定していてスキップ可能な単純な Boolean として、すべてのアイテムに渡されます。
パターン:入れ子になった Scaffold — 1つにまとめる
// WRONG
@Composable
fun ProfileScreen(...) {
Scaffold(topBar = { TopBar(...) }) { outer ->
Scaffold(
modifier = Modifier.padding(outer),
bottomBar = { ProfileTabBar(...) },
) { inner ->
ProfileContent(modifier = Modifier.padding(inner))
}
}
}
// WRONG because: each Scaffold is a SubcomposeLayout with its own measure-phase composition.
// Nesting them doubles that cost on every frame whose constraints change (IME show, rotation,
// nav bar inset change).
// RIGHT
@Composable
fun ProfileScreen(...) {
Scaffold(
topBar = { TopBar(...) },
bottomBar = { ProfileTabBar(...) },
) { padding ->
ProfileContent(modifier = Modifier.padding(padding))
}
}
Scaffold は、単一のサブコンポジション内でトップバー、ボトムバー、スナックバーホスト、FAB をすでにサポートしています。別の Scaffold を入れ子にせず、用意されているスロットを使用します。
パターン:妥当な理由がある SubcomposeLayout の調整
画面で測定時に制約へのアクセスが本当に必要な場合(タブがそれぞれのコンテンツに合わせてサイズ調整され、行の最大幅を共有するタブストリップ、ルックアヘッド駆動の共有要素コンテナー、測定済みアンカーに配置するカスタムポップオーバーなど)は、SubcomposeLayout を維持しつつ、状態を調整してください。
@Composable
fun BadgeRow(badges: List<Badge>) {
val slotState = remember {
SubcomposeLayoutState(SubcomposeSlotReusePolicy(maxSlotsToRetainForReuse = 8))
}
SubcomposeLayout(state = slotState) { constraints ->
val measurables = subcompose(slotId = "badges") {
badges.fastForEach { Badge(it) }
}
val placeables = measurables.map { it.measure(constraints) }
val width = placeables.sumOf { it.width }
val height = placeables.maxOf { it.height }
layout(width, height) {
var x = 0
placeables.fastForEach { p ->
p.place(x, 0)
x += p.width
}
}
}
}
SubcomposeSlotReusePolicy(8) により、レイアウトは以前にコンポーズしたスロットを最大 8 個保持できます。スロット ID が再び現れたとき(たとえば、画面外に出たバッジが再び画面内に入ったとき)、レイアウトはコンポジションを再実行する代わりに既存のスロットテーブルを再利用します。遅延レイアウトでも、独自のポリシー実装を使って同様の仕組みを採用しています。
今後使うことが予測できるスロットについては、前のフレームで state.precompose(slotId, content) を呼び出し、最終的にスロットが不要になった場合は、返された PrecomposedSlotHandle を破棄してください。測定中に次回 subcompose(slotId, …) を呼び出すと、コンポジションの手順を挟まずに、事前コンポーズ済みのコンテンツが再利用されます。
パターン:BoxWithConstraints を含む LazyListScope.item { }
// WRONG
LazyColumn {
items(rows) { row ->
BoxWithConstraints {
val isWide = maxWidth > 600.dp
RowContent(row, isWide)
}
}
}
// WRONG because: each row item subcomposes during the LazyColumn's measure pass. Scroll the
// list and every newly-visible item pays for a fresh subcomposition. On a 60Hz feed this is
// the difference between smooth and dropped frames.
// RIGHT
@Composable
fun RowFeed(rows: List<Row>) {
BoxWithConstraints {
val isWide = maxWidth > 600.dp
LazyColumn {
items(rows, key = { it.id }) { row -> RowContent(row, isWide) }
}
}
}
フィードのルートにある単一の BoxWithConstraints は、フィード自体が再測定されるときにのみサブコンポーズします。行ごとのアイテムは、安定した Boolean パラメーターを持つ単純な RowContent の呼び出しであり、強力なスキップ機能によって処理されます。
必須ルール
maxWidth/maxHeightを読み取ることだけを目的に、Modifierレベルの効果でBoxWithConstraintsを使用してはなりません。代わりにModifier.onSizeChanged { … }またはModifier.layout { … }を使用してください。LazyColumn/LazyRow/LazyVerticalGridのアイテム内にBoxWithConstraintsを配置してはなりません。制約の読み取りを遅延レイアウトの親に移し、解決した値を下位に渡してください。Scaffoldを別のScaffoldの内側にネストしてはなりません。外側のScaffoldがすでに公開しているスロット(topBar、bottomBar、snackbarHost、floatingActionButton)を使用してください。- 子のコンポジションが測定値(親の制約、兄弟要素のサイズ)に本当に依存する場合は、
SubcomposeLayoutを維持しなければなりません。重要なのは適切なツールを使うことであり、SubcomposeLayoutを禁止することではありません。 - スロットが出入りする場合は、
SubcomposeLayoutStateにSubcomposeSlotReusePolicyを渡さなければなりません。デフォルトの何もしないポリシーはスロットをすぐに破棄するため、再び現れるたびにコンポジションのコストが全て発生します。 - 推奨: 今後使うことが予測できるスロットについては、前のフレームで
state.precompose(slotId, content)を呼び出し、測定パスのsubcomposeで結果を再利用してください。 - 推奨: 値が
graphicsLayer { }、drawBehind { }、またはその他のレイアウト/描画フェーズの処理にのみ渡される場合は、BoxWithConstraintsの本体でmaxWidthを読み取る代わりに、Modifier.onSizeChanged { … }で一度だけ測定してください。
検証
-
androidx.compose.runtime:runtime-tracingを使って記録した Perfetto のシステムトレースで、対象サブツリーの測定ごとにCompose:recomposeとCompose:applyChangesが発生しなくなっていることを確認します。これらは、実際に構造が変化したとき(画面回転、画面遷移、リストアイテムの追加)にのみ発生するはずです。 - 以前
BoxWithConstraintsでラップされていたサブツリーの Layout Inspector の再コンポジション回数が、親の制約変更ごとに増加しないことを確認します。 - 維持するカスタム
SubcomposeLayoutがある場合、subcompose(slotId) { … }のコンテンツラムダはrememberで安定した参照にホイストされていること(または、新しいラムダを測定ごとに生成しても安全な理由を説明する 1 行コメントがあり、その処理が意図的で低コストであること)を確認します。これは AndroidX のコードベースで AndroidX 内部のComposableLambdaInMeasurePolicylint が強制しているパターンです。 - 移行したファイルを grep し、
LazyColumn/LazyRow/LazyVerticalGridのアイテム内にBoxWithConstraintsブロックが 1 つもないことを確認します。 - 移行したファイルで、
Scaffoldが別のScaffoldの内側にネストされていないことを確認します。 - 維持する
SubcomposeLayoutごとに、SubcomposeLayoutStateにSubcomposeSlotReusePolicyが渡されていること、または、全てのスロットが恒久的で再利用が不要であることをコメントで明示していることを確認します。
参考資料
- Android Developers — カスタムレイアウト:https://developer.android.com/develop/ui/compose/layouts/custom
- Android Developers — Jetpack Compose の各フェーズ:https://developer.android.com/develop/ui/compose/phases
- Android Developers —
SubcomposeLayoutリファレンス:https://developer.android.com/reference/kotlin/androidx/compose/ui/layout/package-summary#SubcomposeLayout(androidx.compose.ui.Modifier,kotlin.Function2) - Compose UI ソース —
SubcomposeLayout.kt:https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/layout/SubcomposeLayout.kt - Compose foundation ソース —
BoxWithConstraints.kt:https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/foundation/foundation-layout/src/commonMain/kotlin/androidx/compose/foundation/layout/BoxWithConstraints.kt - Material3 ソース —
Scaffold.kt(クロームレイアウトにSubcomposeLayoutを使用):https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Scaffold.kt - Chris Banes — 「Composable metrics」(Perfetto トレースにおけるサブコンポーズのコスト):https://chrisbanes.me/posts/composable-metrics/
- skydoves — Jetpack Compose の仕組み(コンポジションとサブコンポジション):https://speakerdeck.com/skydoves/jetpack-compose-mechanism
../deferring-state-reads/references/three-phases.md— 「計測中にコンポジションを実行しない」という考え方の背景となる、3 フェーズモデルの説明です。../../lists/configuring-lazy-prefetch/SKILL.md— 遅延レイアウトではprecomposeと一時停止可能なサブコンポジションを使用します。同じ API は、独自のSubcomposeLayoutを使用する場合にも利用できます。