Одноразовые события в UI (Effect) — StateFlow, Channel или SharedFlow?

от автора

Слышали про Effect — One-Time Events? Когда я о нём узнал — сразу стал использовать этот паттерн в своих ViewModel, дополнительно избавляясь от error: String? и подобных в моём стейте.

Однако я столкнулся с тем, что реализовать паттерн Effect можно по-разному. Давайте кратко обсудим каждый способ и выберем самый лучший для вашего проекта!

StateFlow

Он очень удобен для хранения состояния , а удобен ли он для доставки одноразовых событий?

Давайте посмотрим на пример:

sealed interface MyEffect {    data object NavigateToMain : MyEffect}class MyViewModel : ViewModel() {    private val _effect = MutableStateFlow<MyEffect?>(null)    val effect = _effect.asStateFlow()    fun onNavigateClick() {        viewModelScope.launch {            _effect.value = Effect.NavigateToMain        }    }}// В Compose UI:LaunchedEffect(Unit) {    viewModel.effect.collect { effect ->        if (effect != null) {            when (effect) {                MyEffect.NavigateToMain -> onNavigateToMain()            }        }    }}

Почему бы и нет, правда? Но давайте вспомним, как работает StateFlow. StateFlow всегда хранит одно актуальное значение. Когда новый подписчик (например, Compose после поворота экрана) начинает его собирать, он сразу же получает последнее сохраненное значение.

Например:

  1. Пользователь нажимает кнопку. _effect.value становится NavigateToMain.

  2. Compose получает событие и делает навигацию.

  3. Получатель поворачивает телефон. Activity пересоздается. Compose заново подписывается на StateFlow.

  4. StateFlow «вспоминает» свое последнее значение (NavigateToMain) и снова отдает его в Compose.

  5. Compose снова вызывает navController.navigate("main").

  6. Результат: Двойная навигация, краш приложения (IllegalStateException: Navigation destination already on back stack) или повторный показ Тоста.

В итоге: StateFlow создан для состояния, которое должно быть актуальным в любой момент времени. Для одноразовых событий он не подходит!

Channel

Channel — это буферизированная очередь для передачи данных между корутинами. Когда вы вызываете send(), сообщение кладется в очередь. Когда получатель вызывает receive(), оно забирает сообщение из очереди.

sealed interface MyEffect {    data object NavigateToMain : MyEffect}class MyViewModel : ViewModel() {    private val _effect = Channel<MyEffect>(Channel.BUFFERED)    val effect = _effect.receiveAsFlow()    fun onNavigateClick() {        viewModelScope.launch {            _effect.send(MyEffect.NavigateToMain)        }    }}// В Compose UI:LaunchedEffect(Unit) {    viewModel.effect.collect { effect ->        when (effect) {            MyEffect.NavigateToMain -> onNavigateToMain()        }    }}

Вроде отлично подходит, да? И очередь та, что нам нужна, и синтаксис красивый. Однако есть один подводный камень…

Функция send() является приостанавливающей (suspending), а Channel имеет ограниченный буфер. У Channel.BUFFERED это 64 элемента.

  1. Пользователь нажимает «Сохранить». ViewModel вызывает _effect.send(...).

  2. Представьте, что в этот момент UI «отписался» (например, Activity ушла в фон / onStop, или происходит поворот экрана, и старый LaunchedEffect отменился).

  3. Никто не вызывает receive(), события копятся в буфере.

  4. Пока в буфере есть место — send() отрабатывает мгновенно, ничего страшного не происходит. Но если UI надолго перестаёт слушать (завис, долго в фоне, баг в логике подписки), а ViewModel продолжает слать события — буфер переполняется, и следующий send() приостанавливает корутину до тех пор, пока кто-то не вызовет receive().

  5. На практике это редкий сценарий (нужно 64+ неразобранных события), но сама возможность подвисания ViewModel — это системный риск, которого при неудачном стечении обстоятельств (утечка подписки, баг в UI) лучше избежать.

В итоге: Channel решает проблему повторных срабатываний (событие забирается и исчезает) и гарантированно доставляет каждое событие — ничего не потеряется, даже если подписчика временно нет. Цена за эту гарантию — теоретический риск подвисания корутины при затяжном отсутствии получателя.

Также важный нюанс: используйте receiveAsFlow(), а не consumeAsFlow(). consumeAsFlow() закрывает канал после первого же коллектора — если Compose пересоздастся (поворот экрана) и подпишется заново, вы получите исключение. receiveAsFlow() позволяет подписываться повторно.

