mcpField
C
collecting-flows-safely

collecting-flows-safely

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

collecting-flows-safely について

Jetpack Compose で UI のライフサイクルに合わせて Kotlin Flow を扱う Claude 向けスキルです。ライフサイクル対応の収集、高頻度の値の調整、コンポーザブルへの Flow 引き渡しを避ける方法を説明します。

主な機能

  1. 01ライフサイクル対応の Flow 収集
  2. 02conflate と distinctUntilChanged による高頻度発行の調整
  3. 03呼び出し側で Flow を収集し、解決済みの値を渡す
  4. 04snapshotFlow による Compose 状態から Flow への変換
  5. 05アプリのバックグラウンド中に収集動作を検証

活用シーン

  1. 01UI 非アクティブ時に ViewModel や Repository の Flow を停止
  2. 02センサーや位置情報の更新による再コンポーズを抑制
  3. 03表示中のリスト状態を分析ツールへ送信

インストール

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

$npx skills add https://github.com/skydoves/compose-performance-skills --skill collecting-flows-safely

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

詳細

Flow を安全に収集する — 上流の処理を UI のライフサイクルに合わせる

collectAsState() はコンポーザブルがコンポジション内にある間、アプリがバックグラウンドにあるときも収集を続けるため、CPU とバッテリーを無駄に消費します。collectAsStateWithLifecycle() は収集を Lifecycle.State(デフォルトは STARTED)に合わせるため、バックグラウンドに移行すると上流の処理が一時停止します。頻繁に値を通知する Flow では、コンシューマーと .conflate()、.distinctUntilChanged() を組み合わせると、Compose には意味のある変更だけが渡されます。Skydoves のホットテイク #4:コンポーザブルの Flow<T> パラメーターは不安定で、コンポーザブル全体のスキップを妨げます。呼び出し側で収集し、解決済みの値を渡してください。

このスキルを使う場面

  • ViewModel または Repository が、Compose 画面に StateFlow<T>、SharedFlow<T>、またはコールドな Flow<T> を公開している。
  • ユーザーがバックグラウンドでのバッテリー消耗、「アプリを最近使ったアプリの一覧に入れても画面が動き続ける」、または logcat に表示される wakelock 関連のノイズについて報告している。
  • コンポーザブルが、Compose の外部から届くセンサーデータ、位置情報、GPS、websocket、またはアニメーションのフレームを消費している。
  • コンポーザブルのシグネチャが fun MyScreen(state: Flow<State>) になっている(Flow をパラメーターとして渡すアンチパターン)。
  • ユーザーが collectAsState と collectAsStateWithLifecycle の違いや、Lifecycle.State.STARTED/RESUMED の意味を尋ねている。
  • Compose の状態を Compose 以外のサブスクライバー(表示中の項目インデックスの分析など)に反映したい。これは snapshotFlow が担う State→Flow 方向の処理です。

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

  • Flow の生成と消費が、1 つのコンポーザブル内の remember { ... } ブロックだけで完結している。これはコンポジション内部の状態なので、mutableStateOf を直接使うことを推奨します。適切なエフェクト API の選び方については、../using-efficient-effects/SKILL.md を参照してください。
  • データソースがすでに State<T>(例:mutableStateOf、Animatable.asState())である。collectAsStateWithLifecycle() を呼び出すためだけに Flow でラップしないでください。
  • Activity が STOPPED の間も実行する必要がある、常時稼働のバックグラウンドリスナー。処理を Compose に入れるのではなく、Activity 内の Service/WorkManager/repeatOnLifecycle に移してください。

前提条件

  • Compose UI 1.4 以降(最新のリリースであればどれでも可)。
  • androidx.lifecycle:lifecycle-runtime-compose を追加してください(2.6 以降。collectAsStateWithLifecycle を公開するアーティファクト)。Maven の座標:androidx.lifecycle:lifecycle-runtime-compose:<version>。
  • Kotlin コルーチンの基礎知識(StateFlow、SharedFlow、conflate、distinctUntilChanged)。
  • 高頻度の通知がアニメーション値を更新している場合は、.conflate() よりフェーズ遅延のほうが適切な解決策となる可能性があるため、../../recomposition/deferring-state-reads/SKILL.md を参照してください。

