Слышали про 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 после поворота экрана) начинает его собирать, он сразу же получает последнее сохраненное значение.
Например:
Пользователь нажимает кнопку.
_effect.valueстановитсяNavigateToMain.Compose получает событие и делает навигацию.
Получатель поворачивает телефон. Activity пересоздается. Compose заново подписывается на
StateFlow.StateFlow"вспоминает" свое последнее значение (NavigateToMain) и снова отдает его в Compose.Compose снова вызывает
navController.navigate("main").Результат: Двойная навигация, краш приложения (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 элемента.
Пользователь нажимает "Сохранить". ViewModel вызывает
_effect.send(...).Представьте, что в этот момент UI "отписался" (например, Activity ушла в фон /
onStop, или происходит поворот экрана, и старыйLaunchedEffectотменился).Никто не вызывает
receive(), события копятся в буфере.Пока в буфере есть место —
send()отрабатывает мгновенно, ничего страшного не происходит. Но если UI надолго перестаёт слушать (завис, долго в фоне, баг в логике подписки), а ViewModel продолжает слать события — буфер переполняется, и следующийsend()приостанавливает корутину до тех пор, пока кто-то не вызоветreceive().На практике это редкий сценарий (нужно 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() } } }
Как это работает (Почему это идеально):
Нет повторных срабатываний:
replay = 0означает, что при пересоздании Compose не получит старых событий. Навигация сработает ровно один раз.Нет блокировок (Главный плюс): Благодаря
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:
// Критичные — обязаны дойти до UI sealed 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 просто два отдельных LaunchedEffect LaunchedEffect(Unit) { viewModel.criticalEffect.collect { effect -> when (effect) { is CriticalEffect.ShowError -> showErrorDialog(effect.message) } } } LaunchedEffect(Unit) { viewModel.uiEffect.collect { effect -> when (effect) { UiEffect.NavigateToMain -> onNavigateToMain() } } }
Спасибо!
