mcpField
U
using-strong-skipping-correctly

using-strong-skipping-correctly

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

using-strong-skipping-correctly について

Kotlin 2.0.20 以降の Compose 開発者向けに、強スキップがスキップ可能性とラムダのメモ化に与える影響を説明するスキルです。自動メモ化の対象外や明示的なオプトアウトも含め、検証と監査の手順を示します。

主な機能

  1. 01強スキップが有効かを確認
  2. 02Composable スコープ内のキャプチャ付きラムダを監査
  3. 03自動 remember の対象外となるスコープを特定
  4. 04必要に応じて @DontMemoize などのオプトアウト注釈を使用
  5. 05@TraceRecomposition で実行時の挙動を検証

活用シーン

  1. 01Kotlin 2.0.20 以降への移行後にコンパイラーレポートを確認
  2. 02親の更新後も再コンポジションする問題を調査
  3. 03リストや Modifier のコールバックでラムダ割り当てを特定
  4. 04スキップやメモ化を無効にする必要性を判断

インストール

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

$npx skills add https://github.com/skydoves/compose-performance-skills --skill using-strong-skipping-correctly

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

同梱ファイルreferences/escape-hatches.md

詳細

Strong Skipping を正しく使う — 再起動可能なコンポーザブルをすべて、意図的にスキップ可能にする

Strong Skipping Mode は、Kotlin 2.0.20 に同梱された Kotlin Compose コンパイラプラグインでデフォルトになった Compose コンパイラの動作です。一度に 2 つの変更を加えます。すべての再起動可能なコンポーザブルが、パラメーターの安定性にかかわらずスキップ可能になります(不安定なパラメーターは === で、安定したパラメーターは equals で比較されます)。また、@Composable 関数内に記述されたキャプチャを持つラムダリテラルは、すべて自動的に remember(captures) { ... } でラップされます。どちらの動作もデフォルトで有効であり、Kotlin 2.0.20 以降ではオプトイン用のフラグは不要です。キャプチャを持たないラムダは、もともとコンパイラによってシングルトンとして出力され、ラップされません。自動 remember の対象となり、実際に処理されるのは、キャプチャを持つラムダだけです。

これは「安定性はもう重要ではない」ということのように聞こえますし、多くの UI コードではほぼそのとおりです。しかし、この変更によって 3 つの注意点が残ります。安定した型でも、=== によるフォールバックで再コンポーズのたびに無効化されないよう、正しい equals のセマンティクスが必要です。@Composable 以外のスコープにあるラムダ(LazyListScope.items { }、Modifier.pointerInput { }、通常のオブジェクト式)は自動でメモ化されません。また、意図的にオプトアウトする必要があるケースがいくつかあり、その場合は @DontMemoize または @NonSkippableComposable を明示的に使用します。このスキルでは、Strong Skipping が有効な環境で作業する際の検証、監査、回避策の選択について説明します。

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

  • 開発者から「まだ @Stable は必要ですか?」や「このコンポーザブルはスキップされるようになりましたか?」と尋ねられた場合。
  • 開発者が「Strong Skipping で解決したと思っていた」にもかかわらず、コンポーザブルが親の更新のたびに再コンポーズされる場合。
  • ラムダの割り当てに関する質問が出た場合。「この onClick = { vm.add(it) } は再コンポーズのたびに割り当てられますか?」など。
  • Kotlin 1.9.x/Compose Compiler 1.5.x から Kotlin 2.0.20 以降にプロジェクトを移行し、レポートのあちこちに restartable skippable と表示されるようになったことへの対応が必要な場合。
  • 開発者がコードやコンパイラの出力で @DontMemoize または @NonSkippableComposable を見つけ、その役割について尋ねた場合。
  • ユーザーが「strong skipping」、「auto-remember」、「@DontMemoize」、「@NonSkippableComposable」、「skippable everywhere」に言及した場合。

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

  • 概念的な質問が「そもそも、型を安定とみなす条件は何ですか?」である場合は、../../stability/understanding-stability-inference/SKILL.md を使用してください。
  • 安定性がひそかに悪化しており、チームで CI ゲートが必要な場合は、../../stability/enforcing-stability-in-ci/SKILL.md を使用してください。
  • Strong Skipping が有効でも、診断を明確にし、equals に基づくスキップを機能させるうえで、型レベルの安定性は依然として重要です。根本原因となっている型を修正するには、../../stability/diagnosing-compose-stability/SKILL.md を使用してください。
  • 再コンポーズは発生しているものの、開発者が確認できない場合は、まず ../debugging-recompositions/SKILL.md を使用して計測してください。

