Представьте, перед вами задача — после авторизации пользователя навигировать его на главный экран, или показать snackbar, например, об ошибке.
В Android для таких событий есть специальное название — One‑Time Events, или же просто Effects — события, которые мы отправляем UI из ViewModel и хотим, чтобы они обработались ровно один раз.
И они отличаются от состояния экрана! Состояние существует всегда, а effect отправляется и обрабатывается единожды; в состоянии существуют данные, с которыми user взаимодействует, или которые повторно могут отображаться на экране, когда как effect — совершенно противоположное.
Моделировать такой паттерн можно несколькими способами: через состояние, SharedFlow или Channel. В этой статье мы разберем на простом примере каждый подход и обсудим минусы и плюсы, а также обсудим лучшее обходное решение!
Я создал простой LoginViewModel и два экрана для демонстрации трёх способов реализации, давайте его перед началом разберём:
class LoginViewModel : ViewModel() { private val _isLoading = MutableStateFlow(false) val isLoading = _isLoading.asStateFlow() fun login() { viewModelScope.launch { _isLoading.value = true delay(2000L.milliseconds) _isLoading.value = false } } fun showSnackbar() { /** Событие показа снэкбара */ }}
-
Состояние
isLoadingдля статуса загрузки — приватное для изменения и публичное для подписки в UI. -
Функция
login()сdelayдля имитации запроса к репозиторию для аутентификации. -
Функция
showSnackbar()для отображения снэкбара с текстом ошибки.
class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { HabrTheme { val navController = rememberNavController() NavHost(navController = navController, startDestination = Login) { composable<Login> { val viewModel = viewModel<LoginViewModel>() val isLoading by viewModel.isLoading.collectAsState() var snackbarVisible by remember { mutableStateOf(false) } var snackbarMessage by remember { mutableStateOf("") } LoginScreen( isLoading = isLoading, onLoginClick = viewModel::login, onShowSnackbarClick = viewModel::showSnackbar, snackbarVisible = snackbarVisible, snackbarMessage = snackbarMessage ) } composable<Main> { MainScreen() } } } } }}
-
Простой
NavHostс type‑safe роутами. -
Рендер Login‑экрана: подписка на изменения
isLoading, вызов функции экрана. -
Рендер Main‑экрана: простая функция без особой логики.

На данный момент на экране находится две кнопки: показ снэбара и вход в систему.
Кнопка показа снэкбара ничего не делает, а кнопка входа в систему не навигирует на Main‑экран после двух секунд паузы.
И это понятно!
Мы решили, что эти события — Effects. Но сейчас мы их не создали и не обработали, так что и функционала нет.
Давайте приступим к реализации!
Channel
Channel — это канал событий, которые рассылаются строго одному подписчику и строго единожды. Давайте рассмотрим пример реализации паттерна Effect с помощью Channel.
class LoginViewModel : ViewModel() { private val _isLoading = MutableStateFlow(false) val isLoading = _isLoading.asStateFlow() private val _channelEffect = Channel<LoginEffect>() val channelEffect = _channelEffect.receiveAsFlow() fun login() { viewModelScope.launch { _isLoading.value = true delay(2000L.milliseconds) _channelEffect.send(LoginEffect.NavigateToMain) _isLoading.value = false } } fun showSnackbar() { viewModelScope.launch { _channelEffect.send(LoginEffect.ShowSnackbar("Test Snackbar")) } }}sealed interface LoginEffect { data object NavigateToMain : LoginEffect data class ShowSnackbar(val message: String) : LoginEffect}
-
Создали запечатанный интерфейс
LoginEffectдля разовых событий: здесь у нас событие навигации и событие показа снэкбара. -
Создали приватный объект канала
Channelи его публичную Flow‑версию для подписки в UI. -
Отправляем события в канал с помощью
send()в функцияхlogin()иshowSnackbar().
ViewModel готов! Теперь рассмотрим обработку событий в UI (я покажу только содержимое composable<Login>, так как остальной код не меняется):
composable<Login> { val viewModel = viewModel<LoginViewModel>() val isLoading by viewModel.isLoading.collectAsState() var snackbarVisible by remember { mutableStateOf(false) } var snackbarMessage by remember { mutableStateOf("") } /** Собираем события Channel из потока */ val lifecycleOwner = LocalLifecycleOwner.current LaunchedEffect(lifecycleOwner.lifecycle) { lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.channelEffect.collect { event -> when (event) { LoginEffect.NavigateToMain -> navController.navigate(Main) is LoginEffect.ShowSnackbar -> { snackbarMessage = event.message snackbarVisible = true delay(3.seconds) snackbarVisible = false } } } } } LoginScreen( isLoading = isLoading, onLoginClick = viewModel::login, onShowSnackbarClick = viewModel::showSnackbar, snackbarVisible = snackbarVisible, snackbarMessage = snackbarMessage )}
-
Для обработки effects в Compose используется
LaunchedEffect, с помощью которого мы можем безопасно собирать поток из ViewModel. -
Мы получаем владельца жизненного цикла
lifecycleOwnerи передаём его в качестве ключа вLaunchedEffect, потому что хотим, чтобы блок обработки потока возобновлялся при изменении жизненного цикла. -
repeatOnLifecycleгарантирует, что поток не будет выполняться, когда приложение находится в фоне — поэтому это самый безопасный способ сбора потока событий. КлючLifecycle.State.STARTEDуказывает, что код обработки потока возобновится, когда жизненный цикл станет STARTED. -
Теперь безопасно собираем поток событий из нашего канала, используя
collect. Производим навигацию с помощью нашегоnavController, и показываем снэкбар (временно), изменяя локальное состояние.

