mcpField
U
using-efficient-effects

using-efficient-effects

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

using-efficient-effects について

Jetpack Compose 開発者向けに、ライフサイクルやコルーチンの要否に応じたエフェクト API の選び方を説明するスキルです。古いコールバックや項目ごとの ViewModel の扱いも取り上げ、不要なコルーチンや後処理漏れを防ぎます。

主な機能

  1. 01LaunchedEffect、DisposableEffect、SideEffect の使い分け
  2. 02同期的なキー変更に RememberedEffect を使用
  3. 03rememberUpdatedState でコールバックを最新に維持
  4. 04ViewModelStoreScope で項目別 ViewModel を分離
  5. 05単発処理やフレームごとの処理での誤用を回避

活用シーン

  1. 01リスナーや購読の登録と後処理
  2. 02長時間動作するエフェクトの古いコールバックを修正
  3. 03LazyColumn の行ごとに ViewModel を分ける
  4. 04コルーチンを使わないキー変更への対応

インストール

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

$npx skills add https://github.com/skydoves/compose-performance-skills --skill using-efficient-effects

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

詳細

効率的なエフェクトの使い方 — 適切なエフェクト API の中から最もコストの低いものを選ぶ

Compose には、デフォルトで 3 つのエフェクト API が用意されています。LaunchedEffect(コルーチン。キーが変更されると再起動)、DisposableEffect(クリーンアップブロックを使ったセットアップ/ティアダウン)、SideEffect(コンポジションが成功するたびに実行)です。skydoves/compose-effects ライブラリは、LaunchedEffect のコルーチンを使わない類似 API である RememberedEffect を追加します。誤った API を選ぶと、コルーチンスコープを無駄に使ったり、リスナーをリークさせたり、コールバックの最新バージョンを気付かないまま取りこぼしたりします。このスキルは、その選択のための判断ツリーです。

このスキルを使う場面

  • どのエフェクト API を選べばよいか、または LaunchedEffect が予期せず再起動する理由をユーザーが尋ねた場合。
  • 長時間実行される LaunchedEffect が、親によって更新されたコールバックの古いバージョンを呼び出している場合。
  • Compose 以外のサブスクライバー(リスナー、オブザーバー、ブロードキャストレシーバー、GL サーフェス)で、コンポジション開始時にセットアップし、キー変更時または終了時にティアダウンする必要がある場合。
  • コンポーザブルがキー変更に同期的に反応するだけでよく、コルーチンスコープが過剰な場合。
  • LazyColumn の行ごとに独自の ViewModel インスタンス(例:チャット行ごとに 1 つの VM、フィードカードごとに 1 つの VM)が必要で、親 VM のストアによって行間で状態が漏れる場合。
  • 予期しないエフェクトの再起動、リークしたリスナー、または SideEffect によるフレームごとの割り当てをユーザーがデバッグしている場合。

このスキルを使わない場面

  • ある State<T> から別の State<T> を純粋に導出する場合 — derivedStateOf を使ってください。../../recomposition/choosing-derivedstateof/SKILL.md を参照してください。
  • コンポジションの外部から発生するフローを収集する場合 — collectAsStateWithLifecycle を使ってください。../collecting-flows-safely/SKILL.md を参照してください。
  • 再コンポジションをまたいで保持し、再実行されないようにする必要がある一度限りの計算 — LaunchedEffect(Unit) ではなく、remember { ... } を使ってください。