前提条件

  • Compose コンパイラの Gradle プラグインを org.jetbrains.kotlin.plugin.compose 経由で適用した Kotlin 2.0.20 以降。Strong Skipping はデフォルトで有効です。Kotlin 2.0.20 以降で無効にするには、composeCompiler { featureFlags.add(ComposeFeatureFlag.StrongSkipping.disabled()) } を使用してください(従来の enableStrongSkippingMode = true プロパティは @Deprecated("Use the featureFlags option instead") であり、古い Kotlin でも引き続き動作します)。
  • 監査対象のモジュールで Compose Compiler レポートが有効になっていること(../../stability/diagnosing-compose-stability/SKILL.md を参照)。
  • レポートを読む際は、ビルドをリリースで実行すること。デバッグビルドでは Live Literals が追加され、スキップの動作が歪むためです(../../measurement/testing-compose-in-release-mode/SKILL.md を参照)。
  • *-composables.txt のコンポーザブルごとの行形式(restartable skippable fun Foo(...))を理解していること。

ワークフロー

1. Strong skipping が実際に有効か確認する

動作が「strong skipping によるもの」だと主張する前に、ツールチェーンが strong skipping をデフォルトで有効にするバージョンであること、または従来のフラグが設定されていることを確認してください。

./gradlew :app:dependencies --configuration kotlinCompilerPluginClasspathRelease | grep -i compose

Compose コンパイラープラグインには Kotlin 2.0.20 以降が使われているはずです。build.gradle.kts では次のようにします。

plugins {
    alias(libs.plugins.kotlin.android)        // Kotlin 2.0.20+
    alias(libs.plugins.kotlin.compose)        // org.jetbrains.kotlin.plugin.compose
}

Kotlin 2.0.20 以降で strong skipping を無効にする場合(新しい機能フラグ DSL):

import androidx.compose.compiler.plugins.kotlin.ComposeFeatureFlag

composeCompiler {
    featureFlags.add(ComposeFeatureFlag.StrongSkipping.disabled())
}

従来のプロパティ composeCompiler { enableStrongSkippingMode = true } は、最近の Compose Compiler Gradle プラグインのリリースでは @Deprecated("Use the featureFlags option instead") になっています。引き続き動作し(古い Kotlin 1.9.x / 2.0.0~2.0.10 では今も唯一の切り替え方法です)が、新しいコードでは、無効にする場合に featureFlags.add(ComposeFeatureFlag.StrongSkipping.disabled()) を使うことをおすすめします。

次に、Compose Compiler のレポートを再生成し、以前は不安定だったことが分かっているシグネチャーを grep します。

./gradlew :app:assembleRelease
grep -A1 "restartable" app/build/compose_compiler/app-release-composables.txt | head -40

strong skipping が有効な場合、List<Foo> などの不安定なパラメーターを受け取るものも含め、すべての再起動可能なコンポーザブルに restartable skippable と表示されるはずです。コンポーザブルが skippable なしで restartable と表示されている場合は、@NonSkippableComposable が付いている(意図的)か、モードが無効になっています(たとえば、誰かが featureFlags.add(ComposeFeatureFlag.StrongSkipping.disabled()) を追加した、または従来の enableStrongSkippingMode = false を設定した場合)。

