Kotlin Flowを使って画面にデータを反映しているとき、「アプリをバックグラウンドに送ったのに、裏で購読が動き続けている」「非表示のはずのViewを更新しようとしてクラッシュした」といった経験はありませんか? Flowは強力な仕組みですが、Androidのライフサイクルと正しく噛み合わせないと、無駄なリソース消費やクラッシュの原因になります。
この記事では、Flowの基礎(cold / hot の違い、StateFlow・SharedFlow)を押さえたうえで、なぜ lifecycleScope.launch でそのまま collect すると危険なのか、そして repeatOnLifecycle を使ってライフサイクル安全に購読する方法を解説します。
Flowの基礎:coldなストリーム
Flowは「非同期に複数の値を順に流すストリーム」です。基本形は flow { } ビルダーで作り、collect で受け取ります。
// 1秒ごとにカウントを流す Flow
fun countFlow(): Flow<Int> = flow {
var count = 0
while (true) {
emit(count++) // 値を流す
delay(1000)
}
}
// collect するまで flow ブロックは動かない
scope.launch {
countFlow().collect { value ->
Log.d("Sample", "受信: $value")
}
}
ここで重要なのが、Flowはcold(コールド)である点です。collect されて初めて flow { } の中が動き出し、collectするたびに最初から実行されます。つまり「購読者がいなければ何も流れない」性質を持ちます。
途中で値を加工したいときは map や filter などの演算子を挟みます。
countFlow()
.filter { it % 2 == 0 } // 偶数だけ
.map { "value = $it" } // 文字列に変換
.collect { text -> println(text) }
StateFlow と SharedFlow ― hotなストリーム
coldなFlowに対して、hot(ホット)なFlowもあります。代表が StateFlow と SharedFlow で、こちらは購読者の有無に関わらず値を保持・発行できます。UIの状態管理でよく使うのは StateFlow です。
StateFlow:常に「最新の1つの値」を保持する。初期値が必須。valueプロパティで現在値を直接読める。同じ値の連続発行はスキップされる。SharedFlow:初期値を持たず、replay数やバッファを細かく設定できる。イベント(1回きりの通知)に向く。
// StateFlow:画面の状態を保持する
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
fun update(data: SampleData) {
_uiState.value = UiState.Success(data) // value で更新
}
状態(今の画面はどうなっているか)は StateFlow、一度きりのイベント(トースト表示・画面遷移など)は SharedFlow、と使い分けると整理しやすくなります。
なぜ lifecycleScope の collect だけだと危険なのか
Fragmentで StateFlow を購読するとき、次のように書きたくなります。しかしこれには問題があります。
// 危険な例:バックグラウンドでも collect が動き続ける
viewLifecycleOwner.lifecycleScope.launch {
viewModel.uiState.collect { state ->
render(state) // 画面が非表示でも呼ばれてしまう
}
}
lifecycleScope のコルーチンは、ライフサイクルが DESTROYED になるまでキャンセルされません。つまり、アプリをバックグラウンドに送って画面が STOPPED になっても、collect は動き続けます。その結果、次のような無駄・危険が生じます。
- ユーザーに見えていない画面のために、CPU・ネットワークを消費し続ける
- 非表示中に届いた更新でViewを触り、状態不整合やクラッシュを招く
- バックグラウンドでの発行がバッテリー消費につながる
repeatOnLifecycle で安全に購読する
この問題を解決するのが repeatOnLifecycle です。指定したライフサイクル状態(通常は STARTED)になったときにブロックを実行し、その状態を下回ったら自動でキャンセルします。再び STARTED に戻れば、ブロックを最初から再実行します。
// 推奨:STARTED のときだけ collect する
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { state ->
render(state) // 画面が見えているときだけ呼ばれる
}
}
}
これで、画面が STOPPED になると collect は自動でキャンセルされ、再表示時に再開されます。無駄な購読もクラッシュも防げます。Fragmentでは viewLifecycleOwner を使うのがポイントです。FragmentのビューはFragment本体より寿命が短いため、View に紐づく購読は viewLifecycleOwner に合わせる必要があります。
複数のFlowを購読するとき
複数のFlowを同時に購読する場合は、repeatOnLifecycle ブロックの中で launch を並べます。1つのブロックで直列に collect を並べると、最初の collect が終わらない限り次に進まないので注意してください(collect は無限に待つため、後続が実行されません)。
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
launch {
viewModel.uiState.collect { render(it) }
}
launch {
viewModel.events.collect { handleEvent(it) }
}
}
}
1つだけなら flowWithLifecycle も使える
購読するFlowが1つだけなら、flowWithLifecycle を使うとネストが浅くなり読みやすくなります。内部では repeatOnLifecycle と同じ仕組みが働きます。
viewLifecycleOwner.lifecycleScope.launch {
viewModel.uiState
.flowWithLifecycle(viewLifecycleOwner.lifecycle, Lifecycle.State.STARTED)
.collect { state -> render(state) }
}
複数のFlowを扱うなら repeatOnLifecycle の中で launch を並べる方が素直です。1本だけなら flowWithLifecycle、と覚えておくとよいでしょう。
実践:ViewModel と組み合わせる
典型的な構成は、ViewModel側で StateFlow を公開し、View側で repeatOnLifecycle を使って購読する形です。
class SampleViewModel(
private val repository: SampleRepository
) : ViewModel() {
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
fun load() {
viewModelScope.launch {
_uiState.value = try {
UiState.Success(repository.fetch())
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
UiState.Error(e.message)
}
}
}
}
class SampleFragment : Fragment(R.layout.fragment_sample) {
private val viewModel: SampleViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { render(it) }
}
}
viewModel.load()
}
private fun render(state: UiState) {
// state に応じて View を更新する
}
}
よくある落とし穴
- Fragmentで
lifecycleScopeをそのまま使う ― Viewに紐づく購読はviewLifecycleOwner.lifecycleScopeを使う。Fragment本体のスコープだとViewが破棄された後に更新してしまう恐れがある。 - 1つのブロックで
collectを直列に複数書く ― 最初のcollectで止まって後続が動かない。launchで分ける。 - 状態とイベントを同じ
StateFlowで扱う ― 1回きりのイベントは、画面回転などで再購読すると再発火してしまう。イベントはSharedFlow側で扱うと安全。
まとめ
Flowをライフサイクル安全に購読する鍵は、lifecycleScope.launch で直接 collect せず、repeatOnLifecycle(Lifecycle.State.STARTED) を挟むことです。これで画面が見えていないあいだは自動で購読が止まり、無駄なリソース消費とクラッシュを防げます。Fragmentでは viewLifecycleOwner を使い、複数Flowは launch で分ける ―― この2点も合わせて押さえておきましょう。
状態は StateFlow、イベントは SharedFlow という役割分担も、後々のバグを減らす効きどころです。まずは既存の collect を repeatOnLifecycle でくるむところから、少しずつ安全な形へ移していってみてください。

コメント