ワークフロー

  1. すべての collectAsState() 呼び出しを監査します。 モジュール内で \.collectAsState\( を検索します。生成元の Flow が同じコンポーザブルスコープ内で作成されている場合を除き、各呼び出しを collectAsStateWithLifecycle() に置き換えます。
  2. Flow が StateFlow ではない場合(コールドな Flow<T> または SharedFlow<T>)、initialValue を指定します。StateFlow<T> の場合、オーバーロードが .value を読み取るため、初期値は不要です。
  3. 適切な minActiveState を選びます。デフォルトの STARTED は、フレームワークの repeatOnLifecycle のデフォルトと一致します。Activity が入力フォーカスを持つ間だけ収集するウィジェット(例:アグレッシブなセンサーソースを使う、常時表示のフォアグラウンド HUD)には RESUMED を使用します。目的が損なわれるため、CREATED は使用しないでください。
  4. **高頻度のプロデューサー(約 100 ms ごとに 1 回を超える発行)**の場合、.conflate() を collectAsStateWithLifecycle() より前に追加し、コンシューマーが各フレームで最新の値だけを受け取るようにします。連続する発行値が等しくなる可能性がある場合は、.distinctUntilChanged() も追加し、発行される型の equals() が正しく実装されていることを確認します。
  5. Flow<T> パラメーターはコンポーザブルの外に持ち上げます。 fun Foo(prices: Flow<Price>) を fun Foo(price: Price) に置き換えます。呼び出し元で収集してください。プロデューサーを親の内部に保持する必要がある場合は、生の Flow ではなく、() -> Price のラムダプロバイダーを公開します。
  6. State → Flow 方向の場合は、LaunchedEffect 内で snapshotFlow { ... } を使用します。これは Compose のスナップショットシステムからコルーチン Flow へのサポート対象のブリッジです。不要な発行を避けるため、.distinctUntilChanged() と組み合わせます。
  7. 検証します。 @TraceRecomposition(../../measurement/tracing-recompositions-at-runtime/SKILL.md を参照)と、アプリをバックグラウンドにした状態での logcat による健全性チェックを行います。上流の発行が停止するはずです。

パターン

パターン:collectAsState を collectAsStateWithLifecycle に置き換える
// WRONG
import androidx.compose.runtime.collectAsState

@Composable
fun HomeScreen(viewModel: HomeViewModel) {
    val state by viewModel.uiState.collectAsState()
    HomeContent(state)
}
// WRONG because: collection continues while the app is backgrounded -> wasted CPU and battery,
// and any upstream operators (network polling, db queries) keep running.
// RIGHT
import androidx.lifecycle.compose.collectAsStateWithLifecycle

@Composable
fun HomeScreen(viewModel: HomeViewModel) {
    val state by viewModel.uiState.collectAsStateWithLifecycle()
    HomeContent(state)
}
パターン:常時表示ウィジェットにカスタムの minActiveState を指定する
// RIGHT
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle

@Composable
fun TopBar(viewModel: HomeViewModel) {
    val state by viewModel.uiState.collectAsStateWithLifecycle(
        minActiveState = Lifecycle.State.RESUMED,
    )
    TopBarContent(state)
}
パターン:Flow<T> をコンポーザブルのパラメーターとして渡さない(skydoves のホットテイク #4)
// WRONG
@Composable
fun PriceTicker(prices: Flow<Price>) {
    val price by prices.collectAsState(initial = Price.ZERO)
    Text(price.formatted)
}
// WRONG because: Flow is unstable -> blocks skipping for the whole composable; the collection
// lifecycle is also unclear (recreated on every recomposition unless the caller remembers it).
// RIGHT
@Composable
fun PriceTicker(price: Price) {
    Text(price.formatted)
}

// Caller collects once and passes the value:
@Composable
fun TickerScreen(viewModel: TickerViewModel) {
    val price by viewModel.price.collectAsStateWithLifecycle()
    PriceTicker(price)
}
パターン:高頻度の Flow には .conflate() と .distinctUntilChanged() が必要
// WRONG
@Composable
fun TiltIndicator(repository: SensorRepository) {
    val tilt by repository.tilt.collectAsStateWithLifecycle(initialValue = 0f)
    TiltUi(tilt)
}
// WRONG because: hundreds of emissions per second invalidate the consuming composable per emission;
// most are dropped frames worth of work.
// RIGHT
@Composable
fun TiltIndicator(repository: SensorRepository) {
    val flow = remember(repository) {
        repository.tilt.conflate().distinctUntilChanged()
    }
    val tilt by flow.collectAsStateWithLifecycle(initialValue = 0f)
    TiltUi(tilt)
}
パターン:snapshotFlow で State → Flow に変換する
// RIGHT
@Composable
fun FeedAnalytics(listState: LazyListState, analytics: Analytics) {
    LaunchedEffect(listState, analytics) {
        snapshotFlow { listState.firstVisibleItemIndex }
            .distinctUntilChanged()
            .collect { index -> analytics.logFirstVisibleIndex(index) }
    }
}
// snapshotFlow bridges snapshot State to a cold Flow without paying the cost of recomposing
// every time firstVisibleItemIndex changes — the read happens inside the LaunchedEffect's coroutine,
// not in the composition phase.
パターン:一方向のリアクションには derivedStateOf より snapshotFlow を優先する
// LESS PREFERRED for an effect that only emits side effects
val isAtTop by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
LaunchedEffect(isAtTop) { if (isAtTop) reportTop() }
// PREFERRED — no derived state slot in composition; reads happen in the coroutine
LaunchedEffect(listState) {
    snapshotFlow { listState.firstVisibleItemIndex == 0 }
        .distinctUntilChanged()
        .filter { it }
        .collect { reportTop() }
}

必須ルール

  • 必ず、コンポジションの外部(ViewModel、リポジトリ、センサー、ネットワーク、websocket、位置情報)から発生するすべての Flow に対して、collectAsStateWithLifecycle()(androidx.lifecycle:lifecycle-runtime-compose から)を呼び出します。
  • Flow<T> をコンポーザブルのパラメーターとして渡してはいけません。 呼び出し元で収集し、解決済みの T(または、非常に高頻度なプロデューサーの場合は () -> T のラムダプロバイダー)を渡します。Flow は不安定で、コンポーザブル全体のスキップを妨げます。
  • 約 100 ms に 1 回を超える頻度で発行する Flow には、必ず .conflate() および/または .distinctUntilChanged() を collectAsStateWithLifecycle() より前に追加します。distinctUntilChanged を使用する場合は、発行される型の equals() が正しいことを確認します。
  • .conflate()/.distinctUntilChanged() をインラインで適用する場合は、再コンポジションのたびに新しいチェーンが作成されないよう、必ずオペレーターのチェーンを remember(key) でラップします。
  • minActiveState に Lifecycle.State.CREATED を使用してはいけません。Activity が非表示の間も収集が続き、移行の目的が損なわれます。
  • collectAsStateWithLifecycle() を呼び出すためだけに、既存の State<T> を Flow に再ラップしてはいけません。State のまま使用します。
  • 推奨: UI ではなく、後続処理だけを行うエフェクト(アナリティクス、ロギング、サイドチャネルへの発行)のみがコンシューマーである場合は、derivedStateOf { ... } より snapshotFlow { ... } を優先します。

検証

  • grep -R "collectAsState(" src/ の結果が 0 件であるか、残っている各箇所が同じコンポーザブル内で作成されたフローを収集しています。
  • アプリをバックグラウンドにして logcat を監視します。上流のオペレーター(Repository のログ行、センサーコールバック)が 1 フレーム以内に停止し、フォアグラウンドに戻すと再開します。
  • データを消費するコンポーザブルに @TraceRecomposition(traceStates = true) を指定すると、生の上流からの各発行ではなく、意味のある発行ごとに 1 回だけ再コンポジションされることが確認できます。../../measurement/tracing-recompositions-at-runtime/SKILL.md を参照してください。
  • Battery Historian / Studio Energy Profiler で、アプリがバックグラウンドにある間、画面に起因するフォアグラウンド専用の処理がないことを確認します。
  • モジュール内のコンポーザブルに Flow<*> 型のパラメーターがありません(grep -R ": Flow<" src/)。
  • minActiveState = RESUMED を使用する場合、その理由(フォーカスが必要なウィジェット)がコード内に記述されています。

参考資料

コメント