Blog

kotlin coroutines flowをlifecycle scopeで安全に扱う

以前、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

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)
            }
        }
    }
}

GlobalScopeでcollectする

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

lifecycleScope.launch

そこで登場するのが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が更新されているのは、少し無駄な気もするので、修正しましょう。

lifecycleScope.launchWhenStarted

- 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 sizeを指定する

これらの問題を解決するのに、新たに指定した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にて販売しています。より詳しく学びたい方は、こちらも合わせて確認してみて下さい。

詳解 Kotlin Coroutines [2021] | Zenn

人気の記事

kotlin coroutinesのFlow, SharedFlow, StateFlowを整理する

Jetpack ComposeとKotlin Coroutinesを連携させる

Layout Composableを使って複雑なレイアウトを組む【Jetpack Compose】

テスト用Dispatcherの使い分け【Kotlin Coroutines】

Flow.combineの内部実装がすごい話

Jetpack ComposeのRippleエフェクトを深堀り、カスタマイズも