Blog

LiveDataからCoroutines Flowへ移行する

以前「LiveData vs Flow vs RxJava」という記事で紹介したとおり、LiveDataは非常にシンプルで、現在でもベストプラクティスであることは間違いないでしょう。

一方で、既に多くの箇所でCoroutinesを導入していたり、Coroutinesが使いやすいと感じている場合、LiveDataからCoroutinesのFlowに移行することも選択肢の1つでしょう。

CoroutinesはLiveDataより複雑で、様々なオプションを提供してくれているため、利用に戸惑うこともあると思います。

今回は、LiveDataが利用されていたようなケースでCoroutines Flowを使う場合どうしたらいいのか、細かい差分についても紹介をしていきます。

LiveDataとStateFlow

LiveDataを置き換えるのであれば、StateFlowを使うのが良いでしょう。

LiveDataにはStateFlowMutableLiveDataにはMutableStateFlowが対応します。

- private val mutableData = MutableLiveData("")
- val data: LiveData<String> = mutableData
+ private val mutableData = MutableStateFlow("")
+ val data: StateFlow<String> = mutableData

異なる点としては、StateFlowでは初期値が必須になります。

初期値が存在しないときは、nullableにしてnullを指定するのが良いでしょう。

- private val mutableData = MutableLiveData()
- val data: LiveData<String> = mutableData
+ private val mutableData = MutableStateFlow<String?>(null)
+ val data: StateFlow<String?> = mutableData

LiveDataにはUIスレッド用のsetValueとバックグランドスレッド用のpostValueがありますが、StateFlowはどのスレッドでもvalueを使って更新することが可能です。

val mutableData = MutableStateFlow<String>("initial")
mutableData.value = "updated"

DataBinding

Android Studio Arctic Foxより、StateFlowでDataBindingが使えるようになりました。

そのため、xmlはLiveDataと同じようにそのまま使えます。

<TextView
    android:id="@+id/textView"
    android:text="@{viewmodel.data}" />

Lifecycleでobserveする

ActivityやFragmentからLiveDataを監視するには、observe関数を使います。

lifecycleOwner (ここではActivity自体)を渡すことでonStartからonStopまでの間だけ監視してくれ、安全かつ効率的にViewを更新することが出来ます。

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

        viewModel.data.observe(this) {
            binding.textView.text = it
        }
    }
}

Flowを使う場合はいくつか気をつけるべき点があります。

ここではLifecycle KTXの2.4.0以降を使います。

implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.4.0")

lifecycleScopeを使った後にrepeatOnLifecycleLifecycle.State.STARTEDを指定することで、LiveDataと同じようにonStartからonStopまで監視してくれます。

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

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.data.collect {
                    binding.textView.text = it
                }
            }
        }
    }
}

以下のような拡張関数を作っておくと便利かもしれません。

fun <T> Flow<T>.collectIn(
    lifecycleOwner: LifecycleOwner,
    observer: (T) -> Unit
) {
    lifecycleOwner.lifecycleScope.launch {
        lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
            this@collectIn.collect {
                observer(it)
            }
        }
    }
}
override fun onCreate(savedInstanceState: Bundle?) {
   super.onCreate(savedInstanceState)

    viewModel.data.collectIn(this) {
        binding.textView.text = it
    }
}

気をつけるべき書き方① : lifecycleScope.launchの中で直接collectする

FlowをLifecycleから監視する際に、いくつか間違えやすい書き方があります。

1つ目は、lifecycleScope.launchの中で直接collectするものです。

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

    lifecycleScope.launch {
        viewModel.data.collect {
            binding.textView.text = it
        }
    }
}

この書き方を行うと、一見期待しているように動きますが、バックグランドに移動した場合等onStopになっても動き続け、不要にUI更新が行われる可能性があります。

検証のため、1秒に1ずつインクリメントして検証してみます。

class TestViewModel : ViewModel() {
    private val _state = MutableStateFlow(0)
    val state: StateFlow<Int> = _state

    init {
        viewModelScope.launch {
            while (true) {
                delay(1000)
                _state.value = _state.value + 1
            }
        }
    }
}
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    lifecycleScope.launch {
        viewModel.state.collect {
            Log.d("TestActivity", "launch:$it")
        }
    }
    lifecycleScope.launch {
        repeatOnLifecycle(Lifecycle.State.STARTED) {
            viewModel.state.collect {
                Log.d("TestActivity", "launch+repeatOnLifecycle:$it")
            }
        }
    }
}

