mcpField
D
deferring-state-reads

deferring-state-reads

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

deferring-state-reads について

頻繁に変化する状態の読み取りを Composition から Layout または Draw に移し、Jetpack Compose の再コンポーズを抑える Claude 向けスキルです。アニメーションやスクロール UI で使うラムダ形式の Modifier と検証方法を解説します。

主な機能

  1. 01Composition の無効化を引き起こす状態読み取りの特定
  2. 02値形式の Modifier を Layout または Draw のラムダ形式に変更
  3. 03graphicsLayer でアニメーション変換をまとめる
  4. 04ラムダプロバイダーでコンポーザブル間にホット値を渡す
  5. 05Layout Inspector やトレースで再コンポーズの変化を検証

活用シーン

  1. 01アニメーション中の再コンポーズの抑制
  2. 02スクロール中の親コンポーザブルの再コンポーズ防止
  3. 03アニメーション描画処理を Composition の外へ移動

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/skydoves/compose-performance-skills --skill deferring-state-reads

インストール後、Claude Code で/deferring-state-readsを入力して読み込まれたか確認します。

同梱ファイルreferences/three-phases.md

詳細

状態の読み取りを後回しにする — 頻繁に発生する読み取りをコンポジションから描画へ移動する

Compose はフレームごとに コンポジション → レイアウト → 描画 の3つのフェーズを実行します。フェーズ N で状態を読み取ると、フェーズ N と、それより後のすべてのフェーズが無効化されます。アニメーションやスクロール駆動の UI で最も大きなパフォーマンス向上につながるのは、ラムダベースの修飾子を使って状態の読み取りをコンポジションからレイアウトまたは描画へ移動することです。このスキルでは、Claude がフェーズを誤っている読み取りを見つけ、移行する方法を学びます。

このスキルを使用する場合

  • アニメーションによって、フレームごとにサブツリー全体が再コンポジションされる場合(Modifier.alpha(progress.value)、Modifier.offset(x.dp)、Modifier.padding(state.dp) — padding にはラムダオーバーロードがないため、インセットがアニメーションする場合は代わりに Modifier.layout { ... } または Modifier.offset { IntOffset(...) } を使用します)。
  • コンポーザブルの本体でスクロール位置を直接読み取り(val y = scrollState.value)、スクロールのピクセルごとに親が再コンポジションされる場合。
  • 開発者が「毎フレーム」「スクロールのカクつき」「アニメーションのカクつき」「フレーム落ち」と言う場合、またはドラッグ時に画面全体が再コンポジションされると報告する場合。
  • @TraceRecomposition のログで、アニメーションのティックごとに親の再コンポジションカウンターが増加している場合。
  • 開発者が、頻繁に変化する値(アニメーションの進行状況、スクロールオフセット、ドラッグの差分)を Float パラメーターとしてコンポーザブル間で渡している場合。

このスキルを使用しない場合

  • 状態がユーザー操作(ボタンクリック、ダイアログを開く操作)のたびに一度だけ変化する場合 — ラムダ修飾子への書き換えによる効果はなく、コードが冗長になるだけです。
  • 状態の読み取りが本当にコンポジションを制御する必要がある場合(別のコンポーザブルを表示/非表示にする、異なるコンポーネントツリーに切り替えるなど)。ラムダ修飾子では、出力するコンポーザブルを選択できません。
  • スキップ不可能な親が、別の理由(不安定なパラメーター)でスキップ不可能な場合。まず ../../stability/diagnosing-compose-stability/SKILL.md を使って診断してください。
  • 開発者が、多数の高頻度入力を頻度の低い1つのブール値にまとめたい場合 — それには ../choosing-derivedstateof/SKILL.md を使用します。

