1. Cold Flow を Hot に変換する「stateIn」の役割

Androidアプリ開発において、Roomデータベースのクエリ監視やネットワーク通信など、データ層から提供されるストリームの多くは Cold Flow(購読者が現れて初めて値の生成を開始するストリーム)です。

しかし、これをそのままUI層(ComposeやFragment)へ公開すると、購読者が増えるたびに上流の処理が重複実行されたり、画面回転のたびにイチから再フェッチが走ってしまいます。これを防ぐために ViewModel 内で利用するのが stateIn 演算子です。

class UserProfileViewModel(
    private val userRepository: UserRepository
) : ViewModel() {

    // Cold Flow を Hot な StateFlow へ変換
    val uiState: StateFlow<UserProfileUiState> = userRepository.getUserStream()
        .map { user -> UserProfileUiState.Success(user) }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000),
            initialValue = UserProfileUiState.Loading
        )
}

ここで最も重要なのが、第二引数に渡す SharingStarted の戦略です。主に以下の3つの選択肢が存在します。

多くの場面で推奨されるのが3つ目の WhileSubscribed ですが、なぜ引数に 5000 を渡すのがデファクトスタンダードとなっているのでしょうか?

2. なぜ「5000ミリ秒」なのか? 画面回転(Configuration Change)の解剖

結論から言えば、この5000ミリ秒(5秒)は 「画面回転やダークモード切替などの Configuration Change に伴う Activity / UI 破棄から再生成までのタイムラグ」を安全に吸収するための猶予時間 です。

「UIがバックグラウンドに回ったり破棄されたりした時は無駄な通信やCPU消費を止めたい。しかし、画面回転のわずか数百ミリ秒の隙間で上流のコルーチンを破棄して再起動するのは、バッテリー的にもパフォーマンス的にも最悪である。」

画面回転時に起きているライフサイクルの実態

ユーザーが端末を回転させたとき、Android OS は現在の Activity を破棄(onDestroy)し、新しい向きで即座に再生成(onCreate → onStart → onResume)します。このとき、Compose の collectAsStateWithLifecycle や Flow 購読は次のように振る舞います。

  1. 画面回転開始: 古い Activity のライフサイクルが ON_STOP に遷移。Compose の Flow コレクターが購読を解除する。
  2. 購読者ゼロの瞬間: この時点で uiState のコレクター数は一時的に 0 になる。
  3. 新しい画面の描画: 新しい Activity が立ち上がり、再び collectAsStateWithLifecycle() が実行され、コレクター数が 1 に戻る。

もしここで SharingStarted.WhileSubscribed()(引数なし=デフォルト stopTimeoutMillis = 0)を指定していた場合、購読者が 0 になった瞬間に上流のコルーチンが即座にキャンセル されます。そして、ほんの数ミリ秒〜数百ミリ秒後に新しい画面が購読を再開した際、上流の処理(通信やDBクエリ)が最初からやり直されてしまいます。

これにより、UIに一瞬ローディングインジケータ(プログレスバー)がチラついたり、無駄な通信トラフィックが発生する「画面回転時のチラつき・再フェッチ問題」が発生します。

5秒という絶妙な閾値

OSの処理負荷が高い低スペック端末やアニメーションが挟まる場合でも、画面破棄から再購読までは通常 数百ミリ秒〜1秒以内 に完了します。

5000ミリ秒(5秒)という余裕を持たせることで、以下の2つの要件を完璧に両立できるのです。

3. WhileSubscribed の第2引数「replayExpirationMillis」も使いこなす

WhileSubscribed には、実はもう一つの重要な引数があります。デフォルトでは Long.MAX_VALUE に設定されている replayExpirationMillis です。

fun WhileSubscribed(
    stopTimeoutMillis: Long = 0,
    replayExpirationMillis: Long = Long.MAX_VALUE
): SharingStarted

stopTimeout と replayExpiration の違い

この2つのパラメータの責務は明確に分かれています。

例えば、「ユーザーがアプリを離れてから数分後に戻ってきた場合、古いキャッシュを表示するのではなく、必ずローディングを表示して最新データを再取得させたい」という要件があるとします。その場合は次のように記述します。

