[Android] Kotlin Flowの基礎とライフサイクル安全な購読 ― repeatOnLifecycleでUIに反映する

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するたびに最初から実行されます。つまり「購読者がいなければ何も流れない」性質を持ちます。

途中で値を加工したいときは mapfilter などの演算子を挟みます。

countFlow()
    .filter { it % 2 == 0 }   // 偶数だけ
    .map { "value = $it" }     // 文字列に変換
    .collect { text -> println(text) }

StateFlow と SharedFlow ― hotなストリーム

coldなFlowに対して、hot(ホット)なFlowもあります。代表が StateFlowSharedFlow で、こちらは購読者の有無に関わらず値を保持・発行できます。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 という役割分担も、後々のバグを減らす効きどころです。まずは既存の collectrepeatOnLifecycle でくるむところから、少しずつ安全な形へ移していってみてください。

コメント

タイトルとURLをコピーしました