Погружение в One‑Time Events в Android

от автора

Представьте, перед вами задача — после авторизации пользователя навигировать его на главный экран, или показать 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/