この記事は、and factory.inc Advent Calendar 2021の 16日目 の記事です。
昨日は、@go_sagawaさんの「GoでAtCoderをやる話」でした。
💡2022年現在はこちらの管理方法ではなく StateFlowでUiStateとEvent管理をするで紹介している書き方にしています。
About
Kotlin coroutines Flowが登場して、RxやLiveDataを入れ替えられるムーブメントを感じています。
とくにLiveDataに関しては、今後はFlowを使うことができる状態になっているようです。
この記事では、さくっと実際にリプレースしてみたらどんな感じに書けるのかを見ていこうと思います。
状態管理
LiveData
まずは、画面に表示する値や状態など、LiveDataの最もスタンダードな書き方を見ていきます。
class MainViewModel : ViewModel(), DefaultLifecycleObserver {
private val _message = MutableLiveData("hello")
val message: LiveData<String> get() = _message
override fun onStart(owner: LifecycleOwner) {
super.onStart(owner)
viewModelScope.launch {
delay(3000)
_message.value = "world"
}
}
}
messageというLiveDataが、onStartの3秒後にworldに変更されるというサンプルコードです。
この値はDataBindingで直接Layout XMLでバインディングして書くことができました。
<TextView
android:id="@+id/message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{viewModel.message}" />
DataBindingではなく、Fragment側でobserveしたいときはこう書くことができます。
mainViewModel.message.observe(viewLifecycleOwner) {
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
}
これも、画面に表示されているテキストと同様、値が変更されるとトーストが表示されます。
StateFlow
このLiveData標準の動きをするのがStateFlowです。
LiveDataと同様、collectすると最後の値を返してくれます。
現在はDataBinding対応もされているので、本当にLiveDataと同様に使えるのではないかなと思っています。
これがStateFlowを使った場合のViewModelです。
class MainViewModel : ViewModel(), DefaultLifecycleObserver {
private val _message = MutableStateFlow("hello")
val message = _message.asStateFlow()
override fun onStart(owner: LifecycleOwner) {
super.onStart(owner)
viewModelScope.launch {
delay(3000)
_message.value = "world"
}
}
}
LiveDataとほぼ同じです。
XML LayoutへのバインディングはLiveDataと何も変わらず使えるので割愛します。
Fragmentでcollectして値が変わったらToast表示する書き方はいくつかポイントがあります。
まずは書き方です。
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
mainViewModel.message.collect {
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
}
}
}
こちらのコードもLiveDataのときと同様にFragmentのonViewCreatedに定義します。LiveDataと比較するとネストが深く記述が多いですが、collectの部分あたりはLiveDataと同様です。
Flowは送るときも受信するときもCoroutineScopeが必要になるので、 launch{}
で囲ってあげる必要があります。
そして repeatOnLifecycle
の部分ですが、こちらは最新のLifecycle-ktxにて追加されたものでFlowの停止と再開などをよしなにやってくれます。
これを使わないで下記のように書いてしまった場合、アプリがバックグランドにいってもFlowを受け取り続けてしまいます。その結果、アプリがクラッシュする原因となったりしてしまいます。必ずrepeatOnLifecycleを使うようにしてください。
下記StateFlowのドキュメントにも同様の記載があります。
https://developer.android.com/kotlin/flow/stateflow-and-sharedflow?hl=ja#stateflow
repeatOnLifecycleにはどのタイミングで開始するかを Lifecycle.State.STARTED
で渡しています。ライフサイクルのonStartで開始し、対応するライフサイクルのonStopで停止します。
基本的にここも公式ドキュメント通り、STARTEDで開始でOKです。逆に、CREATEDなどを指定してしまうと、アプリがまだバックグランドにあってフォアグラウンドになっていないとき、また停止してアプリが閉じているときにだFlowの値が流れてきしまうことになるので、Crashの原因となってしまいます。
Flowに限らず、onStartedからonStopの間以外で画面の処理をすると危ないので、STARTEDを指定しましょう。
また、StateFlowに関しては、collectしたタイミングで最後の値を流してくれます。なので、範囲外で流れた値に関しても失うことなく処理されると思います。
色々書きましたが、StateFlowをcollectするときのテンプレみたいなものなので書きましょう🤗
ちなみに、複数箇所でcollectしたいときはlaunchを分ける必要があるので注意です。これは、collectを呼ぶとそこで中断されるためですね。
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
launch {
mainViewModel.message.collect {
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
}
}
launch {
mainViewModel.message.collect {
Snackbar.make(binding.root, it, LENGTH_SHORT).show()
}
}
}
}
StateFlowのポイント
- LiveDataをそのままリプレース可能な雰囲気
- DataBindingも可能なので、書き方の変更するだけで使えちゃいそう
イベント
ダイアログを表示するとか、Toastを表示するとか、画面遷移とか、1回だけ通知するイベントの実装はどうでしょうか。
LiveDataの場合
LiveDataはあくまで状態管理用のものであって、イベントとして使うべきではない。。とは言うものの、LiveDataでイベントバス的なことをしている人は多いのではないでしょうか(僕です)
LiveDataはObserve時に最後の値を流すという特徴があります。これのおかげで、画面回転や復帰時に最新の値が画面に表示されるのですが、イベントバスとして使うとイベントの再発火が起きてしまいます。
ダイアログは多重に起動するし、画面遷移で使おうものなら、戻ろうとしても前の画面の遷移イベントが再発火してしまってまた遷移されてしまうみたいなことが置きます。
上記の解決策として、Eventラッパーを自作したりする必要があります(Google I/Oのコードでは自作してました)
僕は最初は自作Eventラッパーを使っていましたが、複数Observe対応など面倒が多かったのも有り、LiveEvnetというライブラリにお世話になっています。
SharedFlow
FlowではSharedFlowがイベントバス的なことに向いているとされています。
Kotlinの公式ページでもガッツリEventとして使われています。
実装の仕方としてはこうです。
class MainViewModel : ViewModel(), DefaultLifecycleObserver {
private val _message = MutableStateFlow("hello")
val message = _message.asStateFlow()
// イベント通知
private val _toast = MutableSharedFlow<String>()
val toast = _toast.asSharedFlow()
override fun onStart(owner: LifecycleOwner) {
super.onStart(owner)
viewModelScope.launch {
delay(3000)
_message.value = "world"
// 表示のイベント値を流す
_toast.emit("Changed!")
}
}
}
基本的にはStateFlowとほぼ同様です。関数名がemitになっていますが、ほとんどそれだけですね。
collectするFragment側はこうなっています。
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
mainViewModel.toast.collect {
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
}
}
}
StateFlowと同じです。これでイベント処理が実装できました。動画はありませんが、これを動かすとonStartの3秒後にトーストが1回だけ表示されます。画面復帰しても再発火することはありません。やったね!
また、SharedFlowは複数箇所でcollectすることができます。
例えば1つのイベントを複数のFragmentでcollectしたいような場合などに活躍します。Dialogの値やイベントのやり取りをViewModelでシェアしてるケースなどが該当するかと思います。
SharedFlowの値が流れてしまう問題
さて、先程のSharedFlowではとっても簡単にイベントバスが実装できました。もう完璧でイベント問題解決!と思いきや、今度は値が流れてしまう問題があります。
このSharedFlowはcollectしていない間でも値が流れていきます。そのため、repeatOnLifecycleでSTARTEDを指定していると、その前の処理や、collectが間に合わなかったときに値が流れていってしまい、取得することができません。
例えば、ViewModelの初期処理で、自動でダイアログを表示するイベントや、画面遷移するイベントなどがあった場合、値が流れしまうのはまずいと思います。理想としては、collectしたときに流して欲しいところですが、残念ながらそれができません。
例ですが、onCreateで値を流してもトーストは表示されません。
override fun onCreate(owner: LifecycleOwner) {
super.onCreate(owner)
viewModelScope.launch {
_toast.emit("Changed!")
}
}
個人的にやっかいだなと思っているのは、onStartのタイミングで値を流しても駄目みたいです。
override fun onStart(owner: LifecycleOwner) {
super.onStart(owner)
viewModelScope.launch {
_message.value = "world"
_toast.emit("Changed!")
}
}
とても行けそうな気がするのですがトーストは表示されません。ここに先程のように delay(300)
を入れると反映されるので、速いと駄目みたいです。
ViewModelのDefaultLifecycleObserver(LifecycleObserver)だけでなく、FragmentのonStartでも同様に値が流れていってしまいました。
onStartのあとのonResumeではcollectすることができましたが、ここらへん、そもそも値が流れていってしまうとまずいイベントでは使用が難しそうです。
Channel
SharedFlowの値が流れてしまう問題の解決方法としてCoroutineのChannelを使うという手があります。
このChannelを使ったイベントの書き方はこれです。
class MainViewModel : ViewModel(), DefaultLifecycleObserver {
private val _message = MutableStateFlow("hello")
val message = _message.asStateFlow()
private val _toast = Channel<String>()
val toast = _toast.receiveAsFlow()
override fun onCreate(owner: LifecycleOwner) {
super.onCreate(owner)
viewModelScope.launch {
_toast.send("Changed!")
}
}
}
SharedFlowのときとほぼ変わらず、Channelになっているくらいの変化です。Channelへは send()
で値を送ることができます。また、 receiveAsFlow()
でFlowに変換しているので、Fragment側のcollect処理も何も変更が加わらずに書くことができます。
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
mainViewModel.toast.collect {
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
}
}
}
↑ SharedFlowやStateFlowと何も変更ありません。
このChannelを使うことで、collectしていないときに流れた値は保持しておいてくれて、後でcollectされたタイミングで流してくれます。
また、StateFlowのように再発火もしないため、イベントバスとして期待通りに実装することができました!やったね!
Channelは複数collectできない問題
はい。今度はこれです。Channelは複数箇所でcollectすることができません。
あちらを立てればこちらが立たず。難しい問題ですね。
しかし、SharedFlowのときにあった、値が処理されないかもしれない問題はクリアされています。
SharedFlowかChannelか
どちらもいいところがあれば駄目なところもあるということがわかりました。
ここでそれぞれをまとめます。
SharedFlowのポイント
- 複数箇所でcollect可能
- onStartまでの処理や、onPause以降の処理で流れた値は処理されず流されてしまうので、ライフサイクルに紐づく処理は注意
- ユーザー起因の操作のイベント
- 値が流れてもいいイベント
Channelのポイント
- 複数箇所でcollect不可
- collectしていないときに流れた値は保持してくれる
結論としては、それぞれを適切に使い分ける必要がありそうというのが僕なりの結論になりました。
ただ、1つのViewModelを複数Fragmentで使うというケースは、僕の関与しているプロジェクトだとそう多いケースではなく限定的です。
例えばダイアログで親のFragmentとシェアしていたり、ボトムナビゲーション周りでシェアしていたりして、複数箇所からobserveするようなことはしていますが、それぞれライフサイクルに紐付いて自動処理するというよりかは、ユーザーの操作起因で別のFragmentに伝えるケースが多いです。
そのため、下記の方針で使い分けが良さそうな気がしています。
- 基本的にはChannelを使う
- 複数箇所でcollectするイベントのみ、SharedFlowを使用し、値の喪失が許容できるか、そういうフローケースがないか確認する
おわりに
実際にリプレースをしてみて、最初はとても簡単に使うことができたので、「なんだ簡単じゃないか」なんて思ったのですが沼は深かったです。。笑
今回イベントの部分は色々苦労しましたが、SharedFlowとChannelのいいとこ取りをした独自実装的なイベントバスをしている人もいましたし、ライブラリも出てきそうだなと言う気もします。
色々変化があったらこの記事もアップデートしていきたいなと思います。
参考
https://medium.com/androiddevelopers/a-safer-way-to-collect-flows-from-android-uis-23080b1f8bda
https://elizarov.medium.com/shared-flows-broadcast-channels-899b675e805c
https://qiita.com/KazaKago/items/fba61beba05f5297d951