mcpField
C
compose-side-effects

compose-side-effects

github.com/chrisbanes開発1.1k1.0k 回インストール1 日前に更新
リポジトリ

compose-side-effects について

Android Compose 開発者向けに、副作用 API の選択とライフサイクル管理を解説するリファレンススキルです。キー、クリーンアップ、最新コールバック、Flow の収集、コンポジション時の注意点を扱います。

主な機能

  1. 01Compose 副作用 API の選択ガイド
  2. 02副作用キーと再起動の動作
  3. 03古いコールバックの捕捉回避
  4. 04Flow と snapshotFlow の収集
  5. 05登録とクリーンアップのパターン

活用シーン

  1. 01コンポーザブル内の副作用コードのレビュー
  2. 02キー付き処理に使う副作用 API の選択
  3. 03長時間実行処理の古いコールバックの修正
  4. 04イベント処理のための Compose 状態の監視

インストール

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

$npx skills add https://github.com/chrisbanes/skills --skill compose-side-effects

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

詳細

Compose:副作用

基本原則

コンポーザブルの本体は UI を記述します。再コンポーズされたり、スキップされたり、破棄されたりすることがあります。外部の状態を変更する処理には、その処理のライフサイクルに合ったエフェクト API を使用します。

最小限のエフェクトを選ぶ

必要な処理API
成功した再コンポーズのたびに、Compose の状態を Compose 以外のコードに公開するSideEffect
リスナー、コールバック、オブザーバー、リソースを登録/解除するDisposableEffect(keys...)
サスペンド処理、遅延処理、キーに応じた単発処理を実行するLaunchedEffect(keys...)
ユーザーイベントのコールバックからサスペンド処理を起動するrememberCoroutineScope()
コルーチン内で Compose のスナップショット読み取りを Flow に変換するLaunchedEffect 内の snapshotFlow { ... }

エフェクトのキー

キーは再起動時の識別情報を定義します。いずれかのキーが変わると、古いエフェクトはキャンセル/破棄され、新しいエフェクトが開始されます。

// ✅ Restart collection when userId changes
LaunchedEffect(userId) {
    repository.events(userId).collect { event -> handle(event) }
}

// ❌ Unit hides a changing input; collection keeps using the first userId
LaunchedEffect(Unit) {
    repository.events(userId).collect { event -> handle(event) }
}

安定した、意味のあるキーを使用します。

  • エフェクトがライフサイクルを追従する対象を使用します。userId、screenId、lifecycleOwner、focusRequester などです。
  • 1 つのプロパティだけが関係する場合に、広範なオブジェクト(state、viewModel)を使用しないでください。
  • ラムダが変わるたびに本当に再起動したい場合を除き、変化するラムダをキーに追加しないでください。

古いキャプチャを避ける

再起動はさせずに、最新のコールバックや値を必要とする長時間実行エフェクトには、rememberUpdatedState を使用します。

@Composable
fun Timeout(onTimeout: () -> Unit) {
    val latestOnTimeout by rememberUpdatedState(onTimeout)

    LaunchedEffect(Unit) {
        delay(1_000)
        latestOnTimeout()
    }
}

ライフサイクルが「一度だけ開始」でありながら、呼び出すラムダは常に最新である必要がある場合に使用します。よくあるケースは次のとおりです。

  • タイムアウトやスプラッシュ画面のエフェクトは、onTimeout が変わっても再起動せず、最新のコールバックを呼び出す必要があります。
  • ライフサイクルオブザーバーは同じオーナーに登録されたまま、最新の onStart/onStop ラムダを呼び出す必要があります。
  • 長時間実行されるコレクターは収集のライフサイクルを維持しつつ、最新のイベントハンドラーを呼び出す必要があります。

適切なキーを選ぶのを避けるために rememberUpdatedState を使用しないでください。値が変わったときに処理を再起動すべきなら、その値をキーにします。

// BAD: userId changes should restart the collection, not update a captured value.
val latestUserId by rememberUpdatedState(userId)
LaunchedEffect(Unit) {
    repository.events(latestUserId).collect { event -> handle(event) }
}

// GOOD: the collection lifecycle follows userId.
LaunchedEffect(userId) {
    repository.events(userId).collect { event -> handle(event) }
}
rememberUpdatedState の値は remember {} ブロック内では古くなります

rememberUpdatedState は State オブジェクトを返します。このオブジェクトの .value は再コンポーズのたびに更新されます。「最新」の値を利用できるのは、エフェクトの本体や後から実行されるラムダの内部など、State が遅延して読み取られる場合に限られます。値を先行してキャプチャする場合には利用できません。

