LoginSignup
11
1

More than 1 year has passed since last update.

AndroidでUIへのイベント通知としてSharedFlowとChannelを上手に使い分ける

Last updated at Posted at 2021-12-04

前置き

AndroidでUIへイベントを通知する方法としては最近LiveDataよりもよくSharedFlowを使う方法が紹介されていると思います。
Kotlin標準の言語機能で実現できるので今後もデファクトスタンダードになっていくと感じています。
シンプルな例ですが、下記のようなコードを書くことになると思います。

Activity
class CounterActivity : AppCompatActivity() {
    private val counterViewModel = // getViewModel()

    override fun onCreate(savedInstanceState: Bundle?) {
        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                counterViewModel.countEvent.collect { count ->
                    ...
                }
            }
        }
    }
}
ViewModel
class CounterViewModel(private val counterRepository: CounterRepository) : ViewModel() {
    private val _countEvent = MutableSharedFlow<Int>()
    val countEvent = _countEvent.asSharedFlow()

    init {
        viewModelScope.launch {
            val count = counterRepository.fetchCount()
            _countEvent.emit(count)
        }
    }
}

しかしながら上記のコードには場合によってはうまく動作しないケースが存在します。

問題点

SharedFlowは購読者の有無に関わらずイベントを即時発火させて内部には状態を保持しない特性があります。(保持するのはStateFlow)
それがゆえに一度だけ発火させる振る舞いには向いているのですが、そのせいでイベントが握りつぶされるケースが少なからずあります。

具体的には、上記コードではViewModelのinit {}内でデータ取得処理を記述していますが、当然ながらこのinit {}onCreate()内のcollect {}を呼ぶより先に実行されます。
上記コードのfetchCount()がAPI通信などを行ってしばらく時間がかかるのであれば特に問題にはならないとは思います。

しかしながら下記のように処理を書き換えたらどうなるでしょうか?

ViewModel
class CounterViewModel() : ViewModel() {
    private val _countEvent = MutableSharedFlow<Int>()
    val countEvent = _countEvent.asSharedFlow()

    init {
        viewModelScope.launch {
            _countEvent.emit(1)
        }
    }
}

init {}内のデータ取得処理が即時完了する上記のようなケースの場合は、collect {}されるより先に_countEvent.emit(1)が実行されてしまいます。
その場合はSharedFlowの購読者はまだ存在してないなか発火してしまい、イベントがそのまま立ち消えるのでその後にcollectされてもイベントをキャッチすることが出来ません。

Channelを使う

このような場合に代わりに使えるのがChannelクラスです。
Channelクラスは特徴として、購読者が存在しない場合は状態を保持して一人目の購読者が現れるまで発火を待ってくれます。
なのでデータ代入よりcollect {}のほうがあとになってもイベントをキャッチすることが出来ます。

使い勝手もreceiveAsFlow()というFlow<T>へ変換できる関数が生えており、下記のようにSharedFlowとほとんど同じように使うことが可能です。

ViewModel
class CounterViewModel() : ViewModel() {
    private val _countEvent = Channel<Int>()
    val countEvent = _countEvent.receiveAsFlow()

    init {
        viewModelScope.launch {
            _countEvent.send(1)
        }
    }
}

LiveData時代のSingleLiveEventと同等の機能というと通じる人も多いかと思います。
余談ですがreceiveAsFlow()とは別にconsumeAsFlow()という関数も生えておりこちらもFlow<T>へ変換できますが、メソッド名の通り一度きりのイベントとなり2回目のイベントを送ろうとするとExceptionが吐かれるのでご注意ください(一度間違えてひどい目に会いました。。)

SharedFlowとChannelの使い分け

ここまでSharedFlowではうまく行かずChannelだと問題ないケースを紹介しました。
ではSharedFlowは使わずに常にChannelを使えばいいのか?とお思いになる方もいるかも知れませんが、必ずしもそうとは限りません。

上記でも紹介したとおり、Channelは一人目の購読者が現れるまで待ってから発火するのが特徴です。
これは逆に言えば、2人以上購読者がいるときに期待通りの動作にならない(=2人目の購読者に通知されない)ことがあります。
このような場合はSharedFlowを使うことで2人ともにそれぞれイベントが適切に通知されます。

一般的にはViewModelはView側を意識せず、Viewからどう使われようとも問題ないように実装するのがセオリーです。
しかしながらView側の購読タイミングや購読者数に応じてSharedFlowやChannelをうまく使い分ける必要があります。
その上で個人的なおすすめとしては、基本的にはSharedFlowで実装しつつタイミング的に通知されない場合のみChannelを利用することを検討するのが良いかと思います。

あるいはそもそもですが、一度きりのイベントではなくなるべく状態として扱いStateFlowを利用できないか積極的に検討していく、というのがよりベターな解法だとも思います。

まとめ

とりあえずSharedFlowとChannelをイベント通知として利用する場合は下記のルールを覚えておいてください。

クラス 複数の購読者に対する挙動 購読タイミングによる影響
SharedFlow 購読者が何人いても全員に通知される collect前に代入されたイベントは消えてしまう
Channel 購読者が複数人いても一人にしか通知されない collect前に代入したイベントは保持されて一人目の購読者に通知される

まずはSharedFlowを使いつつ、動作しないときにChannelを検討する、というのがおすすめ。
あわせて、一度きりのイベントではなく状態として扱ってStateFlowで代用できないかも検討する。

11
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
1