前提条件

  • 3フェーズモデルに精通していること。読み取りがどのフェーズで発生するか不明な場合は、移行前に references/three-phases.md を読んでください。
  • rememberGraphicsLayer() と最新の Modifier.animateItem() の形式には Compose UI 1.7+ が必要です。Modifier.offset { }、Modifier.graphicsLayer { }、Modifier.drawBehind { }、Modifier.drawWithCache { } のラムダ形式は、サポートされているすべての Compose バージョンで利用できます。
  • Layout Inspector または @TraceRecomposition を使って再コンポジション回数を確認できること(このリポジトリに ../debugging-recompositions/SKILL.md があれば参照してください。なければ skydoves の compose-stability-analyzer ランタイムを使用してください)。
  • 最終的な計測には リリースビルドを使用してください — デバッグビルドはインタープリター実行されるため、コストを正確に示しません。

ワークフロー

  • 1. 状態を読み取る箇所と、それを使用する修飾子を特定します。 コンポーザブルの本体で state.value、by state、animatedFloat.value、scrollState.value、dragOffset.value を探します。どの修飾子がそれを使用しているかを確認します。

  • 2. 修飾子を分類します — 値形式かラムダ形式か。 位置・サイズ・描画に関する修飾子の多くには、次の2つの形式があります。

値形式(Composition で読み取り)ラムダ形式(Layout または Draw で読み取り)
Modifier.offset(x = dp, y = dp)Modifier.offset { IntOffset(x, y) }
Modifier.padding(start = dp, …)Modifier.layout { measurable, constraints -> … }(padding にラムダオーバーロードはありません。変化の激しい状態を使ってインセットするには、カスタムレイアウトを使用します)
Modifier.size(dp)Modifier.layout { … }(カスタム)
Modifier.alpha(float)Modifier.graphicsLayer { alpha = … }
Modifier.rotate(float)Modifier.graphicsLayer { rotationZ = … }
Modifier.scale(float)Modifier.graphicsLayer { scaleX = …; scaleY = … }
Modifier.background(color)Modifier.drawBehind { drawRect(color) }

値形式に変化の激しい状態を渡している場合は、ラムダ形式に切り替えます。ラムダは Composition を再実行することなく、Layout(または Draw)の各パスで呼び出されます。

  • 3. 値形式の修飾子をラムダ形式に移行します。 状態が変化するとラムダの本体が再実行されますが、Layout または Draw 中に実行されるため、Composition が無効化されることはありません。

  • 4. フレームごとの変換が複数ある場合は、単一の Modifier.graphicsLayer { … } ブロックにまとめます。 これにより、1つの Draw フェーズのノードで translationX/translationY、scaleX/scaleY、rotationZ、alpha、cameraDistance、clip、shape を扱えます。また、alpha + offset + rotate のように連結するよりも推奨されます。

  • 5. ラムダプロバイダーとして、変化の激しい値をコンポーザブル間で受け渡します。 Float ではなく () -> Float を渡すと、受け取る側のコンポーザブルが呼び出し元を無効化することなく、自身のラムダ修飾子内で読み取りを遅延できます。これは、ラムダ修飾子自体に次いで重要な、コンポーザブル間のパフォーマンス最適化のコツです。

  • 6. Layout Inspector/@TraceRecomposition で確認します。 親コンポーザブルの再コンポジション回数が、アニメーションのフレームごとに増加しなくなっている必要があります。まだ増加する場合は、別の箇所に不適切なフェーズでの読み取りが残っています。ファイル内で .value と by state を再検索してください。

  • 7. フレームごとの Draw 処理が単なる変換より複雑な場合は、Modifier.drawBehind { } または Modifier.drawWithCache { } を使用します。 drawBehind は Draw の各パスで再実行されます。drawWithCache は構築処理(パスやブラシなど)をキャッシュし、状態の変化時に onDraw ブロックのみを再実行します。

パターン

パターン:アニメーション付きオフセット
// WRONG
val animatedX = animateFloatAsState(targetX, label = "x")
Box(Modifier.offset(x = animatedX.value.dp))
// WRONG because: reading .value in Composition phase invalidates the whole subtree on every animation frame.
// RIGHT
val animatedX = animateFloatAsState(targetX, label = "x")
Box(Modifier.offset { IntOffset(animatedX.value.toInt(), 0) })

