using-efficient-effects について
Jetpack Compose 開発者向けに、ライフサイクルやコルーチンの要否に応じたエフェクト API の選び方を説明するスキルです。古いコールバックや項目ごとの ViewModel の扱いも取り上げ、不要なコルーチンや後処理漏れを防ぎます。
主な機能
- 01LaunchedEffect、DisposableEffect、SideEffect の使い分け
- 02同期的なキー変更に RememberedEffect を使用
- 03rememberUpdatedState でコールバックを最新に維持
- 04ViewModelStoreScope で項目別 ViewModel を分離
- 05単発処理やフレームごとの処理での誤用を回避
活用シーン
- 01リスナーや購読の登録と後処理
- 02長時間動作するエフェクトの古いコールバックを修正
- 03LazyColumn の行ごとに ViewModel を分ける
- 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も追加してください。
ワークフロー — 判断ツリー
- コルーチンが必要ですか? →
LaunchedEffect(key1, key2, ...)。ブロックはコンポジションのCoroutineScopeで起動され、いずれかのキーが変更されるたびにキャンセルされて再起動します。ネットワーク呼び出し、アニメーション、一時停止処理に使ってください。 - コルーチンなしでセットアップとクリーンアップが必要ですか? →
DisposableEffect(key) { onDispose { ... } }。onDisposeブロックは、キーの変更時と、コンポーザブルがコンポジションを終了するときに実行されます。リスナー、オブザーバー、手動のサブスクリプションに使ってください。 - コルーチンなしでキー変更に同期的に反応する必要がありますか? →
skydoves/compose-effectsのRememberedEffect(key) { ... }。同期関数を呼び出すだけのためにLaunchedEffectスコープを起動するよりも低コストです。onDisposeはありません — ティアダウンが必要な場合はDisposableEffectと組み合わせてください。 - 変更される可能性のあるコールバックを使う、長時間実行される
LaunchedEffectですか? →val latest by rememberUpdatedState(callback)でコールバックをラップし、エフェクト内ではlatestを参照してください。エフェクトは同じコルーチンを維持し、コールバックの参照は最新の状態に保たれます。 - コンポーザブルごとの
ViewModel(LazyColumnの行ごとに 1 つなど)が必要ですか? →com.github.skydoves:compose-effects-viewmodelのViewModelStoreScope(key = <stableId>) { ... }で行の本体をラップし、内部でhiltViewModel()(またはviewModel())を呼び出してください。keyは必須で、安定した識別子(行の ID など)でなければなりません。キーごとに独自のストアが割り当てられます。 - 成功するコンポジションのたびに実行する必要がある副作用ですか? →
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) { ... }に置き換えてください。
参考資料
- Android Developers — Compose の副作用:https://developer.android.com/develop/ui/compose/side-effects
- Android Developers — コンポーザブルのライフサイクル:https://developer.android.com/develop/ui/compose/lifecycle
- Skydoves — compose-effects(
RememberedEffect、ViewModelStoreScope):https://github.com/skydoves/compose-effects - Skydoves — Jetpack Compose の 6 つのガイドライン:https://medium.com/proandroiddev/6-jetpack-compose-guidelines-to-optimize-your-app-performance-be18533721f9
- Ben Trengrove — 再コンポジションのデバッグ:https://medium.com/androiddevelopers/jetpack-compose-debugging-recomposition-bfcf4a6f8d37
- 関連スキル:
../collecting-flows-safely/SKILL.md(collectAsStateWithLifecycleと Flow をパラメーターとして渡すアンチパターンについて)。 - 関連スキル:
../../recomposition/choosing-derivedstateof/SKILL.md(State→State の派生ケースについて。エフェクトではありません)。 - 関連スキル:
../../measurement/tracing-recompositions-at-runtime/SKILL.md(@TraceRecompositionのセットアップについて)。
コメント
Markdown 対応