Blog

kotlin coroutinesでViewModelからViewにイベント通知したい

この記事はAndroid Advent Calendar 2020 の2日目の記事です。

SharedFlowStateFlowの登場により、ますますkotlin coroutinesを手軽に扱えるようになってきました。

AndroidのMVVMにおいても、LiveDataの代わりにStateFlowを使ってViewとViewModelをbindingすることが可能になりました。

GitHub - Mori-Atsushi/android-flow-mvvm-sample: Android MVVM sample app that uses kotlin coroutines flow (without LiveData)

一方で、ViewModelからViewに状態ではなくイベントを送るのは未だいくつかの問題があります。

今回は、複数の方法をメリットデメリットとともに紹介したいと思います。

SharedFlowを使う

イベントを扱う一番簡単な方法は SharedFlow を使う方法です。

以下のようにMutableSharedFlowを使って、 onEvent() が呼ばれたタイミングでflowに値を流すことができます。

class SampleViewModel : ViewModel() {
    private val _event = MutableSharedFlow<Int>()
    val event: SharedFlow<Int> get() = _event

    fun onEvent() {
        viewModelScope.launch {
            _event.emit(1)
        }
    }
}

公開するときは SharedFlowFlow にcastして外から変更できないようにしておいたほうが良いでしょう。

Activity側ではこのようにsubscribeします。

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    lifecycleScope.launch {
        viewModel.event.collect {
            Log.d("SampleActivity", it.toString())
        }
    }
}

lifecycleScope を使うことで onDestory 時に自動的にsubscribeを解除してくれ、リークする心配がありません。

Startからstopの間のみイベントを流す場合は launchWhenStarted を使ってください。(それ以外のときはpause状態になります)

lifecycleScope.launchWhenStarted {
    viewModel.event.collect {
        Log.d("SampleActivity", it.toString())
    }
}
  • 2021/11/03 追記 -

lifecycle-runtime-ktx:2.4.0-alpha01repeatOnLifecycleが追加されました。

launchWhenStartedは削除される計画もあるため、repeatOnLifecycleを使うことをおすすめします。


基本的にはこれで動作しますが、一つ問題があります。

onCreateより前や画面回転中等、subscriberがない間に発生したイベントは、どこにも届かず破棄されてしまいます。

class SampleViewModel : ViewModel() {
    private val _event = MutableSharedFlow<Int>()
    val event: SharedFlow<Int> get() = _event

    init {
        viewModelScope.launch {
            // onStartより前なのでこのタイミングのイベントはviewに届かない
            _event.emit(1)
        }
    }
}

Channelを使う

Channelを使うことで、先程問題になったのsubscriberがない間のイベントも保持することができます。

Channelは作成時にbuffer sizeを指定することができるので、状況に合わせて設定しましょう。

UNLIMITED を設定することもできます。

private val _event = Channel<Int>(Channel.UNLIMITED)
val event: ReceiveChannel<Int> get() = _event

init {
    viewModelScope.launch {
        _event.send(1)
        _event.send(2)
        _event.send(3)
    }
}

公開は ReceiveChannelですることで、外からの書き換えはできなくなります。

view側ではこのようにsubscribeします。

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    lifecycleScope.launchWhenStarted {
        viewModel.event.receiveAsFlow().collect {
            Log.d("SampleActivity", it.toString())
        }
    }
}

receiveAsFlowを使ってFlowに変換し、collectすると、subscribeされてない時のデータも受け取ることができます。

D/SampleActivity: 1
D/SampleActivity: 2
D/SampleActivity: 3

この解決方法の問題点は、複数箇所でsubscribeすることができません。

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    lifecycleScope.launchWhenStarted {
        viewModel.event.receiveAsFlow().collect {
            Log.d("SampleActivity", "collect1:$it")
        }
    }

    lifecycleScope.launchWhenStarted {
        viewModel.event.receiveAsFlow().collect {
            // 上でreceiveされてるため、ここでデータは受け取れない
            Log.d("SampleActivity", "collect1:$it")
        }
    }
}

