以前アドベントカレンダーでこちらのQiita記事を書きました。
この記事が今でもちょいちょいLGTMをもらっているのですが、肝心の自分はこの書き方を全くしておらず、UiのStateもイベントもStateFlowで管理しているので改めて書こうかなと思います。
まえおき
このStateの管理の仕方は、Android公式を読んでの書き方になっています。
イベント管理をストリームやChannelを使ってイベントバス的に実装するのではなく、イベントも状態として実装しましょう。というようなことが書いてあります。
とは言え現実問題として、消費型のイベントもあります。例えばSnackbar表示やツールチップ表示、画面遷移などです。
これらの消費型のイベントは、文字通り状態を消費することで表現できます。つまり、そのイベントを消費したら空の値なんなりを入れればいいということです。なんとシンプルな。。。と思いましたが、難しいイベントバスの仕組みを頑張って構築するよりもシンプルでいい感じだなと思いました🤔
また、実際にコードにしていく際にはこちらの記事も大変に参考にしました🙇♂️。というよりもかなり参考にしています。ありがとうございます。
UiState
今まではLiveDataを使って一つ一つの状態をこんな感じで書いていました。
private val _isLoading: LiveData<Boolean> = MutableLiveData(false)
val isLoading: LiveData<Boolean> get() = _isLoading
private val _homeViewData: LiveData<HomeViewData> = MutableLiveData()
val homeViewData: LiveData<HomeViewData> get() = _homeViewData
これらの状態は1つのdata classで表現します。
data class UiState(
val isLoading: Boolean,
val homeViewData: HomeViewData?,
)
そしてViewModelではこんな感じで宣言します。
private val _uiState = MutableStateFlow(
UiState(
isLoading = false,
homeViewData = null,
)
)
val uiState = _uiState.asStateFlow()
実際にデータを取得して、反映する処理はこんな感じで書けます。
fun fetch() = viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
// なんやかんやでホームのデータを取得してきた
val data = fetchHomeViewData()
_uiState.update { it.copy(isLoading = false, homeViewData = data) }
}
LiveDataを使うのと大きく変わらず書けます。またStateFlowはAndroid Layout対応もされているので、LiveDataと同様にDataBindingで使うことができます。
また、Jetpack Composeでももちろん状態監視ができるのでとってもいい感じです。
Jetpack Composeの例
@Composable
fun HomeScreen(viewModel: HomeViewModel) {
val uiState by viewModel.uiState.collectAsState()
HomeScreen(uiState = uiState)
}
@Composable
fun HomeScreen(uiState: UiScreen) {
Scaffold {
...
}
}
若干本題とずれますが、ViewModelを受け取るComposable関数と同名のComposable関数を用意して、そっちをViewModelの依存から外すことでPreviewがしやすくなるのでとってもおすすめです。
@Preview
@Composable
fun HomeScreenPreview() {
MyTheme {
HomeScreen(
UiState(
isLoading = true,
homeViewData = HomeViewData(...)
)
)
}
}
状態をシンプルなdata classとして宣言しているため、画面全体のPreview表示がとてもしやすくなります。ローディング状態、リトライ状態、タブレットでの表示などなどのパターンを網羅したPreviewもしやすくなります。
UiEvent
イベントとは、例えば画面遷移やSnackbarの表示、Tooltipの表示などなど単発のアクションのことを指しています。これらのイベントを通常のLiveDataで実装してしまうと、最後の値を再度流すという特性上多重発火してしまう挙動をするためイベント実装には不向きでした。
そのため、LiveDataでイベント実装ができる拡張関数や、LiveEventのようなライブラリを使ったり、RxやChannel、SharedFlowのような別のストリームを使ったりしていました。
ここではこのイベントも状態として扱います。状態なので先ほどのUiStateと一緒に管理してもいいのですが、updateするたびに値が流れてくるためあまりよろしくありません。
// ❌な例
data class UiState(
val isLoading: Boolean,
val homeViewData: HomeViewData?,
// 画面遷移のイベント
val navigateDetail: Unit?,
// Snackbar表示のイベント
val showSnackbar: String = "",
)
↑これだと、Snackbar表示をupdateしたあとにローディング状態などを変更すると多重でイベントが流れる可能性がある。
そのため、UiStateとは別に、StateFlowを更新しても値が多重に流れないようにします。
sealed interface UiEvent {
object NavigateDetail : UiEvent()
data class ShowSnackbar(val message: String) : UiEvent()
}
ViewModelでの宣言はこんな感じにしています。
private val _uiEvents = MutableStateFlow<List<UiEvent>>(emptyList())
val uiEvents = _uiEvents.asStateFlow()
先ほどのUiStateと同様にStateFlowを使っています。少し違うのはListにしている点です。複数イベントを発火できるようにしています。
値を流すときはこんな感じ。
fun fetch() = viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
// なんやかんやでホームのデータを取得してきた
val data = fetchHomeViewData()
_uiState.update { it.copy(isLoading = false, homeViewData = data) }
_uiEvents.update { it + ShowSnackbar("データの取得に成功しました") }
}
イベントリストの末尾に新しいイベントを追加して更新してあげています。
これでcollectしているところでイベントのハンドリングをしてあげればいいのですが、このままではイベントが消費されず何度も発火してしまいます。そこで消費用の関数も用意してあげます。
private val _uiEvents = MutableStateFlow<List<UiEvent>>(emptyList())
val uiEvents = _uiEvents.asStateFlow()
fun consume(target: UiEvent) {
_uiEvents.update { e -> e.filterNot { it == target } }
}
UiEventのリストから対象のイベントを取り除いているだけの関数です。
これをFragmentやComposeでcollectしてあげます。
例えばFragmentではこんな感じです。
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
homeViewModel.uiEvents.collect { events ->
events.forEach { event ->
when (event) {
NavigateDetail -> {
// 画面遷移処理
homeViewModel.consume(event)
}
is ShowSnackbar -> {
// Snackbar表示処理
homeViewModel.consume(event)
}
}
}
}
}
}
}
なにやらネストが深いところや、repeatOnLifecycleを使っている部分はStateFlowの部分なので今回は置いておいて、イベントはリストになっているのでforEachとwhenを使って取り出しています。処理をしたあとは必ず消費処理(consume関数)をしてください。これを忘れると画面遷移で戻ってきたときなどに再発火します。
Composeでcollectしたい場合はこんな感じで使っています。
@Composable
fun HomeScreen(viewModel: HomeViewModel) {
val uiState by viewModel.uiState.collectAsState()
val uiEvents by viewModel.uiEvents.collectAsState()
HomeScreen(uiState = uiState)
uiEvents.forEach { event ->
when (event) {
NavigateDetail -> {
// 画面遷移処理
viewModel.consume(event)
}
is ShowSnackbar -> {
// Snackbar表示処理
viewModel.consume(event)
}
}
}
}
StateFlowを使って単純に流して、そして単純に消費処理をするというシンプルな実装ですが、シンプルゆえに挙動がつかみやすく使い勝手がいい感じです。
また、状態もイベントも同じStateFlowを使っているのでテストが書きやすいというのもメリットです。
イベントは複数個所でも同時にcollectすることができます。ただその場合は両方でconsumeを呼ばないほうが良さそうです。まだStateFlowの挙動をあまりわかっていない部分があるのですが、両方でconsumeを呼ぶと適切に値が流れてきませんでした。
おわり
以上が最近僕が書いている状態とイベントの管理方法になります。
この書き方の気に入っているところは、Fragment + DataBinding(XML)の従来のコードでも、ComposeでどちらでもOKなところです。
また、Compose化する際には、Dialogの表示の仕方がイベント発火のDialogFragmentから、isShowのようなフラグでの表示切替になったりなど、従来イベントで管理していたものが状態管理になる箇所も結構あります。
そういう場面にも簡単に修正ができるという点もいいですね。
最後に改めてになりますが、今回の書き方はこちらの記事を本当に大変参考にしています。ありがとうございます🙇♂️