remember {} ブロック内では、生成ラムダは一度だけ実行されます。そこでデリゲートを読み取ると、その時点の .value が記憶されるオブジェクトに取り込まれます。その後 State が更新されても、オブジェクトには反映されません。

val latestChannelId by rememberUpdatedState(channelId)

// ❌ BAD — channelId is read once when remember's lambda executes;
// the destination holds the initial value forever
val destination = remember {
    Destination(channelId = latestChannelId)
}

// ✅ GOOD — skip rememberUpdatedState; key remember on the changing value
val destination = remember(channelId) {
    Destination(channelId = channelId)
}

// ✅ ALSO GOOD — wrapping lambda defers the read to each invocation
val destination = remember {
    Destination(channelId = { latestChannelId })
}

同じ問題は、ラムダやエフェクト本体の中で遅延されず、rememberUpdatedState のデリゲートが先行して読み取られるあらゆる場所で発生します。たとえば、remember 内で構築されるデータクラス、DisposableEffect のセットアップブロックで一度だけ構築されるオブジェクト、作成時に評価されるあらゆる式が該当します。

キャプチャした値によって記憶されるオブジェクトを再作成する必要がある場合は、その値を remember のキーにして、rememberUpdatedState は使用しないでください。rememberUpdatedState は、長時間存続するスコープ(エフェクトのコルーチン、イベントコールバック)を再起動せずに、そのスコープ内で値を最新に保つ必要がある場合に限って使用します。

rememberUpdatedState を使っても、描画状態が「再コンポーズされなくなる」わけではありません。UI に変化する値を表示する必要がある場合は、コンポジション内で通常の State を読み取るか、フレームレートで変化する値には compose-state-deferred-reads の遅延読み取りパターンを使用してください。

Flow の収集

スナックバー、ナビゲーションイベント、分析イベント、フォーカスコマンドなど、各エミッションで命令的な処理を実行するストリームには、副作用/イベントフローを扱う LaunchedEffect を使用します。

LaunchedEffect(events) {
    events.collect { event ->
        snackbarHostState.showSnackbar(event.message)
    }
}

ローカル状態を変更するためだけに、レンダリング状態を命令的に収集しないでください。UI 状態は状態ホルダーの近くで収集し、通常の値を UI コンポーザブルに渡します。状態ホルダーと UI の分離、collectAsStateWithLifecycle() / collectAsState()、プレビューに適した配線については、compose-state-hoistingで説明しています。ここで同じアーキテクチャを重複して説明しないでください。

Android では、利用できる場合はライフサイクル対応の収集を優先してください。ライフサイクル対応 API がないターゲットでは、collectAsState() を使用します。

Compose の状態読み取りには、snapshotFlow を使用します。

LaunchedEffect(listState) {
    snapshotFlow { listState.firstVisibleItemIndex }
        .distinctUntilChanged()
        .collect { index -> analytics.visibleIndex(index) }
}

終端の collect を伴わない snapshotFlow { ... }.map { ... } は何も行いません。

ユーザーイベント

クリックやジェスチャーをきっかけにサスペンド処理を開始する場合は、rememberCoroutineScope() を使用します。

@Composable
fun SaveButton(snackbarHostState: SnackbarHostState) {
    val scope = rememberCoroutineScope()

    Button(
        onClick = {
            scope.launch {
                snackbarHostState.showSnackbar("Saved")
            }
        },
    ) {
        Text("Save")
    }
}

LaunchedEffect を起動するためだけに「イベントフラグ」状態を設けるのは避けてください。クリック自体がすでにイベントです。

登録とクリーンアップ

対になるセットアップとティアダウンには、DisposableEffect を使用します。

@Composable
fun ObserveLifecycle(owner: LifecycleOwner, observer: LifecycleObserver) {
    DisposableEffect(owner, observer) {
        owner.lifecycle.addObserver(observer)
        onDispose {
            owner.lifecycle.removeObserver(observer)
        }
    }
}

登録するすべての経路には、対応する onDispose のクリーンアップ処理を設けてください。

よくある間違い