SharedFlow — золотая середина?

SharedFlow — это горячий поток, который просто транслирует события всем подписчикам. У него нет «последнего состояния» (если replay = 0), и, что самое важное, его можно настроить так, чтобы отправка событий никогда не блокировалась.

sealed interface MyEffect {    data object NavigateToMain : MyEffect}class MyViewModel : ViewModel() {    private val _effect = MutableSharedFlow<MyEffect>(        // replay = 0 по дефолту        extraBufferCapacity = 1, // Даем 1 "слот" в буфере        onBufferOverflow = BufferOverflow.DROP_OLDEST // Если буфер полон, выкидываем старое    )    val effect = _effect.asSharedFlow()    fun onNavigateClick() {        viewModelScope.launch {            _effect.emit(MyEffect.NavigateToMain)        }    }}// В Compose UI:LaunchedEffect(Unit) {    viewModel.effect.collect { effect ->        when (effect) {            MyEffect.NavigateToMain -> onNavigateToMain()        }    }}

Как это работает (Почему это идеально):

  1. Нет повторных срабатываний: replay = 0 означает, что при пересоздании Compose не получит старых событий. Навигация сработает ровно один раз.

  2. Нет блокировок (Главный плюс): Благодаря extraBufferCapacity = 1 и DROP_OLDEST:

    • Когда ViewModel вызывает emit(), событие кладется в буфер.

    • Функция emit() сразу же завершается, корутина идет дальше. Она не ждет, пока UI заберет событие.

    • Если UI сейчас в фоне и не слушает effect, а ViewModel шлет второе событие, срабатывает DROP_OLDEST: старое событие из буфера удаляется, новое кладется на его место. ViewModel продолжает работать как ни в чем не бывало.

Но есть и обратная сторона. DROP_OLDEST — это не бесплатный обед: если подписчика нет в момент отправки, событие может быть потеряно безвозвратно. Представьте: пользователь свернул приложение в момент, когда ViewModel отправила эффект «Ошибка сохранения, попробуйте снова» — если UI в этот момент не слушает поток, тост просто не покажется, и юзер никогда не узнает, что сохранение не удалось.

Поэтому выбор между Channel и SharedFlow — это не «плохой vs хороший», а осознанный trade-off:

Channel

SharedFlow (extraBufferCapacity + DROP_OLDEST)

Гарантия доставки

Да (пока буфер не переполнен)

Нет, может дропнуть событие

Риск блокировки ViewModel

Теоретически да (при переполнении)

Никогда

Для навигации или разовых уведомлений, которые не критичны (можно пропустить) — SharedFlow действительно предпочтительнее. А вот для событий, которые пользователь обязан увидеть (ошибка платежа, критичное предупреждение) — стоит взять Channel или явно продумать, что буфер не переполнится.

Так вы можете комбинировать подходы в своей ViewModel:

// Критичные — обязаны дойти до UIsealed interface CriticalEffect {    data class ShowError(val message: String) : CriticalEffect}// Некритичные — можно дропнуть, если UI не слушаетsealed interface UiEffect {    data object NavigateToMain : MyEffect}class MyViewModel : ViewModel() {    private val _criticalEffect = Channel<CriticalEffect>(Channel.BUFFERED)    val criticalEffect = _criticalEffect.receiveAsFlow()    private val _uiEffect = MutableSharedFlow<UiEffect>(        extraBufferCapacity = 1,        onBufferOverflow = BufferOverflow.DROP_OLDEST    )    val uiEffect = _uiEffect.asSharedFlow()    fun onSaveClick() {        viewModelScope.launch {            _criticalEffect.send(CriticalEffect.PaymentFailed)        }    }    fun onNavigateClick() {        viewModelScope.launch {            _uiEffect.emit(UiEffect.NavigateToMain)        }    }}// В Compose UI просто два отдельных LaunchedEffectLaunchedEffect(Unit) {    viewModel.criticalEffect.collect { effect ->        when (effect) {            is CriticalEffect.ShowError -> showErrorDialog(effect.message)        }    }}LaunchedEffect(Unit) {    viewModel.uiEffect.collect { effect ->        when (effect) {            UiEffect.NavigateToMain -> onNavigateToMain()        }    }}

Спасибо!

ссылка на оригинал статьи https://habr.com/ru/articles/1065784/