Слышали про 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:
// Критичные — обязаны дойти до 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/