compose-state-deferred-reads について
変化の速い状態の読み取りをコンポジションからレイアウトまたは描画へ移す Compose ガイドです。スクロール、アニメーション、ジェスチャー中の不要な再コンポーズを抑え、Compose の各フェーズ間の書き戻しを防ぐ方法を説明します。
主な機能
- 01状態読み取りをレイアウトまたは描画へ移動
- 02プロバイダーラムダをComposable間で受け渡し
- 03フェーズ間の状態書き戻しを回避
- 04測定フェーズで値を適用
- 05状態をコンポジションで読む条件の判断
活用シーン
- 01スクロール中の再コンポーズ削減
- 02アニメーションのオフセットや変換の処理
- 03Lazy 行間での測定サイズ共有
- 04フェーズをまたぐ反復的な無効化の診断
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ chrisbanes/ skills --skill compose-state-deferred-readsインストール後、Claude Code で/compose-state-deferred-readsを入力して読み込まれたか確認します。
詳細
Compose の状態読み取りを遅延する
基本原則
状態を読み取ると、その読み取りを行ったフェーズが無効化されます。コンポーザブルの本体で State<T> を読み取ると、変更によってコンポジションが無効化されます。レイアウトや描画で読み取る場合、変更によって無効化されるのはレイアウトまたは描画だけです。スクロールオフセット、アニメーション、ドラッグ位置など、フレームレートで変化する状態は通常、コンポジションではなくレイアウトや描画で扱います。
逆方向書き込みは、無効化を引き起こすフェーズから、それより前のフェーズで読み取られる状態を書き込む、対称的な失敗パターンです。Compose のフェーズは、コンポジション → レイアウト → 描画の順に実行されます。レイアウトや描画からスナップショットベースの状態に書き込むと、コンポジションで読み取られる状態の書き込みによってコンポジションが無効化されます。コンポジション中に、同じコンポジション内のより前の箇所で読み取られた状態に書き込む場合も同様です。どちらも追加の処理を発生させ、多くの場合、兄弟の遅延アイテムにまで連鎖します。
解決策は構造的なものです。State<T> またはプロバイダーラムダを保持し、レイアウト/描画のコールバック内で値を読み取ります。測定値はコールバックで取得し、兄弟コンポーザブルの本体で測定状態を読み取るのではなく、測定フェーズで適用します。
このスキルを使う場面
val x by animate*AsState(...)をModifier.offset(x = ...)、Modifier.size(...)、Modifier.graphicsLayer(...)、または値を受け取る別の修飾子に渡している。LazyListState.firstVisibleItemScrollOffset、ScrollState.value、Animatable.value、またはジェスチャー状態をコンポーザブルの本体で読み取っている。- コンポーザブルが
scrollOffset: Int、progress: Float、dragOffset: Offsetなど、フレームレートで変化する値を受け取っている。 - データが安定しているにもかかわらず、スクロール、アニメーション、ジェスチャー中に再コンポジションのカウンターが増加する。
- コンポーザブルの本体で、再コンポジションのたびに
stateMap[key] = …、list.addAll(…)などを呼び出している(コンポジション → コンポジションの逆方向書き込み)。 - ある遅延アイテムが
onSizeChanged/onGloballyPositionedでサイズを取得し、兄弟がその高さをコンポジション中に読み取っている(Modifier.height(state.dp))— レイアウト → コンポジションの逆方向書き込み。
0. 逆方向書き込み
逆方向書き込み=あるフェーズで監視可能な状態に書き込み、それより前の(または現在の)フェーズの無効化を引き起こすことです。Compose はコンポジション → レイアウト → 描画の順に実行されるため、次のようになります。
- コンポジション中に、同じコンポジション内で読み取られるスナップショット状態に書き込む。
- レイアウト中(例:
Modifier.layout、onSizeChanged、onGloballyPositioned内)に、コンポジション中に読み取られるスナップショット状態に書き込む。 - 描画中に、コンポジションまたはレイアウト中に読み取られるスナップショット状態に書き込む。
どの場合も、書き込みによって追加の無効化パスが発生し、多くの場合、兄弟の遅延アイテムにまで連鎖します。
各パスでコンポーザブルの本体から mutableStateOf、mutableStateListOf、mutableStateMapOf、またはその他のスナップショットベースの状態に書き込まないでください。
// ❌ BAD — mutates observable map during composition; siblings recompose repeatedly
@Composable
fun MergeOverlay(parent: Map<Key, ViewState>, overlay: Map<Key, ViewState>): Map<Key, ViewState> {
val merged = remember { mutableStateMapOf<Key, ViewState>() }
merged.clear()
merged.putAll(parent)
merged.putAll(overlay) // back-writing composition → composition
return merged
}
// ✅ GOOD — read-only merge; no composition-time writes
@Composable
fun MergeOverlay(parent: Map<Key, ViewState>, overlay: Map<Key, ViewState>): Map<Key, ViewState> =
remember(parent, overlay) {
if (overlay.isEmpty()) parent else parent + overlay
}
読み取り専用の派生スナップショットには remember(keys) { … } を使ってください。mutableState* への書き込みはイベントコールバック(onClick)またはエフェクトに限定し、コンポジションのたびに派生データを再構築する用途には使わないでください。
onSizeChanged のようなコールバックはレイアウト中に書き込みます。生成された状態をそれより前のフェーズで読み取らない場合に限り安全です。詳しくは、以下の行をまたぐ測定を参照してください。
行をまたぐ測定(レイアウト → コンポジションの逆方向書き込み)
行 A の測定結果に合わせて行 B の高さを揃える場合、A で取得したサイズを B のコンポーザブル本体で読み取らないでください。onSizeChanged はレイアウト中に書き込みます。B がその値をコンポジション中に読み取ると、レイアウトからコンポジションへの逆方向書き込みが発生します。
var anchorHeightPx by remember { mutableIntStateOf(0) }
// ❌ BAD — B reads measurement state in composition; insertion/focus can double-recompose B
RowA(Modifier.onSizeChanged { anchorHeightPx = it.height })
RowB(Modifier.height(with(LocalDensity.current) { anchorHeightPx.toDp() })) // composition read
// ✅ GOOD — capture on A; apply on B in measure phase only
RowA(Modifier.onSizeChanged { if (it.height != anchorHeightPx) anchorHeightPx = it.height })
RowB(
Modifier.decorateMeasureConstraints { incoming ->
if (anchorHeightPx > 0) incoming.copy(minHeight = anchorHeightPx, maxHeight = anchorHeightPx)
else incoming
},
)
decorateMeasureConstraints は小さなレイアウトヘルパーです(compose-modifier-and-layout-style を参照)。高さが不明な間は、兄弟要素はコンポジション中に固定のフォールバック値を使います。高さが判明した後に無効化されるのはレイアウトだけで、コンポジションが追加で連鎖することはありません。
1. ブロック形式の修飾子を優先する
いくつかの修飾子には、値形式とブロック形式があります。値形式はコンポジションですでに読み取られた値を受け取り、ブロック形式ではレイアウト時または描画時に読み取ることができます。
// Before: animated value read in composition by the `by` delegate
@Composable
fun SelectionPill(selectedIndex: Int) {
val offsetX by animateDpAsState(120.dp * selectedIndex)
Box(Modifier.offset(x = offsetX))
}
// After: State is kept, value is read in the layout-phase offset block
@Composable
fun SelectionPill(selectedIndex: Int) {
val offsetX = animateDpAsState(120.dp * selectedIndex)
Box(
Modifier.offset {
IntOffset(offsetX.value.roundToPx(), 0)
},
)
}
よく使われる置き換え例:
| コンポジションでの読み取り | 遅延読み取り |
|---|---|
Modifier.offset(x = animatedX) | Modifier.offset { IntOffset(animatedX.value.roundToPx(), 0) } |
Modifier.graphicsLayer(translationY = y) | Modifier.graphicsLayer { translationY = yProvider() } |
val radius by animateFloatAsState(...); drawBehind { drawCircle(radius = radius) } | val radius = animateFloatAsState(...); drawBehind { drawCircle(radius = radius.value) } |
drawBehind ブロックはすでに描画フェーズで実行されます。重要なのは、State.value の読み取りもそのブロック内で行われることです。
2. コンポーザブルの境界を越えてプロバイダーを渡す
頻繁に変化する値がコンポーザブルの境界を越える場合は、スナップショット値ではなくプロバイダーのラムダを渡します。
// Before: HomeScreen reads scroll offset in composition and passes the value down
@Composable
fun HomeScreen() {
val listState = rememberLazyListState()
LazyColumn(state = listState) {
item { HeroImage(scrollOffset = listState.firstVisibleItemScrollOffset) }
}
}
@Composable
fun HeroImage(scrollOffset: Int, modifier: Modifier = Modifier) {
AsyncImage(
model = "...",
modifier = modifier.graphicsLayer(translationY = -scrollOffset / 2f),
)
}
// After: the only read happens inside graphicsLayer
@Composable
fun HomeScreen() {
val listState = rememberLazyListState()
LazyColumn(state = listState) {
item {
HeroImage(
scrollOffsetProvider = {
if (listState.firstVisibleItemIndex == 0) {
listState.firstVisibleItemScrollOffset
} else {
0
}
},
)
}
}
}
@Composable
fun HeroImage(scrollOffsetProvider: () -> Int, modifier: Modifier = Modifier) {
AsyncImage(
model = "...",
modifier = modifier.graphicsLayer {
translationY = -scrollOffsetProvider() / 2f
},
)
}
遅延読み取りの契約を明確にできる場合は、プロバイダーのパラメーター名に Provider を付けます。
3. その他のレイアウト/描画での読み取り箇所
次の場所でも、状態の読み取りを遅延できます。
Modifier.layout { measurable, constraints -> ... }- カスタムの
Alignment.align(...) drawWithContent、drawBehind、その他の描画修飾子graphicsLayer { ... }やoffset { ... }などのブロック形式のレイヤー/レイアウト修飾子
状態によって何かを配置する場所や描画する内容が変わる場合は、これらを使います。状態によってどのコンポーザブルを存在させるかが決まる場合は、コンポジションで読み取ります。
クイックリファレンス
| 症状 | 原因 | 修正 |
|---|---|---|
val x by animateFloatAsState(...) の後に Modifier.offset(...) を使用している | by によってコンポジションで読み取られます | State<Float> を保持し、offset {} 内で .value を読み取ります |
Modifier.graphicsLayer(translationY = animatedY) | プロパティ引数形式ではコンポジションの値を使います | graphicsLayer { translationY = ... } を使います |
Child(scrollOffset = listState.firstVisibleItemScrollOffset) | 頻繁に変化する値が境界を越えています | Child(scrollOffsetProvider = { ... }) にします |
| 描画ブロック内でも毎フレーム再コンポーズされる | 描画ブロックの外で値が読み取られています | State.value の読み取りを描画ブロック内に移します |
| 状態によって異なる UI 分岐を選んでいる | コンポジションでの判断です | 読み取りをコンポジション内に維持します |
コンポーザブル本体で mergedMap.putAll(overlay) を実行している | コンポジションからコンポジションへの書き戻しです | remember(parent, overlay) { parent + overlay } にします |
兄弟要素の Modifier.height(measuredPx.toDp()) | レイアウトからコンポジションへの書き戻しです | 計測フェーズの制約デコレーションを使います |
| 読み取り専用マージ用の ID キャッシュ | オーバーレイが古くなるおそれがあります | 不変の結果に対して remember(keys) を使います |
適用しない場合
- 状態によって出力するコンポーザブルが決まる場合。
- アニメーションが単発で低コストであり、明快さを優先する場合。
- 値を直接アサートするほうが簡単なテストを書く場合。
- 実行時の証拠から、再コンポーズがボトルネックではないと分かっている場合。
関連項目
compose-state-authoring—mutableState*をコンポジションとコールバックのどちらに置くかについて。compose-state-hoisting— プロバイダー/ラムダを境界越しに渡す場合に、状態ホルダーとプレーン UI の分離をどこに適用するかについて。compose-stability-diagnostics— パラメーターの安定性とコンパイラーのレポートについて。compose-modifier-and-layout-style— 計測フェーズの制約デコレーション用ヘルパーについて。