ラムダは Layout で実行されるため、Composition が無効化されることはありません。アニメーションは Layout のみを無効化しながら、引き続き視覚的な位置を制御します。

パターン:アニメーション付きアルファ(およびその他の変換)
// WRONG
val animatedAlpha by animateFloatAsState(targetAlpha, label = "alpha")
Box(Modifier.alpha(animatedAlpha))
// WRONG because: Modifier.alpha(Float) reads the value in Composition; the entire Box subtree recomposes every frame.
// RIGHT
val animatedAlpha by animateFloatAsState(targetAlpha, label = "alpha")
Box(Modifier.graphicsLayer { alpha = animatedAlpha })

graphicsLayer { } 内の読み取りは Draw フェーズでのみ行われます。変換を組み合わせる場合は、すべて同じブロックにまとめます。

// RIGHT — one Draw-phase node, three transforms
Box(
    Modifier.graphicsLayer {
        alpha = progress
        scaleX = 1f + 0.2f * progress
        scaleY = 1f + 0.2f * progress
        translationX = progress * 32.dp.toPx()
    }
)
パターン:スクロールに応じて固定されるヘッダー
// WRONG
val offset = scrollState.value
Header(Modifier.offset(y = offset.dp))
// WRONG because: scrollState.value updates every pixel of scroll, and reading it in the parent body recomposes Header (and every sibling) per scroll tick.
// RIGHT
Header(Modifier.offset { IntOffset(0, scrollState.value) })

このラムダは scrollState(安定したホルダー)をキャプチャし、Layout の実行時にのみ .value を読み取って、無効化を Header の Layout フェーズ内に限定します。

パターン:コンポーザブル間のラムダプロバイダー
// WRONG
@Composable
fun Parent(scrollOffset: Float) {
    Child(scrollOffset)
}

@Composable
fun Child(scrollOffset: Float) {
    Box(Modifier.offset { IntOffset(0, scrollOffset.toInt()) })
}
// WRONG because: scrollOffset is read in Parent's signature each frame; Parent and every sibling of Child recompose per frame even though only Child cares.
// RIGHT
@Composable
fun Parent(scrollOffset: () -> Float) {
    Child(scrollOffset)
}

@Composable
fun Child(scrollOffset: () -> Float) {
    Box(Modifier.offset { IntOffset(0, scrollOffset().toInt()) })
}

ラムダパラメーターは安定した関数参照です。Modifier.offset { } 内で呼び出すことで、読み取りを Layout まで遅延できます。親は変化の激しい状態を再度読み取らないため、再コンポーズされることはありません。

パターン:フレームごとの Draw 処理 — drawBehind/drawWithCache
// WRONG
val color = animatedColor.value
Box(Modifier.background(color))
// WRONG because: Modifier.background reads color in Composition; per-frame color animation invalidates the subtree.
// RIGHT — Draw phase only, no caching needed for a solid color
Box(Modifier.drawBehind { drawRect(animatedColor.value) })
// RIGHT — heavier work; cache the Path/Brush across frames
Box(
    Modifier.drawWithCache {
        val brush = Brush.linearGradient(
            colors = listOf(start, end),
            start = Offset.Zero,
            end = Offset(size.width, size.height),
        )
        onDrawBehind { drawRect(brush, alpha = animatedAlpha.value) }
    }
)

drawWithCache は、ラムダがキャプチャした remember のキーが変化した場合にのみキャッシュを再構築します。onDrawBehind ブロックは、最新の状態を使って Draw のたびに再実行されます。

3 つのフェーズのリファレンス

Compose はすべてのフレームを次の順序で実行します。フェーズ N で読み取りを行うと、フェーズ N とそれ以降のすべてのフェーズが無効化されます。 読み取りはできるだけ後のフェーズに移してください。

