mcpField
A
avoiding-subcomposition-pitfalls

avoiding-subcomposition-pitfalls

github.com/skydoves開発520130 回インストール19 週間前に更新
リポジトリ

avoiding-subcomposition-pitfalls について

高コストな SubcomposeLayout の使い方を診断し、測定フェーズでのコンポジションを減らす Android Compose 開発者向けスキルです。置き換えやネスト回避の判断に加え、必要な場合の調整方法も扱います。

主な機能

  1. 01ホットパス内の SubcomposeLayout の特定
  2. 02不要なサブコンポジションをレイアウト修飾子に置換
  3. 03サブコンポジションを行うレイアウトのネスト回避
  4. 04必要な場合のスロット再利用や事前コンポーズ
  5. 05Perfetto トレースによる変更の確認

活用シーン

  1. 01初回フレームや構成変更時の負荷軽減
  2. 02Lazy 項目内の BoxWithConstraints によるジッターへの対処
  3. 03ネストした Scaffold や BoxWithConstraints の簡素化
  4. 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 内部の ComposableLambdaInMeasurePolicy lint が強制しているパターンです。
  • 移行したファイルを grep し、LazyColumn/LazyRow/LazyVerticalGrid のアイテム内に BoxWithConstraints ブロックが 1 つもないことを確認します。
  • 移行したファイルで、Scaffold が別の Scaffold の内側にネストされていないことを確認します。
  • 維持する SubcomposeLayout ごとに、SubcomposeLayoutState に SubcomposeSlotReusePolicy が渡されていること、または、全てのスロットが恒久的で再利用が不要であることをコメントで明示していることを確認します。

参考資料

コメント