本文へスキップ
すべての記事

Jetpack ComposeにおけるStateFlow対SharedFlow: リプレイバグを起こさずにUI状態をモデル化する

ComposeのViewModelでStateFlowをいつ使い、SharedFlowをいつ使うか——そして両者を取り違えたときに起きる一回限りのイベントのバグについて。

MFKAPPS 1 分で読めます

これまでリリースしてきたComposeの画面はすべて、最終的に同じ問いに行き着く——この情報は今の画面の見た目を表しているのか、それとも一度だけ起きた出来事を表しているのか。この答えを間違えると、決まって厄介なバグが出る。回転後に再び表示されるトースト、二重に発火するナビゲーションイベント、アプリをバックグラウンドに送って開き直すたびに再生される「セッション完了」のお祝い演出。修正すべきはその場しのぎの回避策ではない。最初から正しいFlowの型を選ぶことだ。

Kotlinには呼び出し側から見ると互換性があるように見えるhot flowが2つある——どちらもcollectAsStateWithLifecycle()LaunchedEffectで収集でき、どちらもViewModelの中に置かれ、どちらも設定変更を生き延びる。だが両者は互換ではなく、その違いはまさに上で述べた区別そのものだ。

StateFlow: 常に値を持ち、常にそれをリプレイする

StateFlowは状態のために作られている——誰かが見ているかどうかにかかわらず、いつでも現在の値を持つデータだ。タイマーの残り秒数、フォームのバリデーションエラー、リストのloading/error/successステータス。新しいコレクタは即座に最新の値を受け取り、どのコレクタも常に同じものを見る。

private val _uiState = MutableStateFlow(TimerUiState.Idle)
val uiState: StateFlow<TimerUiState> = _uiState.asStateFlow()

fun start(durationMinutes: Int) {
    _uiState.value = TimerUiState.Running(
        remainingSeconds = durationMinutes * 60,
    )
}

この「新しいコレクタに常に最新の値をリプレイする」という振る舞いこそがStateFlowの存在意義であり、設定変更後もComposeの画面を正しく保つ仕組みだ。端末を回転させるとComposableが再コンポーズされ、新しいコレクタがアタッチされるが、次のemissionを待つ空白の画面ではなく、即座に現在の状態がレンダリングされる。

SharedFlow: 起きた出来事を表す

その同じリプレイの振る舞いが、一度限りのイベントに対してはまさに間違っている。Pomodoroセッションが終わり、完了音とささやかなお祝いアニメーションをトリガーしたいとしよう——それは画面が再コンポーズされるたびにではなく、それが起きた瞬間に一度だけ起きるべきものだ。

このイベントを完了時にtrueにセットされるStateFlow<Boolean>としてモデル化すると、回転後に作られたものやユーザーがアプリを離れて戻ってきた後に作られたものを含め、新しいコレクタはすべて即座にtrueを見てお祝いを再生してしまう。人がまず手を伸ばす対処法は大抵、手動のリセット(消費した直後に_sessionComplete.value = false)だが、これは2つのコレクタが読み取りを競合するか、リセットが誤ったディスパッチャで実行されてフラグが間に合って戻らない限りは機能する。

ここで正しいプリミティブはリプレイなしのSharedFlowだ。なぜなら「現在の値」そのものを保持しないからだ——emissionの瞬間に能動的にsubscribeしているコレクタにだけemitする。

private val _events = MutableSharedFlow<TimerEvent>()
val events: SharedFlow<TimerEvent> = _events.asSharedFlow()

private fun onSessionComplete() {
    viewModelScope.launch {
        _events.emit(TimerEvent.SessionComplete)
    }
}

イベント発生時にリッスンしていなかったコレクタは、単純にそれを一切見ることがない——これはお祝いアニメーションにとっては正しく、タイマーの状態にとっては間違いになる。この非対称性こそが判断の全てだ。遅れてsubscribeした側が現在の値を見るべきなら状態であり、見逃すべきならイベントだ。

実際に機能する経験則

判断には1つの問いだけを使っていて、これはこの方式で作ったすべての画面で通用してきた——まったく新しいコレクタがこの値を即座に受け取ることがバグになるか?

  • タイマーの状態、フォームの内容、ローディングフラグ、リストの内容 → いいえ、新しいコレクタは現在の値を見るべきStateFlow
  • 「このスナックバーを表示」「この画面に遷移」「この音を再生」 → はい、遅れたsubscriberにそれをリプレイすることこそがバグ → SharedFlow(replay = 0)

知っておく価値のある唯一の例外はSharedFlow(replay = 1)で、中間的な選択肢に見える。だが実際には「常に値を持つ」保証も同期読み取り用の.valueも持たないStateFlowに過ぎない——状態をモデル化するなら本物のStateFlow、イベントをモデル化するならSharedFlow(replay = 0)を使う方がよく、これに頼る理由はめったにない。

Compose画面への接続

両者は異なる方法で消費され、これを取り違えることがこのバグの2番目によくあるパターンだ。

@Composable
fun TimerScreen(viewModel: TimerViewModel = viewModel()) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    LaunchedEffect(Unit) {
        viewModel.events.collect { event ->
            when (event) {
                TimerEvent.SessionComplete -> playCompletionSound()
            }
        }
    }

    TimerContent(uiState)
}

collectAsStateWithLifecycle()StateFlow向けだ——Compose stateとして公開する現在の値を必要とする。eventsはその代わりにLaunchedEffectの中で収集される。まさに保持すべき現在の値を持たないからだ。それは読み取るべき値ではなく、反応すべきストリームだ。

まとめ

このバグが防いでいるものは十分に微妙で、たいてい誰にも気づかれずにリリースされてしまう——回転後の重複トースト、バックグラウンドから戻った後に再トリガーされるアニメーション——なぜなら、決して離脱しないコレクタが1つだけという一般的なケースでは何もかも問題なく見えるからだ。それが露呈するのは画面がコレクタを再起動したときだけで、Composeは設定変更のたびにまさにそれを行う。状態はStateFlowでモデル化する。新しいコレクタは追いつくべきだからだ。イベントはリプレイなしのSharedFlowでモデル化する。追いつくべきではないからだ。私はMintlyのタイマーでこの使い分けをしている。実行中のカウントダウンは状態であり、すべての再コンポーズが正しく反映すべきものだ。セッション終了のお祝いはイベントであり、正確に一度だけ発火すべきもの——二度と、開き直したばかりの画面でも決して発火してはならない。