2. @Composable スコープ内のラムダキャプチャ箇所を監査する

strong skipping は、@Composable 関数内に記述されたキャプチャを持つすべてのラムダリテラルを、remember(captures) { ... } でラップします。キャプチャした値のリストは、コンパイラーが生成する remember のキーです。キャプチャを持たないラムダ(たとえば、vm が安定したファイルスコープの参照である { vm.refresh() } や { /* no-op */ })は、すでにコンパイラーによってシングルトンとして出力されるため、自動的に remember されません。キーにするものがないからです。次の3つの点に注意してください。

  • キャプチャを持たないラムダは、周囲の関数が再コンポーズされても、すべての再コンポーズ間で同じインスタンスを共有します(コンパイラーが出力するシングルトン)。
  • 安定した値のみをキャプチャするラムダは、remember(captures) によって一度だけメモ化され、キャプチャが等しい限り、再コンポーズ間で再利用されます。
  • 不安定な値(たとえば、ホイストされていない List<Foo> リテラル)をキャプチャするラムダは、不安定なキャプチャが === で比較されるため、再コンポーズのたびにキャプチャし直されます。新しいリテラルは新しい参照だからです。

問題のある呼び出し箇所を調べてください。考え方は、「このラムダは何をキャプチャしているか。また、各キャプチャは equals で安定しており、再コンポーズ間でも同じ ID を保つか」です。

// RIGHT — captures stable id + stable callback; auto-remembered by strong skipping
@Composable
fun Row(snack: Snack, onClick: (Long) -> Unit) {
    Button(onClick = { onClick(snack.id) }) { Text(snack.name) }
}
// WRONG — captures a freshly allocated list literal; new reference each recompose
@Composable
fun Header(title: String) {
    Toolbar(actions = listOf(Action.Share, Action.Save)) { /* ... */ }
}
// WRONG because: `listOf(...)` allocates a new List every recompose. Even with strong
// skipping the unstable param compares with ===, fails, and Toolbar recomposes every time.
// RIGHT — hoist the unstable literal so its identity is stable
@Composable
fun Header(title: String) {
    val actions = remember { listOf(Action.Share, Action.Save) }
    Toolbar(actions = actions) { /* ... */ }
}
3. strong skipping でメモ化されない箇所を特定する

自動 remember が適用されるのは、@Composable 関数のコンテキスト内にあるラムダだけです。以下は**@Composable ではない**スコープであり、その内部のラムダは自動的に remember されません。割り当てが問題になる場合は、手動で remember(key) を使う必要があります。

  • LazyListScope.items { ... }、LazyGridScope.items { ... }、LazyVerticalGrid.items { ... } など — items ブロックは通常の Kotlin DSL ビルダーです。
  • Modifier.pointerInput { ... }、Modifier.draggable { ... }、Modifier.scrollable { ... } — @Composable ではないラムダです。
  • オブジェクト式と SAM 変換(たとえば、object : DefaultLifecycleObserver { })。
  • Modifier.drawBehind { ... }、Modifier.drawWithCache { ... } — ラムダのパラメーターは @Composable ではありません。
  • @Composable ではないスコープ内のコルーチンビルダー。

このような箇所では、変化する可能性がある値をキーにして、安定したラムダを自分でホイストしてください。

// WRONG — onClick allocated per item per recompose; strong skipping does not reach here
LazyColumn {
    items(snacks, key = { it.id }) { snack ->
        SnackRow(snack, onClick = { vm.select(snack.id) })
    }
}
// WRONG because: `items { snack -> ... }` is LazyListScope, not a @Composable function.
// Strong skipping does not auto-remember the onClick lambda — a new instance allocates per row,
// per recompose, and SnackRow's `===` comparison on the lambda param fails every time.
// RIGHT — hoist a stable handler keyed on the stable callback
LazyColumn {
    items(snacks, key = { it.id }) { snack ->
        val onClick = remember(snack.id) { { vm.select(snack.id) } }
        SnackRow(snack, onClick = onClick)
    }
}
// RIGHT — even better: pass the id-aware callback once
LazyColumn {
    items(snacks, key = { it.id }) { snack ->
        SnackRow(snack, onClick = vm::select)   // method reference is stable
    }
}
4. 例外的な動作を選択する