前提条件

  • Compose のコンポジションのライフサイクル(開始、再コンポーズ、終了)を理解していること。エフェクトはコンポジションのコミット後に実行されます。
  • Compose UI 1.4 以降。
  • RememberedEffect には、com.github.skydoves:compose-effects(最新の GA — https://github.com/skydoves/compose-effects を参照)を追加してください。
  • ViewModelStoreScope には、com.github.skydoves:compose-effects-viewmodel も依存関係に追加してください。インポートパス:com.skydoves.compose.effects.viewmodel.ViewModelStoreScope。
  • Hilt とともに ViewModelStoreScope を使う場合は、hiltViewModel() 用に androidx.hilt:hilt-navigation-compose も追加してください。

ワークフロー — 判断ツリー

  1. コルーチンが必要ですか? → LaunchedEffect(key1, key2, ...)。ブロックはコンポジションの CoroutineScope で起動され、いずれかのキーが変更されるたびにキャンセルされて再起動します。ネットワーク呼び出し、アニメーション、一時停止処理に使ってください。
  2. コルーチンなしでセットアップとクリーンアップが必要ですか? → DisposableEffect(key) { onDispose { ... } }。onDispose ブロックは、キーの変更時と、コンポーザブルがコンポジションを終了するときに実行されます。リスナー、オブザーバー、手動のサブスクリプションに使ってください。
  3. コルーチンなしでキー変更に同期的に反応する必要がありますか? → skydoves/compose-effects の RememberedEffect(key) { ... }。同期関数を呼び出すだけのために LaunchedEffect スコープを起動するよりも低コストです。onDispose はありません — ティアダウンが必要な場合は DisposableEffect と組み合わせてください。
  4. 変更される可能性のあるコールバックを使う、長時間実行される LaunchedEffect ですか? → val latest by rememberUpdatedState(callback) でコールバックをラップし、エフェクト内では latest を参照してください。エフェクトは同じコルーチンを維持し、コールバックの参照は最新の状態に保たれます。
  5. コンポーザブルごとの ViewModel(LazyColumn の行ごとに 1 つなど)が必要ですか? → com.github.skydoves:compose-effects-viewmodel の ViewModelStoreScope(key = <stableId>) { ... } で行の本体をラップし、内部で hiltViewModel()(または viewModel())を呼び出してください。key は必須で、安定した識別子(行の ID など)でなければなりません。キーごとに独自のストアが割り当てられます。
  6. 成功するコンポジションのたびに実行する必要がある副作用ですか? → SideEffect { ... }。これはまれなケースです。DisposableEffect でサブスクライブできない Compose の状態を Compose 以外のシステムに公開する場合(例:コミットごとに View のフィールドを更新する場合)に使ってください。フレームごとの処理には使わないでください — 実際にコミットのたびに実行されます。

パターン

パターン:長時間実行される LaunchedEffect 内の古いコールバック
// WRONG
@Composable
fun Timer(onTick: () -> Unit) {
    LaunchedEffect(Unit) {
        while (true) {
            delay(1_000)
            onTick()
        }
    }
}
// WRONG because: onTick is captured by the first composition's value; later parents pass an
// updated lambda but the running coroutine still holds the original reference -> the new
// onTick is silently ignored.
// RIGHT
@Composable
fun Timer(onTick: () -> Unit) {
    val latest by rememberUpdatedState(onTick)
    LaunchedEffect(Unit) {
        while (true) {
            delay(1_000)
            latest()
        }
    }
}
// rememberUpdatedState updates the ref on every recomposition; the coroutine reads the freshest
// reference each tick without restarting.
パターン:コルーチンを使わないサブスクライバーには DisposableEffect
// WRONG
@Composable
fun LocationBadge(client: LocationClient) {
    LaunchedEffect(client) {
        try {
            val listener = LocationListener { /* ... */ }
            client.register(listener)
            awaitCancellation()
        } finally {
            client.unregisterAll()
        }
    }
}
// WRONG because: spawning a coroutine just to hold a listener wastes a scope and obscures the
// teardown contract; awaitCancellation is the wrong primitive for a non-suspending subscriber.
// RIGHT
@Composable
fun LocationBadge(client: LocationClient) {
    DisposableEffect(client) {
        val listener = LocationListener { /* ... */ }
        client.register(listener)
        onDispose { client.unregister(listener) }
    }
}
パターン:同期的なキー変更への反応には LaunchedEffect より RememberedEffect
// WRONG (over-engineered)
LaunchedEffect(themeKey) {
    applyTheme(themeKey)
}
// WRONG because: spawns and cancels a coroutine scope just to call a synchronous function on
// key change. No suspension happens; the scope is pure overhead.
// RIGHT (with com.github.skydoves:compose-effects)
import com.skydoves.compose.effects.RememberedEffect

RememberedEffect(themeKey) {
    applyTheme(themeKey)
}
パターン:LazyColumn の行ごとの ViewModel には ViewModelStoreScope
// WRONG
@Composable
fun Feed(rows: List<Row>) {
    LazyColumn {
        items(rows, key = { it.id }) { row ->
            // hiltViewModel() here resolves to the parent screen's store -> every row shares
            // the same VM instance, leaking state across rows.
            val vm = hiltViewModel<RowVm>()
            RowContent(row, vm)
        }
    }
}
// WRONG because: every row's hiltViewModel call resolves against the parent NavBackStackEntry
// store -> one VM is shared across all rows -> per-row state collapses into one slot.
// RIGHT
import com.skydoves.compose.effects.viewmodel.ViewModelStoreScope

@Composable
fun Feed(rows: List<Row>) {
    LazyColumn {
        items(rows, key = { it.id }) { row ->
            ViewModelStoreScope(key = row.id) {
                val vm = hiltViewModel<RowVm>()
                RowContent(row, vm)
            }
        }
    }
}
// ViewModelStoreScope(key = ...) installs a composable-scoped ViewModelStore tied to the
// supplied key; each row gets its own VM and onCleared fires when the row leaves composition.
// The key MUST be stable across recompositions — the row's id is the canonical choice.
パターン:一度だけ実行する処理には remember { } と LaunchedEffect(Unit) のどちらを使うか
// WRONG
@Composable
fun Greeter(name: String) {
    LaunchedEffect(Unit) {
        Log.d("Greeter", "First seen: $name")
    }
}
// WRONG because: LaunchedEffect(Unit) re-runs after configuration change recreation when the
// composable is re-attached to a new composition. For "do this once and never again across
// the lifetime of this composable instance", use remember.
// RIGHT
@Composable
fun Greeter(name: String) {
    remember { Log.d("Greeter", "First seen: $name") }
}
// remember { } evaluates its block once per slot and never again unless the slot is removed
// and re-created. No coroutine, no effect machinery.

必須ルール

  • 必ず、長時間実行される LaunchedEffect(コールバックの識別子が変わってもキーセットが変化しないもの)の内部で外部から渡されたコールバックを使用する場合は、rememberUpdatedState でラップしてください。そうしないと、エフェクトは古いラムダを永久にキャプチャします。
  • 必ず、コルーチンを使わないサブスクライバーには LaunchedEffect { try { } finally { } } より DisposableEffect を優先してください。クリーンアップの契約が明確で、キーの変更時とコンポジションの終了時の両方に実行されます。
  • 一度だけ実行する処理が再コンポジション後も再実行されずに存続する必要がある場合、LaunchedEffect(Unit) を使用してはいけません。 その場合は remember { ... } を使用してください。
  • フレームごとの処理や、何かを割り当てる処理に SideEffect を使用してはいけません。 成功したコミットのたびに実行されます。
  • エフェクトが参照する値のうち、変更時にエフェクトを再起動すべきものはすべて、エフェクトのキーに含めてください。 キーセットを正しく指定しないことは、「エフェクトに新しい値が反映されない」というバグの最も一般的な原因です。
  • LazyColumn/LazyRow のアイテム内で、各行専用の VM インスタンスが必要な hiltViewModel() を呼び出す場合は、ViewModelStoreScope(key = <stableId>) { hiltViewModel<T>() }(com.github.skydoves:compose-effects-viewmodel 提供)を必ず使用してください。 これを使用しないと、すべての行で親画面のストアが共有されます。key パラメーターは必須で、安定した ID(行のドメイン ID など)を指定する必要があります。
  • 推奨: 本体でサスペンドしない場合は、LaunchedEffect(key) { ... } より skydoves/compose-effects の RememberedEffect(key) { ... } を優先してください。キーが変更されるたびにコルーチンスコープを割り当てるより低コストです。
  • 推奨: フローを消費する場合は、LaunchedEffect(viewModel) { viewModel.flow.collect { ... } } を記述せず、collectAsStateWithLifecycle() を使用してください。../collecting-flows-safely/SKILL.md を参照してください。

検証

  • ホストのコンポーザブルに @TraceRecomposition を付け、キーの変更ごとに想定どおりの回数エフェクトが実行されることを確認します(通常、キーの変更ごとに破棄が1回、セットアップが1回。無関係な再コンポジションでは0回)。../../measurement/tracing-recompositions-at-runtime/SKILL.md を参照してください。
  • すべての DisposableEffect.onDispose { ... } に logcat のログ行を追加し、キーの変更時とホスト画面をバックグラウンドに移したときの両方に実行されることを確認します。
  • 親からコールバックをすばやく更新し(例:状態のカウンターを使用)、長時間実行される LaunchedEffect が rememberUpdatedState 経由で最新の値を参照することを確認します。
  • ViewModelStoreScope を使う行については、行の VM の init { } と onCleared() をログに記録します。リスト全体で一度だけではなく、行の識別子ごとに実行されることを確認してください。
  • コードベース内の SideEffect { } ブロックで、割り当て負荷の高い処理が実行されていないことを確認します。Compose を使わないサブスクライバーへのポインター書き込みだけに留めてください。
  • LaunchedEffect(Unit) { /* non-suspending work */ } が残っていないことを確認します。一度だけ実行する処理には remember { ... }、キーに応じて実行する処理には RememberedEffect(key) { ... } に置き換えてください。

参考資料

コメント