イベント処理 Channel vs SharedFlow

Luke·
개발추천AndroidKotlin

Androidでは、イベント(サイドエフェクト)は主にChannelまたはSharedFlowを使って処理します。

Channelを使ったイベント処理のサンプルコード

private val _effect: Channel<A> = Channel()
val effect = _effect.receiveAsFlow()

SharedFlowを使ったイベント処理のサンプルコード

private val _sideEffectFlow: MutableSharedFlow<SE>
val sideEffectFlow: SharedFlow<SE> = _sideEffectFlow.asSharedFlow()

では、SharedFlowChannelを使ったイベント処理の違いは何でしょうか?

それぞれの長所と短所について見ていきましょう。

Channel

長所と短所

長所:バックグラウンドで発生したイベントも収集可能

短所:複数のサブスクライバーを持つには不向き

テスト1 - バックグラウンド

MainViewModel

class MainViewModel : ViewModel() {
    private val _channel = Channel<Int>()
    val channel = _channel.receiveAsFlow()

    init {
        viewModelScope.launch {
            repeat(100) {
                Log.d("Channel", "MainViewModel - Send $it")
                _channel.send(it)
                delay(1000)
            }
        }
    }
}

MainActivity

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

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.channel.collect { number ->
                    Log.d("Channel","MainActivity - Collected $number from channel")
                }
            }
        }
        // ...
}

結果

pasted-image-1759713265068.png

  1. MainActivity - onStop(バックグラウンドに移行)

  2. MainViewModel - channel send 7(バックグラウンドでsend)

  3. MainActivity - onStart(フォアグラウンドに復帰)

  4. MainActivity - collect 7(フォアグラウンドでcollect)

バックグラウンドでsendした7を正しくcollectしていることが確認できます。

これが可能な理由は、channelsend()channelのバッファが満杯であるか存在しない場合にsuspendされるためです。(suspending the caller while the buffer of this channel is full or if it does not exist, or throws an exception if the channel is closed for send (see close for details).)

テスト2 - 複数のサブスクライバーがある場合

MainActivity

lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.channel.collect { number ->
                    Log.d("Subscriber","Subscriber[1] - Collected $number from channel")
                }
            }
        }

lifecycleScope.launch {
        repeatOnLifecycle(Lifecycle.State.STARTED) {
            viewModel.channel.collect { number ->
                Log.d("Subscriber","Subscriber[2] - Collected $number from channel")
            }
        }
    }

結果

pasted-image-1759713283873.png

Channelで複数のサブスクライバーがある場合、各サブスクライバーが交互にcollectすることになります。

  1. channel - send 1

  2. subscriber[1] - collect 1

  3. channel - send 2

  4. subscriber[2] - collect 2

公式ドキュメントによると、Channelは公平(fair)であるためです。

したがって、複数のサブスクライバーがある場合、各サブスクライバーが同じイベントを受信しないため、ChannelよりもSharedFlowの方が適しています。

例えば、アプリ全体にtickを送って定期的にアプリのデータをリフレッシュする必要がある場合、Channelを使うのは不適切です。

SharedFlow

長所:複数のサブスクライバーを持つことができる

短所:バックグラウンドで発生したイベントの収集ができない

テスト1 - バックグラウンド

MainActivity

lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.sharedFlow.collect { number ->
            Log.d("SharedFlow","MainActivity - Collected $number from sharedFlow")
        }
    }
}

結果

pasted-image-1759713296336.png

  1. MainActivity - onStop(バックグラウンドに移行)

  2. MainViewModel - sharedFlow emit 8, 9, 10, 11(バックグラウンドでemit)

  3. MainActivity - onStart(フォアグラウンドに復帰)

  4. MainActivity - collect 12(フォアグラウンドでcollect)- 8, 9, 10, 11は消失

バックグラウンドでemitした8, 9, 10, 11が消失していることが確認できます。

テスト2 - 複数のサブスクライバーがある場合

MainActivity

lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.sharedFlow.collect { number ->
                    Log.d("Subscriber","Subscriber[1] - Collected $number from sharedFlow")
                }
            }
        }

        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.sharedFlow.collect { number ->
                    Log.d("Subscriber","Subscriber[2] - Collected $number from sharedFlow")
                }
            }
        }

結果

pasted-image-1759713311261.png

複数のサブスクライバーがあっても、すべてのサブスクライバーが同じイベントをcollectしていることが確認できます。

短所の克服方法