強力なスキップは、明確な方針に基づいています。一方で、反対の動作が正当に必要となるケースもいくつかあります。完全な参照表は references/escape-hatches.md にあります。概要を示す判断ツリーは次のとおりです。

  • @DontMemoize をラムダの式の位置に指定します(このアノテーションの対象は EXPRESSION のみで、TYPE は対象外です)。ラムダを自動的に remember させたくない場合に使います。呼び出しごとに新しい値を意図的にキャプチャするラムダに使います(例:最初のコンポジション時のカウンターではなく、最新のカウンターを記録するロガー)。呼び出し側で指定します。Button(onClick = @DontMemoize { ... }) のようにし、(@DontMemoize () -> Unit) のようなパラメーターの型の位置には指定しません。
  • @NonSkippableComposable を関数に指定すると、その関数はスキップの対象外になります。パラメーターの等価性にかかわらず、再コンポジションのたびに実行する必要がある、副作用のみを持つコンポーザブル(ロガーやデバッグオーバーレイなど)に使います。
  • @NonRestartableComposable を関数に指定すると、再起動スコープが完全に削除されます。再起動に伴う管理コストが関数本体のコストを上回る、ごく小さなコンポーザブルにのみ使います。アプリのコードで必要になることはほとんどありません。Compose UI では内部的に使われています。
  • @ReadOnlyComposable を関数に指定すると、その関数はコンポジションの値を読み取るだけでノードを生成しないことを宣言します。これにより、初期化コンテキストで実行できます(例:別のコンポーザブルに渡す値の計算)。Compose の MaterialTheme.colorScheme ゲッターでは、このアノテーションが使われています。
// RIGHT — logger composable that must always emit
@NonSkippableComposable
@Composable
fun RecompositionLogger(label: String) {
    SideEffect { Log.d("Recomp", "tick: $label @ ${System.currentTimeMillis()}") }
}
// RIGHT — annotate the lambda expression that intentionally captures fresh per-call state.
// @DontMemoize is @Target(EXPRESSION) — apply it on the lambda literal, NOT on the type.
@Composable
fun TraceClicks(counter: Int, content: @Composable (() -> Unit) -> Unit) {
    content(@DontMemoize { Log.d("Click", "counter at click time = $counter") })
}
5. @TraceRecomposition で動作を確認する

レポートを読むと、関数の境界までの状況がわかります。特定のコンポーザブルが実行時にスキップされているかどうかを確認するには、compose-stability-analyzer の @TraceRecomposition を使って計測します。セットアップの詳細については、../../measurement/tracing-recompositions-at-runtime/SKILL.md を参照してください。要点は次のとおりです。

@TraceRecomposition(traceStates = true)
@Composable
fun SnackRow(snack: Snack, onClick: (Long) -> Unit) { /* ... */ }

Logcat には、変更されたパラメーターとともに再コンポジションごとに 1 行が出力されます。これにより、「強力なスキップによってこの呼び出しがスキップされたかどうか」が直接わかります。

パターン

パターン:強力なスキップを使っていても安定性は重要

強力なスキップを使っても、@Immutable / @Stable は不要にはなりません。不安定なパラメーターの比較方法が equals から === に変わります。=== は参照の等価性を判定します。ホットパスで安定した型が必要な場合は、@Immutable を付けて equals が使われるようにすると、構造的に等しいインスタンスがスキップされます。

// WRONG
data class Filter(val tags: List<String>, val sort: SortOrder)

