Представьте, перед вами задача — после авторизации пользователя навигировать его на главный экран, или показать 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.current
LaunchedEffect(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.current
LaunchedEffect(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.current
LaunchedEffect(lifecycleOwner.lifecycle) {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
withContext(Dispatchers.Main.immediate) { // <----
viewModel.sharedFlow.collect { /** ... */ }
}
}
}
Всего лишь нужно обернуть наш поток в withContext(Dispatchers.Main.immediate) — этон значительно снижает вероятность потери, но не исключает её полностью!
Спасибо за прочтение статьи! Пишите комментарии, буду рад всем.
Автор: MarkDoroshko
