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

Спасибо за прочтение статьи! Пишите комментарии, буду рад всем.

Комментарии (0)