Теперь, как вы видите, у нас работает показ снэкбара и навигация после двух секунд ожидания.
Конечно, в данном сценарии вернуться назад из Main в Login кажется нелогичным, однако это сделано для примера того, что backstack работает корректно.
А что будет, если коллектор (сборщик) потока наших событий пропадёт, перестанет слушать канал? Например, пользователь свернул приложение? Здесь кроется важный нюанс.
Channel без аргументов создаёт rendezvous‑канал (с ёмкостью 0) — буфера нет! Когда пропадает коллектор, вызов send() не кладёт событие в буфер, а приостанавливает корутину‑отправителя. Это очень похоже на буфер, но это не он — send терпеливо ждёт, когда появится подписчик, и гарантированно отправляет событие при появлении.
Буфер же давал бы совершенно другое поведение — события «кладутся» в него, и рассылаются подписчику при его появлении.
В Channel можно добавить буфер, указав в качестве аргумента capacity Channel.BUFFERED (дефолт — 64 ёмкость) или Channel.UNLIMITED (без ограничений).
Давайте посмотрим на два этих поведения наглядно, чтобы понять разницу — для этого введём новый effect Counter и будет рассылать 1000 таких событий в UI, в котором будет происходить логирование текущего счетчика. Также дополнительно добавим вывод о переходе приложения в состояние STOP:
class LoginViewModel : ViewModel() { private val _isLoading = MutableStateFlow(false) val isLoading = _isLoading.asStateFlow() private val _channelEffect = Channel<LoginEffect>() // Нет буфера! val channelEffect = _channelEffect.receiveAsFlow() fun login() { viewModelScope.launch { repeat(1000) { delay(3L.milliseconds) _channelEffect.send(LoginEffect.Counter(it)) } } }}sealed interface LoginEffect { data class Counter(val number: Int) : LoginEffect}
class MainActivity : ComponentActivity() { override fun onStop() { super.onStop() println("COUNT STOP") } ...}
LaunchedEffect(lifecycleOwner.lifecycle) { lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.channelEffect.collect { event -> when (event) { ... is LoginEffect.Counter -> { println("COUNT: ${event.number}") } } } }}
Попробуем свернуть приложение без подключенного буфера (Channel<LoginEffect>()):

Мы видим, как счетчик приостановился при сворачивании приложения, а после продолжил идти с того места, где остановился. И это работает так, как ожидается: send() приостанавливает поток, когда подписчик исчез (а он исчез, потому что наш collect() работает в repeatOnLifecycle, который гарантирует выполнение потока только во время работы приложения!).
Давайте попробуем свернуть приложение с подключенным неограниченным буфером (Channel<LoginEffect>(capacity = Channel.UNLIMITED)):

Невероятно! События прилетели всей пачкой! Поведение ожидаемое: пока приложение свёрнуто — подписчика нет, однако корутина в login() не приостановилась — события продолжают отправляться, но в буфер. Когда же коллектор появился, они сразу доставляются ему из буфера.
Однако всё же есть минус — доставка события не гарантированна!
Гарантируется доставка события, если ViewModel продолжает жить, а UI‑сборщик просто приостановлен (например, при сворачивании приложения). Событие НЕ гарантируется, если Activity уничтожается и пересоздаётся заново (например, при повороте экрана), потому что между старым и новым сборщиком образуется окно, в котором send() может выполниться без получателя.
val lifecycleOwner = LocalLifecycleOwner.currentLaunchedEffect(lifecycleOwner.lifecycle) { lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.channelEffect.collect { /** ... */ } }}
repeatOnLifecycle гарантирует, что поток, лежащий внутри, не будет обрабатываться, когда приложение работает в фоне — поэтому мы передаем Lifecycle.State.STARTED, чтобы повторно подписаться на поток, когда приложение снова переёдет в статус STARTED.
Это означает, что есть промежуток времени, когда наш поток не обрабатывается. repeatOnLifecycle(STARTED) отменяет сбор событий, как только жизненный цикл опускается ниже STARTED — то есть уже на onStop. Именно в этот момент сборщик исчезает, а новая Activity со своим сборщиком появится только после onStart. Если ViewModel отправит событие в этом окне между onStop и onStart — оно потеряется, потому что получателя в этот момент нет.
Рассмотрим данную ситуацию на примере (добавив логирование при onDestroy):
class MainActivity : ComponentActivity() { override fun onDestroy() { super.onDestroy() println("COUNT INTERRUPT") }}