@Composable
fun FilterBar(filter: Filter) { /* ... */ }
// WRONG because: Filter is unstable (List<String> is unstable). Strong skipping makes
// FilterBar skippable, but only when `prevFilter === newFilter`. A copy() with the same
// content fails identity, recomposes the bar, every time.
// RIGHT
@Immutable
data class Filter(val tags: ImmutableList<String>, val sort: SortOrder)

@Composable
fun FilterBar(filter: Filter) { /* ... */ }
// RIGHT because: @Immutable + ImmutableList makes Filter stable. Strong skipping uses
// equals(), and structurally-equal copies skip.
パターン:ホットなコンポーザブルのパラメーターにリテラルなコレクションを指定する
// WRONG
@Composable
fun Toolbar(title: String) {
    ActionRow(actions = listOf(Action.Share, Action.Save))
}
// WRONG because: a fresh List allocates per recompose. Strong skipping makes ActionRow
// skippable, but the List param fails === every time and the body re-runs.
// RIGHT
private val ToolbarActions = persistentListOf(Action.Share, Action.Save)

@Composable
fun Toolbar(title: String) {
    ActionRow(actions = ToolbarActions)
}
パターン:LazyListScope.items 内のラムダ

ワークフローのステップ 3 を参照してください。要点は次のとおりです。items { } は @Composable ではないため、強力なスキップはその内部には適用されません。安定したコールバックを外に出すか、メソッド参照を使ってください。

パターン:意図的に新しいラムダを使う場合の @DontMemoize
// WRONG — auto-remembered lambda captures `counter` from the FIRST composition
@Composable
fun LoggingButton(counter: Int) {
    Button(onClick = { Log.d("Btn", "counter = $counter") }) { Text("hit") }
}
// WRONG because: under strong skipping the lambda is auto-remembered, but `counter` is a
// stable Int capture, so the remember key is the int value. This is actually fine here.
// Where it bites is when the developer EXPECTS each invocation to capture fresh state but
// the lambda is being passed across a memoization boundary that hides the capture.
// RIGHT — explicit opt-out where freshness is the contract.
// @DontMemoize is @Target(EXPRESSION) — apply on the lambda literal, never on the type.
@Composable
fun TraceContent(latest: Int, body: @Composable (() -> Unit) -> Unit) {
    body(@DontMemoize { sendTelemetry("snapshot=$latest") })
}

アプリのコードで @DontMemoize が必要になることはほとんどありません。まず計測し、その後に必要な場合だけ使ってください。デフォルトの動作は、ほぼすべての UI で適切です。

パターン:副作用のみを持つコンポーザブルに @NonSkippableComposable を指定する
// RIGHT
@NonSkippableComposable
@Composable
fun FrameTicker(onTick: () -> Unit) {
    SideEffect { onTick() }
}

このアノテーションは、「このコンポーザブルには再コンポジションごとに実行すべき処理があるため、スキップガードで処理を早期終了させないでください」という意味です。使用は控えめにしてください。「毎回実行する必要がある」コンポーザブルの多くは、実際には状態の読み取り位置が適切ではありません。まず読み取り位置を修正してください。

パターン:CI と組み合わせて強力なスキップのリグレッションを防ぐ

強力なスキップを使うと、レポートに表示されるほぼすべてのコンポーザブルが skippable になるため、レポートを目視してリグレッションを見つけにくくなります。../../stability/enforcing-stability-in-ci/SKILL.md と組み合わせることで、コンポーザブルが skippable から not skippable に変わった場合に、ベースラインの差分で検出できます。

