- PVSM.RU - https://www.pvsm.ru -
Представьте, перед вами задача — после авторизации пользователя навигировать его на главный экран, или показать 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 — это канал событий, которые рассылаются строго одному подписчику и строго единожды. Давайте рассмотрим пример реализации паттерна 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 — это «горячий» поток событий для нескольких подписчиков. То есть когда мы отправляем событие в этот поток, оно рассылается всем коллекторам, которые подписаны на поток.
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 в данном сценарии?»).
Не гарантирует доставку события.
Реализация паттерна 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. — Кэширование настраивается вручную, что вызывает вопросы.
Данное решение кажется трудным для понимания, да и я его сам не сильно понимаю, однако хочу приложить его к этой статье, чтобы вы его использовали в своих целях (возможно, понимание придёт позже):
val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(lifecycleOwner.lifecycle) {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
withContext(Dispatchers.Main.immediate) { // <----
viewModel.sharedFlow.collect { /** ... */ }
}
}
}
Всего лишь нужно обернуть наш поток в withContext(Dispatchers.Main.immediate) — этон значительно снижает вероятность потери, но не исключает её полностью!
Спасибо за прочтение статьи! Пишите комментарии, буду рад всем.
Автор: MarkDoroshko
Источник [1]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/android/456097
Ссылки в тексте:
[1] Источник: https://habr.com/ru/articles/1067070/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1067070
Нажмите здесь для печати.