Наживаем кнопку Login, запуская счетчик, и меняем ориентацию экрана, вызывая этим окно между onStop и onStart. И вот оно! В логах мы увидели, как в этот момент мы потеряли одно событие.
Шанс этого невелик, но он есть, и именно поэтому некоторые не любят Channel.
Плюсы
-
Взаимодействует только с одним коллектором.
-
События отправляются единожды.
-
Встроенный буфер.
Минусы
-
Не гарантирует доставку события.
SharedFlow
SharedFlow — это «горячий» поток событий для нескольких подписчиков. То есть когда мы отправляем событие в этот поток, оно рассылается всем коллекторам, которые подписаны на поток.
class LoginViewModel : ViewModel() { private val _isLoading = MutableStateFlow(false) val isLoading = _isLoading.asStateFlow() private val _sharedFlowEffect = MutableSharedFlow<LoginEffect>() val sharedFlowEffect = _sharedFlowEffect.asSharedFlow() fun login() { viewModelScope.launch { _isLoading.value = true delay(2000L.milliseconds) _sharedFlowEffect.emit(LoginEffect.NavigateToMain) _isLoading.value = false } } fun showSnackbar() { viewModelScope.launch { _sharedFlowEffect.emit(LoginEffect.ShowSnackbar("Test Snackbar")) } }}sealed interface LoginEffect { data object NavigateToMain : LoginEffect data class ShowSnackbar(val message: String) : LoginEffect}
-
Мы создаём приватный объект
MutableSharedFlow(Mutable — чтобы иметь доступ кemitиtryEmitмтеодам) и публичную неизменяемую версию этого объекта. -
Для отправки события в поток используем
emitметод.
/** Собираем события SharedFlow из потока */val lifecycleOwner = LocalLifecycleOwner.currentLaunchedEffect(lifecycleOwner.lifecycle) { lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.sharedFlowEffect.collect { event -> when (event) { LoginEffect.NavigateToMain -> navController.navigate(Main) is LoginEffect.ShowSnackbar -> { snackbarMessage = event.message snackbarVisible = true delay(3.seconds) snackbarVisible = false } } } }}
-
Меняем только поток, на который подписываемся.