この結果は以下のようになります。

D/TestActivity: launch:0
D/TestActivity: launch+repeatOnLifecycle:0
D/TestActivity: launch:1
D/TestActivity: launch+repeatOnLifecycle:1
D/TestActivity: launch:2
D/TestActivity: launch+repeatOnLifecycle:2
D/TestActivity: launch:3
D/TestActivity: launch+repeatOnLifecycle:3
// バックグランドへ(onStop)
D/TestActivity: launch:4
D/TestActivity: launch:5
D/TestActivity: launch:6
D/TestActivity: launch:7
// フォアグラウンドへ(onStart)
D/TestActivity: launch+repeatOnLifecycle:7
D/TestActivity: launch:8
D/TestActivity: launch+repeatOnLifecycle:8
D/TestActivity: launch:9
D/TestActivity: launch+repeatOnLifecycle:9
D/TestActivity: launch:10
D/TestActivity: launch+repeatOnLifecycle:10

launchのみの場合はバックグラウンド時も動作し続けますが、repeatOnLifecycle と組み合わせたときにフォアグラウンド時のみに更新を制限できることが分かると思います。

またStateFlowを使った場合、フォアグラウンドになったタイミングで最新の値が反映されるため、古い情報が表示される心配もありません。

通常のケースであれば、repeatOnLifecycle を使うことで不要な画面更新を抑えることが出来るでしょう。

気をつけるべき書き方②: launchWhenStartedFlowを扱う

LifecycleCoroutineScopeにはlaunchWhenStartedというonStartからonStopまで動作するlaunchメソッドが存在しますが、これをFlowに対して利用するのは推奨されていません。

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

    lifecycleScope.launchWhenStarted {
        viewModel.data.collect {
            binding.textView.text = it
        }
    }
}

repeatOnLifecycle を利用した場合、 onStop になったときにCoroutinesがキャンセルされ、リソースが開放されますが、launchWhenStartedでは一時停止の状態になるだけでリソースが開放されません。

StateFlowであまり大きな問題になることはありませんが、SharedFlowChannel等、bufferを持つものはbufferがつまる危険性もあります。

公式ドキュメントにも以下のように書かれ、将来的に削除される計画があります。

Caution: This API is not recommended to use as it can lead to wasted resources in some cases. Please, use the Lifecycle.repeatOnLifecycle API instead. This API will be removed in a future release.

注意:このAPIは、場合によってはリソースの浪費につながる可能性があるため、使用をお勧めしません。代わりにLifecycle.repeatOnLifecycle APIを使用してください。このAPIは、将来のリリースで削除される予定です。

今後は repeatOnLifecycleを使うようにしましょう。

map / switchMap

オペレーター系の移行についても見ていきましょう。

基本的にはLiveDataよりFlowのほうがオペレータが多いため、自由度が増します。

LiveDataのmapはFlowでもそのままmapが対応します。

- val data1 = MutableLiveData(0)
+ val data1 = MutableStateFlow(0)
val data2 = data1.map { it * 2 }

LiveDataのswitchMapにはflatMapLatestが対応しています。

このメソッドはまだExperimentalCoroutinesApiとなっており、変更される可能性があります。

- val flag = MutableLiveData(false)
- val data1 = MutableLiveData(0)
- val data2 = MutableLiveData(1)
- val data3 = flag.switchMap { if (it) data1 else data2 }
+ val flag = MutableStateFlow(false)
+ val data1 = MutableStateFlow(0)
+ val data2 = MutableStateFlow(1)
+ val data3 = flag.flatMapLatest { if (it) data1 else data2 }

Cold Streamに気をつけて

mapflatMapLatestStateFlowに対して利用するとFlowに変換され、cold streamとして扱われます。

そのため、mapしたFlowを複数箇所でcollectすると、collectの回数分mapが呼ばれることになります。

class TestViewModel : ViewModel() {
    private val data1 = MutableStateFlow(0)
    val data2 = data1.map {
        Log.d("TestViewModel", "map:$it")
        it * 2
    }