ViewModelにlifecycleを渡す

複数箇所でsubscribe可能で、イベントの漏れが発生しない方法として、ViewModel側にlifecycleを渡し、ViewModel側でバッファリングする方法があると思います。

以下のような、 LifecycleStateFlow というクラスと bufferUntilStarted というFlowの拡張関数を用意します。

class LifecycleStateFlow private constructor(
    private val mutableStateFlow: MutableStateFlow<Lifecycle.State>
) : LifecycleEventObserver, StateFlow<Lifecycle.State> by mutableStateFlow {
    constructor() : this(MutableStateFlow(Lifecycle.State.INITIALIZED))

    override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
        mutableStateFlow.value = source.lifecycle.currentState
    }

    suspend fun waitUntilAtLeast(state: Lifecycle.State) {
        if (value.isAtLeast(state)) return
        first { it.isAtLeast(state) }
    }
}

fun <T> Flow<T>.bufferUntilAtLeast(
    lifecycleStateFlow: LifecycleStateFlow,
    state: Lifecycle.State,
    capacity: Int = Channel.BUFFERED
): Flow<T> {
    return buffer(capacity)
        .onEach { lifecycleStateFlow.waitUntilAtLeast(state) }
}

fun <T> Flow<T>.bufferUntilStarted(
    lifecycleStateFlow: LifecycleStateFlow,
    capacity: Int = Channel.BUFFERED
): Flow<T> {
    return bufferUntilAtLeast(
        lifecycleStateFlow,
        Lifecycle.State.STARTED,
        capacity
    )
}

ViewModelはこのように使います。

class SampleViewModel : ViewModel() {
    val lifecycleStateFlow = LifecycleStateFlow()

    private val _event = MutableSharedFlow<Int>()
    val event: SharedFlow<Int> = _event
        .bufferUntilStarted(lifecycleStateFlow)
        .shareIn(viewModelScope, SharingStarted.Eagerly)

    init {
        viewModelScope.launch {
            _event.emit(1)
            _event.emit(2)
            _event.emit(3)
        }
    }
}

特定のflowに対して、bufferUntilStarted をつけることで、Startedになるまでバッファリングしてくれます。

shareInで再度hot stream化することを忘れないでください。

View側ではこのように利用します。

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    lifecycle.addObserver(viewModel.lifecycleStateFlow)

    lifecycleScope.launch {
        viewModel.event.collect {
            Log.d("SampleActivity", "collect1:$it")
        }
    }

    lifecycleScope.launch {
        viewModel.event.collect {
            Log.d("SampleActivity", "collect2:$it")
        }
    }
}

lifecycle.addObserverlifecycleStateFlow を指定することを忘れないでください。

また、今回はViewModel側でバッファリングしてるため、 launchWhenStarted ではなく通常の launch で良いでしょう。

このように2つsubscriberがあっても、2つともに全ての値が流れてきます。

onEach1:1
onEach1:2
onEach1:3
onEach2:1
onEach2:2
onEach2:3

若干複雑なのと、ViewModelがlifecycleを知っていることに違和感がある人もいるかも知れませんが、全てのデータが通知され、複数箇所でsubscribe可能なイベントを作成することができました。

まとめ

今回はkotlin coroutinesを使ってViewにイベントを通知する方法について複数紹介しました。

少し複雑ですが、全てのデータが通知され、複数箇所でsubscribe可能なイベントも作成することができました。

データの流れてくるタイミング、データを欠損したくないか否か、複数subscribeする必要があるか等で解決方法が変わってくると思います。

また、SharedFlowもChannelも bufferonBufferOverflow 等、オプションによって挙動を変えることができるので、ぜひ色々試してみてください。

SharedFlowの深堀り、replay, bufferって何【kotlin coroutines flow】

また、MVVMのViewModelは本来状態を扱うべきであり、イベントでやり取りするのは最小限であるべきだと考えています。

イベントのご利用は計画的に。

- 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エフェクトを深堀り、カスタマイズも