以前「LiveData vs Flow vs RxJava」という記事で紹介したとおり、LiveDataは非常にシンプルで、現在でもベストプラクティスであることは間違いないでしょう。
一方で、既に多くの箇所でCoroutinesを導入していたり、Coroutinesが使いやすいと感じている場合、LiveDataからCoroutinesのFlowに移行することも選択肢の1つでしょう。
CoroutinesはLiveDataより複雑で、様々なオプションを提供してくれているため、利用に戸惑うこともあると思います。
今回は、LiveDataが利用されていたようなケースでCoroutines Flowを使う場合どうしたらいいのか、細かい差分についても紹介をしていきます。
LiveDataを置き換えるのであれば、StateFlowを使うのが良いでしょう。
LiveData
にはStateFlow
、MutableLiveDataには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"
Android Studio Arctic Foxより、StateFlowでDataBindingが使えるようになりました。
そのため、xmlはLiveDataと同じようにそのまま使えます。
<TextView
android:id="@+id/textView"
android:text="@{viewmodel.data}" />
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
を使った後にrepeatOnLifecycleでLifecycle.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
を使うことで不要な画面更新を抑えることが出来るでしょう。
launchWhenStarted
でFlow
を扱う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
であまり大きな問題になることはありませんが、SharedFlow
やChannel
等、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
を使うようにしましょう。
オペレーター系の移行についても見ていきましょう。
基本的には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 }
map
もflatMapLatest
もStateFlow
に対して利用すると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については以下の記事で詳しく説明しています。
また、DataBinding
はStateFlow
のみ対応しており、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
LiveData
にはdistinctUntilChangedという値が変更されたタイミングのみ出力するというオペレータがあります。
StateFlow
は同じ値は連続して流さないようになっているので、デフォルトでdistinctUntilChanged
が行われている状態になります。
LiveData
を使うようなケースであれば、同じ値を連続して流す必要はないと思いますが、そのような要求があればSharedFlowを使うのが良いでしょう。
replay
に1、onBufferOverflow
にBufferOverflow.DROP_OLDESTを指定することで、LiveData
と同じく再collect
時に最後の値を受け取れるようになります。
value
の代わりにemit
もしくはtryEmit
を利用する必要があります。
val flow = MutableSharedFlow<Int>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
).apply {
tryEmit(0)
}
一方、イベントとして扱いたいときはChannelを使うのが良いでしょう。
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)
今回はLiveData
の挙動をFlow
で再現する方法について紹介しました。
Flow
は多くのオプションを持つため、LiveData
の挙動をほぼ再現することが可能な一方、気をつけるべき点もいくつかあることがわかりました。
今回はLiveData
の挙動を再現することに重きを置きましたが、無理にLiveData
の動作に合わせようとせず、Flow
のデフォルトの挙動で扱うほうがわかりやすいケースもありそうです。
Kotlin Coroutinesの解説本をZennにて販売しています。より詳しく学びたい方は、こちらも合わせて確認してみて下さい。