    init {
        viewModelScope.launch {
            while (true) {
                delay(1000)
                data1.value = data1.value + 1
            }
        }
    }
}
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    lifecycleScope.launch {
        repeatOnLifecycle(Lifecycle.State.STARTED) {
            viewModel.data2.collect {
                Log.d("TestActivity", "collect1:$it")
            }
        }
    }
    lifecycleScope.launch {
        repeatOnLifecycle(Lifecycle.State.STARTED) {
            viewModel.data2.collect {
                Log.d("TestActivity", "collect1:$it")
            }
        }
    }
}

1つのデータにつき、mapが2回呼ばれていることが確認できます。

D/TestViewModel: map:0
D/TestActivity: collect1:0
D/TestViewModel: map:0
D/TestActivity: collect2:0
D/TestViewModel: map:1
D/TestActivity: collect1:2
D/TestViewModel: map:1
D/TestActivity: collect2:2
D/TestViewModel: map:2
D/TestActivity: collect1:4
D/TestViewModel: map:2
D/TestActivity: collect2:4

LiveDataのmapは値が共有されるため、mapは1つのデータにつき一度のみ実行されます。

LiveDataのHot, Coldについては以下の記事で詳しく説明しています。

また、DataBindingStateFlowのみ対応しており、Flowは直接利用出来ないことにも注意が必要です。

Flowでは、stateInを使うことでStateFlowに変換することができ、値も共有されるようになります。

startedにはSharingStarted.WhileSubscribed()を指定することで、1つ以上collectされてるときにのみ動作するようになり、これでLiveDataと同じように動きます。

val data2 = data1.map {
    Log.d("TestViewModel", "map:$it")
    it * 2
- }
+ }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), 0)
D/TestActivity: collect1:0
D/TestViewModel: map:0
D/TestActivity: collect2:0
D/TestViewModel: map:1
D/TestActivity: collect1:2
D/TestActivity: collect2:2
D/TestViewModel: map:2
D/TestActivity: collect1:4
D/TestActivity: collect2:4

distinctUntilChanged

LiveDataにはdistinctUntilChangedという値が変更されたタイミングのみ出力するというオペレータがあります。

StateFlowは同じ値は連続して流さないようになっているので、デフォルトでdistinctUntilChangedが行われている状態になります。

LiveDataを使うようなケースであれば、同じ値を連続して流す必要はないと思いますが、そのような要求があればSharedFlowを使うのが良いでしょう。

replayに1、onBufferOverflowBufferOverflow.DROP_OLDESTを指定することで、LiveDataと同じく再collect時に最後の値を受け取れるようになります。

valueの代わりにemitもしくはtryEmitを利用する必要があります。

val flow = MutableSharedFlow<Int>(
    replay = 1,
    onBufferOverflow = BufferOverflow.DROP_OLDEST
).apply {
    tryEmit(0)
}

一方、イベントとして扱いたいときはChannelを使うのが良いでしょう。

MediatorLiveData

LiveDataには高度な変換を行うことが出来るMediatorLiveDataが用意されています。

val data1 = MutableLiveData(0)
val data2 = MutableLiveData(1)
val merged = MediatorLiveData<Int>().apply {
    addSource(data1) {
        value = it
    }
    addSource(data2) {
        value = it
    }
}

FlowではchannelFlowを使うことでほぼ同じ操作を行うことが出来ます。

ここでもstateInを使うことでリソースの共通化が出来ます。

val data1 = MutableStateFlow(0)
val data2 = MutableStateFlow(1)
val merged = channelFlow {
    launch {
        data1.collect {
            send(it)
        }
    }
    launch {
        data2.collect {
            send(it)
        }
    }
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), 0)

Flowでは多くのオペレータが用意されているため、より適したoperatorがあるかも知れません。

上記の例ではmergeで同じ処理が簡潔に書けます。

val data1 = MutableStateFlow(0)
val data2 = MutableStateFlow(1)
val latest: StateFlow<Int?> = merge(data1, data2)
    .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), 0)

combinefilterもよく使うオペレータでしょう。

まとめ

今回はLiveDataの挙動をFlowで再現する方法について紹介しました。

Flowは多くのオプションを持つため、LiveDataの挙動をほぼ再現することが可能な一方、気をつけるべき点もいくつかあることがわかりました。

今回はLiveDataの挙動を再現することに重きを置きましたが、無理にLiveDataの動作に合わせようとせず、Flowのデフォルトの挙動で扱うほうがわかりやすいケースもありそうです。


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