はじめに
Jetpack Composeを使ったMVVMアーキテクチャで、ViewModelが持つStateFlowをComposable側で購読する実装は今やお馴染みになりました。
val uiState by viewModel.uiState.collectAsState()
このコード、動きます。ですが本当に安全でしょうか?
結論から言うと、多くの現場コードに潜む「動くけど正しくない」実装です。本記事では、なぜcollectAsState()だけでは不十分なのか、Android特有のライフサイクルをどう意識すべきかを、OK/NG例を交えて整理します。
対象読者
- ViewModelとComposableの連携でStateFlow/SharedFlowを使っている
-
collectAsState()とcollectAsStateWithLifecycle()の違いを説明できない -
repeatOnLifecycleを「なんとなく」使っている
何が問題なのか
Androidアプリの画面は、フォアグラウンド/バックグラウンドの遷移によってライフサイクルの状態が絶えず変化します。一方でViewModelは画面の生成・破棄よりも長く生き続ける存在です(ViewModelStoreに紐づく限り)。
つまり、次のようなミスマッチが起こりえます。
- 画面がバックグラウンドにあるのに、ViewModelからのFlowを購読し続けてUI更新処理を実行してしまう
- 結果、不要なCPU/メモリ消費、最悪の場合はクラッシュにつながる
これは特に「ライブラリが自動でよしなにやってくれる」と思われがちな部分なので、明示的に理解しておく必要があります。
NG例:collectAsState()をそのまま使う
@Composable
fun UserProfileScreen(viewModel: UserProfileViewModel = hiltViewModel()) {
// NG: ライフサイクルを考慮しない購読
val uiState by viewModel.uiState.collectAsState()
when (uiState) {
is UserProfileUiState.Loading -> LoadingIndicator()
is UserProfileUiState.Success -> UserProfileContent(uiState.user)
is UserProfileUiState.Error -> ErrorMessage(uiState.message)
}
}
collectAsState()はComposableがコンポジションに存在する限りFlowを購読し続けます。画面がバックグラウンドに回ってもコンポジション自体は破棄されないため、購読は止まりません。
これにより以下のような問題が起こります。
- バックグラウンド中でもViewModelからの新しい値でリコンポジションが走り続ける
- 通信量の多いFlow(位置情報の連続更新など)を持つ画面では、無駄な処理がバックグラウンドでも継続する
- Android 12以降で導入されたバックグラウンド処理の制限と噛み合わず、意図しない挙動を生む可能性がある
OK例:collectAsStateWithLifecycle()を使う
@Composable
fun UserProfileScreen(viewModel: UserProfileViewModel = hiltViewModel()) {
// OK: ライフサイクルを意識した購読
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
when (uiState) {
is UserProfileUiState.Loading -> LoadingIndicator()
is UserProfileUiState.Success -> UserProfileContent(uiState.user)
is UserProfileUiState.Error -> ErrorMessage(uiState.message)
}
}
collectAsStateWithLifecycle()は内部でrepeatOnLifecycle(Lifecycle.State.STARTED)相当の仕組みを使い、画面がSTARTED以上のときだけFlowを購読します。バックグラウンドに回ると自動的に購読を停止し、フォアグラウンドに戻ると再開します。
導入方法もシンプルで、依存関係を追加するだけです。
implementation "androidx.lifecycle:lifecycle-runtime-compose:2.8.x"
そしてcollectAsState()をcollectAsStateWithLifecycle()に置き換えるだけで恩恵を受けられます。基本的にComposable側でFlowを購読するときは、この関数をデフォルトの選択肢にすべきです。
ViewModel側の実装:repeatOnLifecycleが必要なケース
collectAsStateWithLifecycle()はComposable(View層)側の話です。では、ViewModel内部や、Fragment/Activity側で明示的にFlowを購読する場合はどうすべきでしょうか。
NG例:lifecycleScope.launchだけで購読する
class MainActivity : ComponentActivity() {
private val viewModel: MainViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// NG: ライフサイクルの状態を考慮しない購読
lifecycleScope.launch {
viewModel.events.collect { event ->
handleEvent(event)
}
}
}
}
lifecycleScopeはonCreateからonDestroyまで生き続けるコルーチンスコープです。つまりこのコードは、Activityがバックグラウンドにあってもcollectし続けます。副作用としてToastやNavigationを発火するようなevents(SharedFlow)だと、バックグラウンド中にユーザーの目に触れない形でイベントが処理され、状態の不整合を招くことがあります。
OK例:repeatOnLifecycleでライフサイクル状態に応じたブロックを作る
class MainActivity : ComponentActivity() {
private val viewModel: MainViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// OK: STARTED〜STOPPEDの間だけ購読する
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.events.collect { event ->
handleEvent(event)
}
}
}
}
}
repeatOnLifecycle(Lifecycle.State.STARTED)は、Lifecycleが指定した状態(STARTED)に達するたびに与えられたブロックを新しいコルーチンとして起動し、状態を下回ると(STOPPEDになると)そのコルーチンをキャンセルします。これにより、フォアグラウンドにいる間だけ購読するという制御を明示的に実現できます。
複数のFlowを同時に購読する場合は、launchを組み合わせます。
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
launch { viewModel.uiState.collect { /* ... */ } }
launch { viewModel.events.collect { /* ... */ } }
}
}
Composable内でrepeatOnLifecycleを直接使うケース
通常、Composable内でのFlow購読はcollectAsStateWithLifecycle()で事足ります。しかし、状態としてではなく**副作用(ワンショットイベント)**を扱いたい場合、LaunchedEffectと組み合わせてrepeatOnLifecycleを使うことがあります。
@Composable
fun MainScreen(viewModel: MainViewModel = hiltViewModel()) {
val lifecycleOwner = LocalLifecycleOwner.current
val snackbarHostState = remember { SnackbarHostState() }
// OK: 副作用系イベントをライフサイクル考慮で購読
LaunchedEffect(lifecycleOwner) {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.snackbarEvents.collect { message ->
snackbarHostState.showSnackbar(message)
}
}
}
Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }) { /* ... */ }
}
ポイントは、LaunchedEffectのキーにlifecycleOwnerを渡し、内部でrepeatOnLifecycleによるライフサイクル制御を行っている点です。こうすることで、画面がバックグラウンドの間はSnackbar表示処理が呼ばれず、フォアグラウンド復帰時に正しく再開されます。
なぜSTARTEDが基準なのか
collectAsStateWithLifecycle()や上記の例でLifecycle.State.STARTEDを基準にしている理由は、Androidの画面がユーザーに見えている状態を表すのがSTARTEDだからです。
| 状態 | 意味 |
|---|---|
| CREATED | 生成済みだが非表示(バックグラウンド) |
| STARTED | 画面が見えている(フォアグラウンド、フォーカスは無いこともある) |
| RESUMED | 画面が見えていてフォーカスもある |
RESUMEDを基準にすると、他アプリとのマルチウィンドウ表示や、ダイアログ表示中などフォーカスが外れる瞬間に購読が止まってしまい、過剰にシビアです。逆にCREATEDを基準にすると、見えていないのに購読し続けてしまいます。そのため、STARTEDが「見えている間だけ処理したい」という要件に最も適した基準とされています。
まとめ
| 実装 | 評価 | 理由 |
|---|---|---|
collectAsState() |
NG | コンポジション生存中は常に購読し続ける |
collectAsStateWithLifecycle() |
OK | STARTED以上でのみ購読、バックグラウンドで自動停止 |
lifecycleScope.launch { flow.collect {} } |
NG | Activity/Fragmentの生存中は常に購読し続ける |
repeatOnLifecycle(STARTED) { flow.collect {} } |
OK | ライフサイクル状態に応じて購読の開始/停止を制御 |
Jetpack Composeは「宣言的に書けば良い感じに動く」印象を与えがちですが、Flowの購読に関してはライフサイクルを意識した書き方を選ぶかどうかで実際の挙動が変わることを覚えておく必要があります。
特にチーム開発では、collectAsState()とcollectAsStateWithLifecycle()の違いをレビュー観点として共有しておくと、思わぬバックグラウンド処理によるバグを未然に防げます。