フェーズ処理内容無効化された場合のコスト
Composition@Composable 関数を実行し、UI ツリーを構築/差分計算します最大 — スキップ可能性を判定するすべてのゲートが再実行され、子のコンポーザブルも再コンポーズされる可能性があります
Layout各ノードに対して measure() と placeRelative() を実行します中 — 影響を受けるサブツリーを再測定して再配置します
Drawキャンバスに描画コマンドを記録します最小 — 描画パスを 1 回実行します
Modifier フェーズ早見表(よくあるケース)
Modifier状態を読み取るフェーズ
Modifier.offset(Dp)Composition
Modifier.offset { IntOffset }Layout
Modifier.padding(Dp) / padding(PaddingValues)Composition(ラムダを受け取るオーバーロードはありません)
Modifier.size(Dp)Composition
Modifier.layout { measurable, constraints -> … }Layout(頻繁に変化する状態に測定を連動させる必要がある場合の回避策です。例:アニメーションするインセット — padding にラムダ形式はありません)
Modifier.alpha(Float)Composition
Modifier.rotate(Float)Composition
Modifier.scale(Float)Composition
Modifier.graphicsLayer { … }Draw
Modifier.background(Color)Composition
Modifier.drawBehind { … }Draw
Modifier.drawWithCache { … }Draw(キャプチャした状態が変化するとキャッシュが再構築されます)

無効化の伝播、後方書き込みのルール、各 Modifier の詳細な解説など、仕組みを詳しく知りたい場合は references/three-phases.md を参照してください。

必須ルール

  • 必須: ユーザー操作 1 回あたり 1 回を超える頻度で変化する状態(アニメーション値、スクロール位置、ドラッグのオフセット、ジェスチャーの差分)には、ラムダベースの Modifier を優先してください。
  • 必須: アニメーションする alpha、scale、rotate、translation には Modifier.graphicsLayer { … } を使用してください。頻繁に変化する状態に Modifier.alpha(state.value) / Modifier.rotate(state.value) / Modifier.scale(state.value) を使用しないでください。
  • 禁止: ラムダ Modifier で後から状態を読み取れる場合、コンポーザブル本体で頻繁に変化する状態を読み取らないでください。コンポーザブル本体での .value の読み取りを 1 回避けるごとに、フレームごとの Composition の無効化が 1 回減ります。
  • 禁止: 同じ Composition パスですでに読み取った MutableState に書き込まないでください。これは後方書き込みです(references/three-phases.md を参照)。ランタイムはコストを伴って再コンポーズを中断します。
  • 推奨: 値の変化が頻繁で、コンポーザブルの境界を越えて渡される場合は、単純な値よりラムダプロバイダー(() -> T)を使用してください。受け取り側ではラムダ Modifier と組み合わせてください。
  • 推奨: フレームごとの処理で、サイズやテーマに依存して再構築されるリソース(パス、ブラシ、グラデーション)を扱う場合は、Modifier.drawBehind { } より Modifier.drawWithCache { } を使用してください。

検証

  • Layout Inspector で、アニメーションの各フレームにおける親コンポーザブルの再コンポーズ回数が 0(またはそれに近い値)になっていることを確認します。増えるのは Layout/Draw のカウンターだけです。
  • @TraceRecomposition(traceStates = true)(skydoves/compose-stability-analyzer)で、release + R8 + 実機において、ラップするコンポーザブルがフレームごとに再コンポーズされず、ラムダ Modifier が適用されたサブノードのみが再実行されることを確認します。
  • 移行したファイルを grep し、頻繁に変化する状態が渡されている Modifier.alpha(/Modifier.rotate(/Modifier.scale(/Modifier.offset(<dp expr>) のパターンがないことを確認します。
  • コンポーザブル間で渡す、頻繁に変化する値は T の値ではなく、() -> T のラムダプロバイダーにします。
  • リリースビルドでも正しく描画されることを確認します。値形式からラムダ形式への変更で動作が変わらないのは、ラムダ本体が元の算術処理と一致する場合に限られます(dp と px の混同に注意してください。Modifier.offset { IntOffset(x, y) } が受け取るのはピクセル値です)。

参考資料

コメント