間違い問題の診断修正方法
コンポーザブル本体でネットワークリクエストを直接実行するコンポジション中に副処理を実行しています通常は ViewModel /状態ホルダーに移します。UI が所有するキー付き処理の場合に限り、LaunchedEffect を使用します
コンポーザブル本体から分析プロパティを書き込むコンポジション中に副処理を実行しています成功する再コンポジションのたびに公開する必要がある場合は、SideEffect を使用します
コンポーザブル本体からインプレッション/イベントを記録するコンポジション中に副処理を実行していますそのキーに対して一度だけ実行する必要がある場合は、LaunchedEffect(key) を使用します
LaunchedEffect(Unit) が変化する id をキャプチャするキーが不足していますid をキーにするか、再起動してはいけない場合は rememberUpdatedState を使用します
id が変化した後も LaunchedEffect(Unit) の実行を継続させるために rememberUpdatedState(id) を使用する隠れたライフサイクルの不具合ですエフェクトのキーに id を指定します
長時間実行されるエフェクトが、再コンポジション後に古いコールバックを呼び出す古い値をキャプチャしていますコールバックを rememberUpdatedState でラップし、エフェクト内でラッパーを呼び出します
rememberUpdatedState のデリゲートを remember {} 内で直接読み取る(例:Destination(id = latestId))値が一度だけキャプチャされ、更新されません値を remember のキーにします:remember(id) { Destination(id = id) }
LaunchedEffect(state) { ... } が頻繁に再起動するキーの範囲が広すぎます対象のプロパティをキーにします
LaunchedEffect(...) { nonSuspendSetter() }エフェクトの種類が適切ではありません通常は SideEffect を使用します。LaunchedEffect はキー付きの単発処理/遅延処理に限って使用します
クリーンアップなしで LaunchedEffect にリスナーを追加する破棄処理が不足していますDisposableEffect を使用します
shouldShowSnackbar = true を設定してクリックから処理を起動するイベントフラグのアンチパターンですクリックコールバック内で rememberCoroutineScope() を使用します
副処理のためにコンポーザブル本体で if (isFocused) { … } を使う、またはフォーカス状態を読み取るコンポジション中に副処理を実行していますLaunchedEffect(focused) { … } または snapshotFlow を使用します
計測対象のコンポーザブルで onSizeChanged { heightState = it.height } を使用する兄弟コンポーザブルがコンポジション中に heightState を読み取ると、レイアウトからコンポジションへの書き戻しが発生します兄弟コンポーザブルでは、コンポジション中に Modifier.height(state.dp) を使うのではなく、計測フェーズで高さを参照する必要があります

フォーカスと計測

フォーカス: コンポーザブルの本体でフォーカスを読み取り、副次処理(プリロード、分析、トースト)を実行すると、その処理がコンポジション中に実行されます。代わりに、エフェクト内でフォーカスを監視してください。

// ❌ BAD — side work runs during composition every time `focused` is true,
// including transient focus passes; `SideEffect` re-runs after every successful recomposition
@Composable
fun Preloader(interactionSource: MutableInteractionSource) {
    val focused by interactionSource.collectIsFocusedAsState()
    if (focused) {
        preloadImages()
    }
}

// ✅ GOOD — side work in a keyed effect
@Composable
fun Preloader(interactionSource: MutableInteractionSource) {
    val focused by interactionSource.collectIsFocusedAsState()
    LaunchedEffect(focused) {
        if (focused) preloadImages()
    }
}

複数のスナップショット読み取りをサンプリングする必要がある場合や、派生値が変わるたびにエフェクトのキーを変更せずに急速な変化をデバウンスする場合は、LaunchedEffect 内で snapshotFlow { … } を使用してください。TV/D-pad のフォーカスナビゲーションのセマンティクスについては、compose-focus-navigation を参照してください。

計測: onSizeChanged/onGloballyPositioned は有効なコールバックですが、レイアウトフェーズ中に実行されます。そこでスナップショット状態を書き込んでも安全なのは、それ以前のフェーズでその状態が読み取られていない場合に限られます。兄弟要素がコンポジションでその状態を読み取る場合、レイアウトからコンポジションへの書き戻しが発生し、兄弟要素は計測のたびに再コンポジションされます。キャプチャした寸法は Modifier.layout で適用してください(compose-modifier-and-layout-style §7 および compose-state-deferred-reads を参照)。

レビュー時の要注意事項

  • コンポーザブル本体内のコードについて、「これは一度だけ実行される」と説明している。
  • パラメーターが変化する関数で LaunchedEffect(Unit) を使用している。
  • エフェクト内のフローチェーンに終端の収集処理がない。
  • ライフサイクルをモデル化するのではなく、リンターを黙らせるためにキーを選んだエフェクト。
  • キーも rememberUpdatedState も使わずに、長期間実行されるエフェクトからコールバックラムダを使用している。
  • rememberUpdatedState のデリゲートを remember {} ブロックまたはオブジェクトコンストラクター内で先行して読み取っている。値は一度だけキャプチャされ、その後更新されない。

コメント