val uiState = repository.getTimelineStream()
    .map { TimelineUiState.Success(it) }
    .stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(
            stopTimeoutMillis = 5_000L,        // 画面回転対策で5秒待つ
            replayExpirationMillis = 60_000L   // 1分以上離脱したらキャッシュ破棄
        ),
        initialValue = TimelineUiState.Loading
    )

こうすることで、バックグラウンドに回って1分以内であれば瞬時に復帰画面を表示し、1分以上経過した場合は古いキャッシュを捨ててクリーンな初期状態(Loading)から再フェッチを開始できます。

4. Jetpack Compose: collectAsStateWithLifecycle との協調

ViewModel 側で WhileSubscribed(5000) を設定しても、UI 側でただの collectAsState() を使ってしまうと台無しになります。collectAsState は Composable の Composition ライフサイクル(画面ツリーに載っているか)にしか追従せず、Android の Activity / Fragment ライフサイクル(フォアグラウンド/バックグラウンド)を検知できないためです。

必ず androidx.lifecycle:lifecycle-runtime-compose に含まれる collectAsStateWithLifecycle を使用しましょう。

@Composable
fun UserProfileRoute(
    viewModel: UserProfileViewModel = viewModel()
) {
    // ライフサイクルが STARTED 未満(バックグラウンド等)になると自動で購読解除される
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    UserProfileScreen(uiState = uiState)
}

この2つの組み合わせ(collectAsStateWithLifecycle + WhileSubscribed(5000))によって、初めて「Androidのライフサイクルと完全に同期し、かつ画面回転でも死なない堅牢なリアクティブパイプライン」が完成します。

5. 現場で嵌まる2つの落とし穴と対処法

落とし穴1: ユニットテストで値が流れてこない(Turbine / runTest)

WhileSubscribed(5000) を用いた ViewModel をテストする際、Turbine などのライブラリでテストを書くと「値がいつまで経っても流れてこずタイムアウトする」という罠に遭遇することがあります。

これは、テスト実行時にテストスコープのディスパッチャ(StandardTestDispatcher 等)が仮想時間を進めないと、購読開始やタイムアウトのコルーチンがディスパッチされないことが原因です。

@Test
fun testUiStateEmitsSuccess() = runTest {
    val repository = FakeUserRepository()
    val viewModel = UserProfileViewModel(repository)

    viewModel.uiState.test {
        // 初期値
        assertEquals(UserProfileUiState.Loading, awaitItem())
        
        // 購読者登録に伴い上流が開始され、Success が流れてくる
        val item = awaitItem()
        assertTrue(item is UserProfileUiState.Success)
    }
}

Turbine を使う場合は、テストブロック内で awaitItem() を呼ぶことで購読が発生するため動作しますが、もしテスト環境専用に即時実行させたい場合は、DI(依存性注入)等を通じて SharingStarted を差し替え可能にしておく設計も有効です。

落とし穴2: 画面スタックの奥に積まれた画面での挙動

Navigation Compose などで画面 A から画面 B へ遷移した場合、画面 A の Composable はバックスタックに保存されますが、画面上に表示されなくなるためライフサイクルは STOPPED になります。

この時、画面 A の ViewModel が WhileSubscribed(5000) を持っていれば、画面遷移後5秒で綺麗に通信やリソース消費が停止します。そして画面 B から戻るボタンで画面 A に戻った際は、再び STARTED に戻るため自動的に購読が再開されます。これはメモリ効率とバッテリー保護の観点から極めて理想的な挙動です。

6. まとめ:おまじないを理解して意図のあるコードを書く

「なぜそう書くのか?」を掘り下げると、Android が誕生以来抱え続けてきた「Configuration Change と省電力」という2大テーマに対する、Kotlin コルーチン設計者たちの美しい回答が見えてきます。

意味を理解して記述されたコードは、不意のパフォーマンス低下やバグを防ぎ、チーム開発における強力な拠り所となります。何気ない1行のコードにも、確かな設計意図を込めていきましょう。