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つの選択肢が存在します。
SharingStarted.Eagerly: ViewModelが生成された瞬間に即座に上流のFlowを開始し、購読者がいなくなっても永続的に共有し続ける。SharingStarted.Lazily: 最初の購読者が現れたときに上流を開始し、以降は購読者がいなくなっても停止しない。SharingStarted.WhileSubscribed(stopTimeoutMillis): 購読者が存在する間だけ上流を開始し、最後の購読者がいなくなってから指定ミリ秒後に上流をキャンセルする。
多くの場面で推奨されるのが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 購読は次のように振る舞います。
- 画面回転開始: 古い Activity のライフサイクルが
ON_STOPに遷移。Compose の Flow コレクターが購読を解除する。 - 購読者ゼロの瞬間: この時点で
uiStateのコレクター数は一時的に0になる。 - 新しい画面の描画: 新しい Activity が立ち上がり、再び
collectAsStateWithLifecycle()が実行され、コレクター数が1に戻る。
もしここで SharingStarted.WhileSubscribed()(引数なし=デフォルト stopTimeoutMillis = 0)を指定していた場合、購読者が 0 になった瞬間に上流のコルーチンが即座にキャンセル されます。そして、ほんの数ミリ秒〜数百ミリ秒後に新しい画面が購読を再開した際、上流の処理(通信やDBクエリ)が最初からやり直されてしまいます。
これにより、UIに一瞬ローディングインジケータ(プログレスバー)がチラついたり、無駄な通信トラフィックが発生する「画面回転時のチラつき・再フェッチ問題」が発生します。
5秒という絶妙な閾値
OSの処理負荷が高い低スペック端末やアニメーションが挟まる場合でも、画面破棄から再購読までは通常 数百ミリ秒〜1秒以内 に完了します。
5000ミリ秒(5秒)という余裕を持たせることで、以下の2つの要件を完璧に両立できるのです。
- 画面回転時: 5秒以内に新しい画面が再購読するため、上流コルーチンは一切キャンセルされず、最新のキャッシュ状態のままシームレスに描画が継続される。
- ホーム画面遷移・アプリアイコンタップ時: ユーザーがホーム画面に戻ってアプリが完全にバックグラウンドへ回った場合、5秒経過後に安全に上流コルーチンがキャンセルされ、通信やバッテリー浪費がストップする。
3. WhileSubscribed の第2引数「replayExpirationMillis」も使いこなす
WhileSubscribed には、実はもう一つの重要な引数があります。デフォルトでは Long.MAX_VALUE に設定されている replayExpirationMillis です。
fun WhileSubscribed(
stopTimeoutMillis: Long = 0,
replayExpirationMillis: Long = Long.MAX_VALUE
): SharingStarted
stopTimeout と replayExpiration の違い
この2つのパラメータの責務は明確に分かれています。
stopTimeoutMillis: 購読者が 0 になってから、上流のコルーチン(データフェッチや監視)を停止するまでの猶予時間。replayExpirationMillis: 上流が停止した後、保持している最新のキャッシュ値(リプレイキャッシュ)を破棄して初期値(initialValue)に戻すまでの期限。
例えば、「ユーザーがアプリを離れてから数分後に戻ってきた場合、古いキャッシュを表示するのではなく、必ずローディングを表示して最新データを再取得させたい」という要件があるとします。その場合は次のように記述します。
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 コルーチン設計者たちの美しい回答が見えてきます。
WhileSubscribed(5000)は画面回転時の再生成タイムラグを吸収するためのセーフティネット- UI側には必ず
collectAsStateWithLifecycleを採用する - 必要に応じて
replayExpirationMillisでキャッシュの鮮度をコントロールする
意味を理解して記述されたコードは、不意のパフォーマンス低下やバグを防ぎ、チーム開発における強力な拠り所となります。何気ない1行のコードにも、確かな設計意図を込めていきましょう。