sharedFlowの短所はMVVMのViewModelでイベントを処理する6つの方法で解決できますが... こうすると複数のサブスクライバーを持てなくなります。これはどういうことでしょうか?詳しく見ていきましょう。(その前にMVVMのViewModelでイベントを処理する6つの方法の記事を読んでおきましょう。)

問題発生シナリオ

eventオブジェクトがあり、それをAFragmentBFragmentcollectしていると仮定しましょう。

1.eventemitされると、AFragmentBFragmentcollectされます。(わずかな差でAFragmentが先にcollectしたと仮定)

pasted-image-1759713323502.png

2.このときAFragmenteventconsumedtrueになります。

pasted-image-1759713330677.png

3.その後BFragmenteventcollectされるべきですが、eventはすでにconsumedされているためcollectされません。

pasted-image-1759713335897.png

解決方法

slotStoreというHashMapを作成しました。

slotStorekeyには現在collectしているcollectorの名前とslottoString()値が入ります。 valueにはeventと同じ値を持つ新しいeventが入ります。

先ほどと同様に、eventオブジェクトがあり、それをAFragmentBFragmentcollectしていると仮定します。

1. eventemitされると、AFragmentBFragmentcollectされます。(わずかな差でAFragmentが先にcollectしたと仮定)

2.このときslotStore{ AFragment + event.toString() : Event(event.value) }のような値が保存されます。

pasted-image-1759713348116.png

pasted-image-1759713354736.png

3.その後slotStorekey値がAFragment + event.toString()であるvalueconsumedtrueになります。

pasted-image-1759713367369.png

4.BFragmentでも同じ動作が実行されます。

pasted-image-1759713375343.png

pasted-image-1759713380810.png

5.collect後、BFragmenteventconsumed処理されます。

pasted-image-1759713386527.png

上記の方法を使って、2つ以上のサブスクライバーを持つEventFlowemitされたとき、1つのサブスクライバーだけがcollectする問題を解決しました。 slotStoreを使ったコードはこちらで確認できます。

解決方法 - 改善

Eventオブジェクトが使われなくなってもslotStoreで参照が続くため、GCされない問題がありました。ArrayDequeを使って最大20個までのEventFlowSlotを保存するよう実装しました。最終的なコードは以下の通りです。

private class EventFlowImpl<T>(
    replay: Int
) : MutableEventFlow<T> {

    private val flow: MutableSharedFlow<EventFlowSlot<T>> = MutableSharedFlow(replay = replay)

    private val slotStore: ArrayDeque<Slot<EventFlowSlot<T>>> = ArrayDeque()

    @InternalCoroutinesApi
    override suspend fun collect(collector: FlowCollector<T>) = flow
        .collect { slot ->

            val slotKey = collector.javaClass.name + slot

            if(isContainKey(slotKey)) {
                if(slotStore.size > MAX_CACHE_EVENT_SIZE) slotStore.removeFirst()
                slotStore.addLast(Slot(slotKey, EventFlowSlot(slot.value)))
            }

            val slotValue = slotStore.find { it.key == slotKey }?.value ?: slot

            if (slotValue.markConsumed().not()) {
                collector.emit(slotValue.value)
            }
        }

    override suspend fun emit(value: T) {
        flow.emit(EventFlowSlot(value))
    }

    fun isContainKey(findKey: String): Boolean {
        return slotStore.find { it.key == findKey } == null
    }
}

private data class Slot<T>(
    val key: String,
    val value: T
)

結論

イベント(サイドエフェクト)は通常、一箇所でのみ処理します。

sharedFlowを使ってバックグラウンドで発生したイベントを処理するには、eventFlowを作成する必要があります。eventFlowを作ること自体が追加のコードが必要になるという欠点があります。また、eventFlowを作った時点でsharedFlowの長所(複数のサブスクライバーを持てること)が失われます。これも上記で説明した方法で解決できますが、追加のコードが必要であり、コードの理解が難しくなります。

したがって、イベントは通常一箇所でのみ処理するため、channelを使ってイベントを受信するのが最もコードが少なく、比較的理解しやすい方法です。

必要な場合にのみsharedFlowを使うのが良いと考えます。

参考

[Kotlin] CoroutineのSharedFlowとChannel

StateFlow vs SharedFlowを比較してみよう #イベントハンドリング

MVVMのViewModelでイベントを処理する6つの方法

Luke

Luke

Androidエンジニア

Android イベント処理 Channel vs SharedFlow 比較 - バックグラウンドイベント消失の解決法 | Jinukeu Blog