以前、kotlin coroutines flowを使って、LiveDataを使わずMVVMを行う方法について書きました。
kotlin coroutines flowでMVVMを試した(LiveDataを使わない)
その後、StateFlow も登場し、ますますLiveDataの代わりに、kotlin coroutinesを使う手法が確立してきたように感じます。
Flowをactivityやfragmentで安全にcollectするためには、lifecycle scopeを使う必要があります。
しかし、lifecycle scopeは通常のcoroutines scopeにいくつかメソッドが追加されており、少し複雑です。
今回はflowをlifecycleScopeで安全に使う方法について考えます。
ViewModelからViewに非同期にデータを流すため、flowを利用します。
表示し続けるような状態を示すデータであればStateFlow、dialogの表示や画面遷移など、一時的なイベントであればBroadcastChannelを使うのが良いと思います。
class TestViewModel : ViewModel() {
private val _event = BroadcastChannel<Int>(Channel.BUFFERED)
val event: Flow<Int> = _event.asFlow()
private val _state = MutableStateFlow(0)
val state: StateFlow<Int> = _data
}
今回は1秒に1ずつインクリメントしたdataを、flowにdataを流していきます。
class TestViewModel : ViewModel() {
private val _event = BroadcastChannel<Int>(Channel.BUFFERED)
val event: Flow<Int> = _event.asFlow()
private val _state = MutableStateFlow(0)
val state: StateFlow<Int> = _data
init {
viewModelScope.launch {
var i = 0
while (true) {
_event.send(i)
_state.value = i
i += 1
delay(1000)
}
}
}
}
Lifecycle scopeを使う前に、念の為GlobalScopeを使ったらどうなるか見てみましょう。
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
GlobalScope.launch {
viewModel.event.collect {
Log.d(tag, "event:$it")
}
}
GlobalScope.launch {
viewModel.state.collect {
Log.d(tag, "state:$it")
}
}
}
当然ですが、このコードには問題があります。
GlobalScopeはアプリケーションが終了するまでずっと動き続けるため、画面回転等で破棄されたactivity上でも動作し続け、クラッシュする危険もあります。
D/TestActivity: event:0
D/TestActivity: state:0
D/TestActivity: event:1
D/TestActivity: state:1
// 画面回転
D/TestActivity: ON_PAUSE
D/TestActivity: ON_STOP
D/TestActivity: ON_DESTROY
D/TestActivity: ON_CREAT
D/TestActivity: ON_START
D/TestActivity: ON_RESUM
// 以下2回ずつ呼ばれる
D/TestActivity: event:2
D/TestActivity: event:2
D/TestActivity: state:2
D/TestActivity: state:2
そこで登場するのがlifecycle scopeです。
このlifecycle scopeは、onDestory時にcollectを解除してくれるため、クラッシュする心配がなくなります。
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.launch {
viewModel.event.collect {
Log.d(tag, "event:$it")
}
}
lifecycleScope.launch {
viewModel.state.collect {
Log.d(tag, "state:$it")
}
}
}
基本的にはこれで問題ないはずです。
LiveDataのobserveと異なるのは、ホームボタンを押したり、異なるactivityが上に重なってonStopが呼ばれても、collectは動き続けます。
D/TestActivity: event:0
D/TestActivity: state:0
D/TestActivity: event:1
D/TestActivity: state:1
// バックグラウンドへ
D/TestActivity: ON_PAUS
D/TestActivity: ON_STOP
// 動作し続ける
D/TestActivity: event:2
D/TestActivity: state:2
D/TestActivity: event:3
D/TestActivity: state:3
ユーザの目に触れていないviewが更新されているのは、少し無駄な気もするので、修正しましょう。
- 2021/11/03 追記 -
launchWhenStarted
の問題はlifecycle-runtime-ktx:2.4.0-alpha01で追加されたrepeatOnLifecycleを使うことで解決します。
launchWhenStarted
は削除される計画もあるため、repeatOnLifecycle
を使うことをおすすめします。
lifecycle Scopeには、通常のlaunchに加えて、以下の3種類が用意されています。
* launchWhenCreated
* launchWhenStarted
* launchWhenResumed
今回は、LiveDataと同じくonStartからonStopまで動作させたいので、launchWhenStartedを使います。
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.launchWhenStarted {
viewModel.event.collect {
Log.d(tag, "event:$it")
}
}
lifecycleScope.launchWhenStarted {
viewModel.state.collect {
Log.d(tag, "state:$it")
}
}
}
これで、onStartとonStopの間のみcollectが動作するようになります。
しかし、この場合もいくつか問題があります。
D/TestActivity: event:0
D/TestActivity: state:0
// バックグラウンドへ
D/TestActivity: ON_PAUSE
D/TestActivity: ON_STOP
// 5秒後フォアグラウンドへ
D/TestActivity: ON_START
// バックグラウンド時に発生したeventが一気に流れてくる
D/TestActivity: event:1
D/TestActivity: event:2
D/TestActivity: event:3
D/TestActivity: event:4
D/TestActivity: event:5
D/TestActivity: event:6
D/TestActivity: event:7
D/TestActivity: state:7
D/TestActivity: ON_RESUME
D/TestActivity: event:8
D/TestActivity: state:8
StateFlowでつくられたstateの方は、フォアグラウンドに戻った際に最新の値のみが流れてきます。
これは想定どおりの動きだと思います。
問題はBroadcastChannelで作られたeventの方で、バックグラウンド時に発生したeventが全て一気に流れてきます。
状況によって色々あるとは思いますが、いきなり大量のデータが流れてきても困ると思います。
また、より問題なのは、BroadcastChannelのBufferを食いつぶした場合です。
ためしに、buffer sizeを2で動かしてみます。
-private val _event = BroadcastChannel<Int>(Channel.BUFFERED)
+private val _event = BroadcastChannel<Int>(2)
val event: Flow<Int> = _event.asFlow()
こんな感じになります。
D/TestActivity: event:0
D/TestActivity: data:0
// バックグランドへ
D/TestActivity: ON_PAUSE
D/TestActivity: ON_STOP
// 10秒後フォアグラウンドへ
D/TestActivity: ON_START
D/TestActivity: event:1
D/TestActivity: event:2
D/TestActivity: event:3
D/TestActivity: event:4
D/TestActivity: data:4
D/TestActivity: ON_RESUME
D/TestActivity: event:5
D/TestActivity: data:5
10秒間バックグランドにいたのに、0から4までしかインクリメントされていないことがわかるでしょうか。
BroadcastChannelではBufferがいっぱいになると、sendできなくなり、そこでつまります。
viewModelScope.launch {
var i = 0
while (true) {
_event.send(i) // ← ここでbufferに余裕ができるまで停止する
_state.value = i
i += 1
delay(5000)
}
}
他のActivity / Fragment等で同じflowをcollectしていた場合、突然データが更新されない自体が発生します。
これらの問題を解決するのに、新たに指定したbufferのchannelを用意し、lifecycleScopeで一度collectします。
ここでは一旦 Channel.RENDEZVOUS = 0 で動かしてみます。
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val temp = Channel<Int>(Channel.RENDEZVOUS)
lifecycleScope.launch {
viewModel.event.collect {
temp.offer(it) // ← channelが詰まらないようofferを使う
}
}
lifecycleScope.launchWhenStarted {
temp.consumeEach {
Log.d(tag, "event:$it")
}
}
}
するとこのように動きます。
D/TestActivity: event:0
D/TestActivity: event:1
// バックグランドへ
D/TestActivity: ON_PAUSE
D/TestActivity: ON_STOP
// 10秒後フォアグラウンドへ
D/TestActivity: ON_START
D/TestActivity: event:2
D/TestActivity: ON_RESUME
D/TestActivity: event:12
D/TestActivity: event:13
フォアグラウンドになったタイミングで、バックグランド中のデータが一つ流れ、その後は新しいデータのみが流れてきます。
buffer sizeが0なのに一つデータが流れてくるのは、launchWhenStartedの内部の仕様上仕方がないのだと思います。
buffer sizeを1にすると、バックグランド中の2つのデータが流れてきます。
D/TestActivity: event:0
D/TestActivity: event:1
// バックグランドへ
D/TestActivity: ON_PAUSE
D/TestActivity: ON_STOP
// 10秒後フォアグラウンドへ
D/TestActivity: ON_START
D/TestActivity: event:2
D/TestActivity: event:3
D/TestActivity: ON_RESUME
D/TestActivity: event:12
D/TestActivity: event:13
ちなみに、以下のコードではなぜか動かず、channelが詰まってしまいました。 (bufferのoperatorもonStopの間動作してないのだと思います。)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.launchWhenStarted {
viewModel.event.buffer(Channel.RENDEZVOUS).collect {
Log.d(tag, "event:$it")
}
}
}
少し長ったらしいので、関数化したくなると思います。
このような拡張関数を作るとよいかもしれません。
inline fun <T> StateFlow<T>.observe(
lifecycleOwner: LifecycleOwner,
crossinline action: suspend (value: T) -> Unit
) {
lifecycleOwner.lifecycleScope.launchWhenStarted {
this@observe.collect {
action(it)
}
}
}
inline fun <T> Flow<T>.observe(
lifecycleOwner: LifecycleOwner,
capacity: Int = Channel.RENDEZVOUS,
crossinline action: suspend (value: T) -> Unit
) {
val temp = Channel<T>(capacity)
lifecycleOwner.lifecycleScope.launch {
this@observe.collect {
temp.offer(it)
}
}
lifecycleOwner.lifecycleScope.launchWhenStarted {
temp.consumeEach {
action(it)
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel.event.observe(this) {
Log.d(tag, "event:$it")
}
viewModel.state.observe(this) {
Log.d(tag, "state:$it")
}
}
launchWhenStartedは便利ですが、少し動きが奇妙で、使い方によってはchannelが詰まる可能性があることを把握しておきましょう。
今回かなり変な実装になってしまったので、もし他にいいアイディアがあれば教えて下さい。
また今回、onDestoryの後に発生したeventに関しては完全無視されていたり、onStart時に最後のeventをreplayしたい、みたいなことは実現できませんでした。
lifecycleはViewModelのほうで把握しておき、onStopが呼ばれた後はeventを流さないようにする、channelが詰まっても大丈夫なようにする、等他のアプローチのほうが好ましいかもしれません。
flowはまだまだ使い方が確立しておらず、stableでもないので、注意深く使っていく必要があると思います。
- 2021/01/31追記-
Kotlin Coroutinesの解説本をZennにて販売しています。より詳しく学びたい方は、こちらも合わせて確認してみて下さい。