必須ルール

  • 必須:強いスキップが有効だと断言する前に、Kotlin/Compose コンパイラプラグインのバージョンを確認してください。./gradlew :app:dependencies を実行し、Kotlin 2.0.20 以降(デフォルトで有効)であることを確認します。古い Kotlin では、composeCompiler { } 内に従来の enableStrongSkippingMode = true フラグがあることを確認します。プロジェクトで featureFlags.add(ComposeFeatureFlag.StrongSkipping.disabled()) によって Strong Skipping が明示的に無効化されている場合、このスキルの残りの内容は適用対象外としてください。
  • 必須ではありません:実際の *-composables.txt レポートを確認せずに、コンポーザブルについて「強いスキップのおかげでスキップ可能になった」と宣言しないでください。@NonSkippableComposable のようなアノテーションや、LazyListScope.items { } 内のラムダは依然として落とし穴であり、レポートで確認できます。
  • 必須:ホットパスに渡される不安定な型について、参照同一性に頼るのではなく、@Immutable/@Stable を優先してください。値の生成元(copy()、map { }、ビルダー DSL)が同一性を保持しない場合、=== は構造的な equals の代用として適切ではありません。
  • 必須ではありません:理由を説明するコードコメントを同じ場所に1行添えずに、@DontMemoize や @NonSkippableComposable を使用しないでください。これらは高度なオプトアウトであり、理由が併記されていなければ、後から読む人は誤りだと思うでしょう。
  • 必須ではありません:強いスキップを、不安定なリテラルをホイストする代わりとして扱わないでください。コンポーザブルの引数内で割り当てられた listOf(...) は、強いスキップの有無にかかわらず === に失敗します。remember { ... } またはトップレベルの persistentListOf(...) にホイストしてください。
  • 推奨:このスキルを ../../stability/enforcing-stability-in-ci/SKILL.md と組み合わせてください。これにより、コンポーザブルが skippable から not skippable に戻った場合(例:正当な理由なく誰かが @NonSkippableComposable を追加した場合)に、CI が失敗するようにできます。
  • 推奨:開発者から「自分のラムダは再コンポーズごとに割り当てられますか?」と聞かれた場合、はい/いいえで答える前に、その呼び出し箇所が @Composable 関数内にあることを確認してください。答えは @Composable の境界でまったく変わります。

スキップ可能性は診断指標であり、KPI ではありません。強いスキップが有効な場合、レポート上ではすべてのコンポーザブルが「スキップ可能」ですが、実行時にスキップされるのは、実際にパラメーターが等しいコンポーザブルだけです。もはや重要なのは「スキップ可能か」ではなく、「開発者が気にしている再コンポーズをまたいで、パラメーターが等しいと判定されるか」です。

検証

  • Kotlin/Compose コンパイラのバージョンが 2.0.20 以降(デフォルトで有効)であり、featureFlags.add(ComposeFeatureFlag.StrongSkipping.disabled()) によるオプトアウトが有効になっていないことを確認済みです。または、古い Kotlin の場合、従来の enableStrongSkippingMode = true フラグが設定されていることを確認済みです。
  • *-composables.txt に、以前は不安定なパラメーターを持っていたコンポーザブルが restartable skippable として表示されています。
  • 問題の呼び出し箇所で、不安定なリテラルのキャプチャ(listOf、mapOf、@Composable ではないスコープ内の即席のラムダ)を調査済みです。
  • 変更したコード内にある @DontMemoize/@NonSkippableComposable/@NonRestartableComposable/@ReadOnlyComposable には、それぞれ同じ場所に理由を説明するコメントがあります。
  • @TraceRecomposition(../../measurement/tracing-recompositions-at-runtime/SKILL.md を参照)を使って、入力が等しい場合にコンポーザブルがスキップされることを実行時に確認済みです。
  • 安定性のベースライン(../../stability/enforcing-stability-in-ci/SKILL.md を参照)が変更したモジュールを対象としており、今後スキップ動作が回帰した場合に CI が失敗するようになっています。

参考資料

コンポーザブルがそもそも不安定な理由を診断するには、../../stability/diagnosing-compose-stability/SKILL.md を参照してください。特定のコンポーザブルが実行時に実際にスキップされるかを計測するには、../debugging-recompositions/SKILL.md と ../../measurement/tracing-recompositions-at-runtime/SKILL.md を参照してください。CI で strong-skipping のリグレッションを防ぐには、../../stability/enforcing-stability-in-ci/SKILL.md を参照してください。

コメント