Функционал остался тот же, однако есть одна особенность, которая отмечена в демонстрации слева: после того, как мы свернули приложение и открыли снова, мы остались на Login‑экране!
Почему? Всё потому, что SharedFlow, в отличие от Channel, не имеет встроенного буфера, поэтому отправленное нами событие потерялось.
Но мы можем указать, сколько нужно кэшировать событий, если сборщика нет. Тогда мы также, как и с Channel, обработаем событие при открытии экрана. Вот так:
MutableSharedFlow<LoginEffect>( replay = 4)
Теперь мы добились такого же поведения, как в Channel. Тогда в чем разница?
SharedFlow, как следует из названия — предназначены для нескольких подписчиков на один поток, когда как Channel создан для единственного подписчика, который обычно есть у UI, подписанного на Channel.
Также как и Channel — SharedFlow не гарантирует доставку события. Проведя эксперимент с DESTROYED, вы получите больше потерянных событий, чем при Channel!
Плюсы
-
События отправляются единожды.
Минусы
-
Предназначен для работы с несколькими коллекторами.
-
Ручная настройка кэша буфера (что иногда может вызывать вопросы типа «Какой размер кэша указать мне в repeat в данном сценарии?»).
-
Не гарантирует доставку события.
State
Реализация паттерна Effect через состояние — сам по себе необычный подход. Потому что, как мы говорили в начале, One‑Time Events — это разовые события, в то время как состояние постоянно.
У этого способа есть и плюс, и минус, так что давайте рассмотрим его!
class LoginViewModel : ViewModel() { private val _isLoading = MutableStateFlow(false) val isLoading = _isLoading.asStateFlow() private val _isLoggedIn = MutableStateFlow(false) val isLoggedIn = _isLoggedIn.asStateFlow() private val _snackbar = MutableStateFlow<String?>(null) val snackbar = _snackbar.asStateFlow() fun login() { viewModelScope.launch { _isLoading.value = true delay(2000L.milliseconds) _isLoading.value = false _isLoggedIn.value = true } } fun showSnackbar() { _snackbar.value = "Test snackbar" }}
-
Добавляем два новых состояния:
isLoggedInдля статуса аутентификации пользователя иsnackbarShowedдля статуса показа снэкбара. -
В
login()иshowSnackbar()просто меняем значения — UI подписывается на их изменения.
composable<Login> { val viewModel = viewModel<LoginViewModel>() val isLoading by viewModel.isLoading.collectAsState() val isLoggedIn by viewModel.isLoggedIn.collectAsState() val snackbar by viewModel.snackbar.collectAsState() var snackbarVisible by remember { mutableStateOf(false) } var snackbarMessage by remember { mutableStateOf("") } LaunchedEffect(isLoggedIn, snackbar) { if (isLoggedIn) { // Доп. задержка из-за UI-гонки на моём эмуляторе delay(1000L.milliseconds) navController.navigate(Main) } if (snackbar != null) { snackbarMessage = snackbar!! snackbarVisible = true delay(3.seconds) snackbarVisible = false } } LoginScreen( isLoading = isLoading, onLoginClick = viewModel::login, onShowSnackbarClick = viewModel::showSnackbar, snackbarVisible = snackbarVisible, snackbarMessage = snackbarMessage )}
-
Подписываемся на
viewModel.isLoggedInиviewModel.snackbar. -
Используем
LaunchedEffectдля отслеживания изменений наших состояний. Внутри делаем обычные проверки и делаем те же действия, что и раньше.

Тестируем иии… видим странное поведение. Давайте разберемся по порядку, что не так:
Вспомним, что состояние постоянно. Если оно поменялось — оно останется таким.
В данном случае мы меняем состояния isLoggedIn и snackbar. Нажав на кнопку «Show Snackbar», я поменял состояние snackbar с null на String — и это состояние сохранилось. Именно поэтому мы видим всплывающий снэкбар во время навигации: проверка if (snackbar != null) срабатывает сразу после блока с навигацией.
С навигацией всё также — первая навигация сохраняет isLoggedIn с true, так что когда я пытаюсь вернуться на экран назад по backStack, я возвращаюсь обратно, потому что проверка if (isLoggedIn) истина.
Поэтому когда мы реализуем паттерн Effect с помощью состояния, мы не должны забывать сбрасывать это состояние, во избежание непредвиденного поведения.
fun onLogin() { _isLoggedIn.value = false}fun onShowSnackbar() { _snackbar.value = null}
Добавим две функции для сбрасывания каждого состояния и вызовем их в LaunchedEffect после логик:
LaunchedEffect(isLoggedIn, snackbar) { if (isLoggedIn) { navController.navigate(Main) viewModel.onLogin() // <---- } if (snackbar != null) { snackbarMessage = snackbar!! snackbarVisible = true delay(3.seconds) snackbarVisible = false viewModel.onShowSnackbar() // <---- }}

Вот теперь поведение ожидаемое!
Однако вы сами, наверное, заметили минус — нужно помнить о сбросе состояния.
Плюсы
-
Событие доставится гарантированно!
Минусы
-
Нужно помнить о сбросе состояния.
-
Логически не подходит для One‑Time Events идеологии.
Выводы
Я бы расставил по приоритетам использование SharedFlow, Channel и State для разовых событий следующим образом:
-
Channel — лучший для этой задачи
1. + ИдеологияChannelсовпадает с идеологией Effects — один подписчик, разовая отправка события.
2. + Встроенный буфер.
3. +‑ Не гарантирует доставку события, есть обходной пусть (см. ниже). -
State
1. + Гарантирует доставку события.
2. — Нельзя забывать про сброс состояния.
3. — Лишний код во ViewModel. -
SharedFlow
1. — Идеология не совпадает с Effects — предназначен для нескольких подписчиков.
2. — Кэширование настраивается вручную, что вызывает вопросы.
Обходной путь для Channel
Данное решение кажется трудным для понимания, да и я его сам не сильно понимаю, однако хочу приложить его к этой статье, чтобы вы его использовали в своих целях (возможно, понимание придёт позже):
val lifecycleOwner = LocalLifecycleOwner.currentLaunchedEffect(lifecycleOwner.lifecycle) { lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { withContext(Dispatchers.Main.immediate) { // <---- viewModel.sharedFlow.collect { /** ... */ } } }}
Всего лишь нужно обернуть наш поток в withContext(Dispatchers.Main.immediate) — этон значительно снижает вероятность потери, но не исключает её полностью!
Спасибо за прочтение статьи! Пишите комментарии, буду рад всем.
ссылка на оригинал статьи https://habr.com/ru/articles/1067070/