1. Введение: Состояния под контролем. Что делать с событиями?

Представьте, что вы уже навели порядок в управлении состоянием вашего экрана. Вместо россыпи взаимозависимых булевых флагов и nullable-полей вы используете единый sealed interface UiState. Каждое возможное состояние — LoadingSuccessError — чётко определено. Компилятор строго следит за полнотой обработки, а «невозможные» комбинации, которые раньше порождали мистические баги, попросту исключены из кодовой базы. Мир данных стал предсказуемым и типобезопасным. Однако разработка, это движение от одной решённой проблемы к следующей. Как только вы реализуете первую успешную авторизацию или отправку формы, встаёт новый вызов. Ведь помимо того, что пользователь видит (индикатор загрузки, список, ошибку), существуют вещи, которые должны произойти ровно один раз: показать тост, перейти на другой экран, отправить событие в аналитику. И здесь, на границе логики и её проявления, даже в продуманных проектах часто случается откат к старым, несовершенным практикам. Побочные эффекты прорываются через прямые вызовы из ViewModel во View, нарушая разделение ответственности, или прячутся в хрупких обёртках вроде SingleLiveEvent, которые могут терять события при повороте экрана или неожиданно воспроизводить их снова. В тяжёлых случаях логика одноразовых действий застревает в onResume(). Мы создали аккуратный мир для состояний, но оставили хаос на периферии, где живут события. Решение, однако, лежит в той же плоскости. Если компилятор помогает контролировать то, что есть (состояние), почему бы не доверить ему и то, что произошло (событие)? События можно описать так же явно, типизированно и исчерпывающе с помощью sealed interface. Когда они становятся полноценными элементами архитектуры, а не подпольными эффектами, код обретает цельную предсказуемость. В этой статье мы пройдём путь построения такой системы: от объявления событий до их безопасной передачи и гарантированно однократной обработки, без костылей, без страха перед поворотом экрана и без компромиссов в чистоте архитектуры.

2. Философская основа: Почему Event - это не State

Приведя в порядок состояния экрана, мы закономерно задаёмся следующим вопросом: куда отнести всё остальное: переходы между экранами, всплывающие уведомления, запросы разрешений? Интуитивно мы понимаем, что это не совсем то же самое, что список данных или сообщение об ошибке. И это ощущение верно. Событие (Event) и состояние (State) различаются не просто синтаксически, а концептуально, и игнорирование этого различия неизбежно приводит к регрессиям, сводящим на нет всю пользу от типобезопасного моделирования.

Состояние - это то, что есть. Оно описывает текущую картину мира: загружается ли контент, получены ли данные, произошла ли ошибка. Состояние можно читать многократно, и каждый раз оно возвращает одну и ту же информацию. Более того, оно должно сохраняться при повороте экрана, потому что пользователь не должен терять контекст из-за простого изменения ориентации устройства. Всё, что видит пользователь, - это прямая проекция состояния.

Событие же - это то, что произошло. Это сигнал о необходимости выполнить одноразовое действие: показать тост, перейти на другой экран, отправить событие в аналитику. Ключевое свойство события - его единичность. Оно должно быть обработано ровно один раз и после этого забыто. Если после поворота устройства пользователь снова видит уведомление «Платёж успешно проведён» или неожиданно оказывается на другом экране - это не просто странность, а явная ошибка, вызванная тем, что событие было ошибочно представлено как часть состояния.

Именно так возникают классические баги. Система восстанавливает ViewModel после поворота, пересоздаёт состояние - и вместе с ним невольно повторяет побочный эффект, который был к нему привязан. Тестирование подобной логики превращается в сложную задачу: чтобы проверить, что при успешной операции происходит навигация, приходится создавать моки фрагментов, перехватывать вызовы навигационных контроллеров или полагаться на хрупкие проверки значений в LiveData. Код при этом обрастает паутиной интерфейсов-колбэков вроде OnLoginSuccessListener или NavigationDelegate, которые разрывают логику на части и жёстко привязывают её к конкретной реализации View. Чёткое разделение состояний и событий дает практический метод устранения целого класса проблем. Оно позволяет развести данные и действия по разным уровням ответственности: первые отвечают за отображение, вторые - за реакцию на произошедшее. Для состояний самый надёжный способ закрепить архитектурное решение - это доверить контроль над его целостностью компилятору. Этот же принцип прекрасно работает и для событий.

3. Архитектурный паттерн: Пары sealed-типов как полноценный контракт экрана

Чётко разделив состояния и события концептуально, мы подходим к воплощению этого разделения в архитектуре. Наиболее органично так��й подход реализуется в модифицированной версии MVVM, которая заимствует ключевую идею из MVI: UI не принимает решений, а только реагирует. Вся бизнес-логика сосредоточена в ViewModel, становящейся единственным источником правды, а View выступает пассивным потребителем, который лишь отображает состояние и исполняет команды. В этой модели каждый экран получает явно выраженный контракт, то есть полное и исчерпывающее описание всего, что с ним может происходить. Именно здесь sealed-типы раскрывают весь свой потенциал, становясь языком такого контракта. Мы определяем две параллельные иерархии:

  • sealed interface ScreenState описывает всё, что пользователь видит. Пустой экран загрузки, список данных, сообщение об ошибке. Каждое из этих представлений является конкретным вариантом данного интерфейса. Состояние пассивно: оно просто существует, отражая текущую реальность интерфейса.

  • sealed interface ScreenEvent описывает всё, что должно произойти ровно один раз. Показать уведомление, перейти на другой экран, запросить разрешение. Каждый подобный побочный эффект становится отдельным вариантом события. Событие активно: оно не отображается, а требует немедленной реакции.

Вся коммуникация между ViewModel и View сводится к двум потокам данных: один передаёт состояние, другой - события. Это устраняет необходимость в строковых идентификаторах для навигации, в обёртках над Any?, в ручных проверках типов и рефлексии. Более того, компилятор Kotlin берёт под контроль обе стороны контракта: конструкция when гарантирует, что вы обработали все возможные состояния и все возможные события. Добавив новый тип события, вы сразу увидите ошибки компиляции во всех местах, где его необходимо учесть, точно так же, как это было с состояниями. Такой подход дает решение реальных проблем. Он делает код проще для чтения и понимания, поведение системы - предсказуемым, а целые классы ошибок - невозможными на этапе компиляции. В результате архитектура перестаёт быть набором слабых соглашений и превращается в строгую систему, которая сама защищает себя от регрессий и недопустимых состояний.

4. Практическая реализация: от объявления до потребления

Теория обретает ценность только тогда, когда превращается в рабочий код. Давайте рассмотрим, как описанный подход выглядит на практике - от первого объявления контракта до его полной обработки в UI. В качестве примера возьмём типичный экран авторизации, где есть и состояния (ожидание, загрузка, ошибка), и одноразовые действия (навигация, уведомления, переход к двухфакторной аутентификации).

4.1. Объявляем контракт

Начнём с самого фундамента. Опишем то, что может происходить на экране. Выделим две независимые иерархии:

// STATE: Что видит пользователь?
sealed interface LoginState {
    object Idle : LoginState
    object Loading : LoginState
    data class Error(val message: String) : LoginState
    object Success : LoginState // Факт успешного входа
}

// EVENT: Что должно произойти один раз?
sealed interface LoginEvent {
    data class ShowToast(val text: String) : LoginEvent
    object NavigateToHome : LoginEvent
    data class OpenTwoFactorScreen(val sessionId: String) : LoginEvent
}

Здесь LoginState описывает всё, что пользователь может наблюдать: пустой экран, индикатор загрузки, сообщение об ошибке или состояние успешного входа. Важно понимать, что даже успех авторизации - это состояние, а не событие. LoginEvent же описывает исключительно побочные эффекты, которые должны быть выполнены однократно: показать уведомление, перейти на главный экран или запустить дополнительный этап аутентификации. Такое разделение закладывает основу предсказуемости.

4.2. Реализуем логику в ViewModel

Теперь передадим контроль над этими двумя потоками в ViewModel. Для состояния используем StateFlow так как оно должно быть всегда доступно и сохранять последнее значение. Для событий - SharedFlow с параметром replay = 0 по умолчанию, что гарантирует однократную доставку.

class LoginViewModel(
    private val authRepository: AuthRepository
) : ViewModel() {

    private val _state = MutableStateFlow<LoginState>(LoginState.Idle)
    val state: StateFlow<LoginState> = _state.asStateFlow()

    // replay = 0 - новые подписчики не получат старые события
    // extraBufferCapacity = 0 - если нет подписчиков, событие теряется
    private val _events = MutableSharedFlow<LoginEvent>()
    val events: SharedFlow<LoginEvent> = _events.asSharedFlow()

    fun onLoginClicked(username: String, password: String) {
        viewModelScope.launch {
            _state.value = LoginState.Loading
            
            // Имитация сетевого запроса
            delay(1000) // В реальном коде здесь будет вызов authRepository.login()
            
            // Пример логики обработки результата
            val result = when {
                username.isEmpty() || password.isEmpty() -> 
                    AuthResult.Error("Заполните все поля")
                username == "admin" && password == "1234" -> 
                    AuthResult.Success("token_123")
                username == "2fa" && password == "test" -> 
                    AuthResult.TwoFactorRequired("session_456")
                else -> 
                    AuthResult.Error("Неверные credentials")
            }
            
            _state.value = when (result) {
                is AuthResult.Success -> {
                    // Успех - это состояние, но навигация - событие
                    _events.emit(LoginEvent.NavigateToHome)
                    LoginState.Success
                }
                is AuthResult.TwoFactorRequired -> {
                    // Требуется 2FA - событие для перехода
                    _events.emit(LoginEvent.OpenTwoFactorScreen(result.sessionId))
                    LoginState.Idle
                }
                is AuthResult.Error -> {
                    // Ошибка - состояние, но показ тоста - событие
                    _events.emit(LoginEvent.ShowToast(result.message))
                    LoginState.Error(result.message)
                }
            }
        }
    }
}

// Вспомогательные классы для результата авторизации
sealed interface AuthResult {
    data class Success(val token: String) : AuthResult
    data class TwoFactorRequired(val sessionId: String) : AuthResult
    data class Error(val message: String) : AuthResult
}

Важное замечание про SharedFlow. По умолчанию MutableSharedFlow() имеет replay = 0 и extraBufferCapacity = 0. Это значит, что новые подписчики не получат старые события, и что если событие эмитится, но нет активных коллекторов, то оно теряется. Для большинства сценариев UI это корректное поведение. Если нужно гарантировать доставку события даже при временном отсутствии подписчика, можно установить extraBufferCapacity = 1, но тогда нужно быть готовым к тому, что при быстрой эмиссии нескольких событий часть может быть потеряна (перезаписана).

4.3. Обрабатываем в Fragment

Переходим к заключительному шагу, безопасному потреблению обоих потоков во View. Подписка на состояние и события выглядит почти одинаково, но с важным различием в семантике

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)
    
    setupViews()
    
    // Подписываемся на состояние
    lifecycleScope.launch {
        repeatOnLifecycle(Lifecycle.State.STARTED) {
            viewModel.state.collect { state ->
                renderState(state) // Обрабатываем состояние
            }
        }
    }

    // Подписываемся на события
    lifecycleScope.launch {
        repeatOnLifecycle(Lifecycle.State.STARTED) {
            viewModel.events.collect { event ->
                when (event) {
                    is LoginEvent.ShowToast -> 
                        Toast.makeText(context, event.text, Toast.LENGTH_SHORT).show()
                    LoginEvent.NavigateToHome -> 
                        findNavController().navigate(R.id.homeFragment)
                    is LoginEvent.OpenTwoFactorScreen -> 
                        openTwoFactorScreen(event.sessionId)
                }
                // Компилятор гарантирует обработку всех случаев!
            }
        }
    }
}

private fun renderState(state: LoginState) {
    when (state) {
        LoginState.Idle -> {
            binding.progressBar.visibility = View.GONE
            binding.errorText.visibility = View.GONE
            binding.loginButton.isEnabled = true
        }
        LoginState.Loading -> {
            binding.progressBar.visibility = View.VISIBLE
            binding.errorText.visibility = View.GONE
            binding.loginButton.isEnabled = false
        }
        is LoginState.Error -> {
            binding.progressBar.visibility = View.GONE
            binding.errorText.visibility = View.VISIBLE
            binding.errorText.text = state.message
            binding.loginButton.isEnabled = true
        }
        LoginState.Success -> {
            binding.progressBar.visibility = View.GONE
            binding.errorText.visibility = View.GONE
            binding.loginButton.isEnabled = false
            // Можно показать анимацию успеха
        }
    }
}

private fun openTwoFactorScreen(sessionId: String) {
    val direction = LoginFragmentDirections.actionLoginFragmentToTwoFactorFragment(sessionId)
    findNavController().navigate(direction)
}

4.4. Почему это работает?

repeatOnLifecycle(Lifecycle.State.STARTED) - это современный и безопасный способ подписки на Flow в Android. Он гарантирует, что коллектор работает только когда UI находится в состоянии STARTED или RESUMED, что предотвращает утечки памяти и обращения к уничтоженным View.

SharedFlow �� replay = 0 гарантирует, что новые подписчики не получат старые события, что исключает повторную навигацию после поворота экрана.

sealed interface + exhaustive when даёт compile-time гарантии полноты обработки. Добавив новый вариант события, вы не сможете собрать проект, пока не обработаете его во всех местах.

Разделение State и Event делает код тестируемым. Вы можете легко протестировать ViewModel, проверяя какие состояния и события эмитятся в ответ на действия.

Сейчас мы создали систему, в которой ошибки вроде повторной навигации после поворота или забытых уведомлений становятся невозможными в силу самой её конструкции. Каждая строчка кода работает на предсказуемость, тестируемость и защиту от регрессий.

5. Выгоды, которые вы почувствуете сразу

Как только вы внедрите описанный подход в реальный экран, его преимущества станут очевидны с первых же запусков. Это не теоретические выгоды «где-то в будущем», а конкретные улучшения, которые сразу меняют качество кода и уменьшают количество багов.

5.1. Типобезопасность и контроль на этапе компиляции

Вы получаете для событий тот же уровень строгости и надёжности, который уже оценили при работе с состояниями. Конструкция when над sealed interface остаётся exhaustive и компилятор требует обработать все возможные варианты: ShowToast, NavigateToHome, OpenTwoFactorScreen и любые другие. Это не просто удобство автодополнения, а гарантия, что ни один побочный эффект не останется без реализации. Когда через месяц вы добавите новое событие ShowSnackbar, проект просто не соберётся, пока вы не ��бработаете его во всех when-выражениях. Это страховка от забывчивости, которая раньше оборачивалась тихими багами.

5.2. Конец багам с конфигурационными изменениями

Проблема дублирующихся тостов и повторной навигации после поворота экрана исчезает на архитектурном уровне. SharedFlow с replay = 0 не хранит историю событий, поэтому новые подписчики (например, заново созданный Fragment после поворота) не получают события, которые уже были обработаны. При этом, чтобы избежать потери событий в момент, когда подписчик ещё не готов (например, при быстром последовательном вызове), мы используем небольшой буфер (extraBufferCapacity = 1). Это гарантирует доставку, но не создаёт риска повторного воспроизведения. Пользователь увидит уведомление ровно один раз, навигация сработает однократно, и никакие «призрачные» переходы не нарушат его ожидания. Это не костыль вроде SingleLiveEvent или проверок флагов в onResume(), а свойство, зашитое в саму архитектуру.

5.3. Идеальная тестируемость

Поскольку события теперь представляют собой обычные данные, передаваемые через SharedFlow, их можно легко перехватить и проверить в unit-тестах. Вам больше не нужны моки View, NavController или проверки вызовов через Mockito.

Для удобства можно создать небольшую функцию-расширение, которая собирает события

@Test
fun `on successful login emits NavigateToHome event`() = runTest {
    // Given
    val viewModel = LoginViewModel(FakeAuthRepository())
    
    // When
    viewModel.onLoginClicked("user", "password")
    
    // Then - проверяем, что было эмиттировано нужное событие
    val events = mutableListOf<LoginEvent>()
    val job = launch {
        viewModel.events.collect { events.add(it) }
    }
    
    // Ждём завершения корутины в ViewModel
    advanceUntilIdle()
    
    assertEquals(1, events.size)
    assertIs<LoginEvent.NavigateToHome>(events.first())
    
    job.cancel()
}

@Test  
fun `on empty password shows error toast`() = runTest {
    val viewModel = LoginViewModel(FakeAuthRepository())
    
    viewModel.onLoginClicked("user", "")
    
    val events = mutableListOf<LoginEvent>()
    val job = launch {
        viewModel.events.collect { events.add(it) }
    }
    advanceUntilIdle()
    
    assertIs<LoginEvent.ShowToast>(events.first())
    assertEquals("Заполните все поля", (events.first() as LoginEvent.ShowToast).text)
    
    job.cancel()
}

Такие тесты читаются как спецификация. Они ясно говорят, что должно произойти при определённых условиях. Никакой магии, никаких зависимостей от Android-фреймворка, только чистая логика, которую легко проверить.

5.4. Чистый и декларативный UI-слой

Ваш Fragment или Composable-функция преображаются, становясь удивительно простыми и сосредоточенными исключительно на отображении интерфейса. Исчезает необходимость в разрозненных интерфейсах-колбэках, передаче ссылок на контроллеры во ViewModel или костылях с findViewById. Вся логика, связанная с одноразовыми действиями, наконец-то покидает методы жизненного цикла, где ей никогда не было места.

Вместо этого остаются две чёткие ответственности: подписка на состояние для отображения UI и подписка на события для выполнения одноразовых действий. Такой подход не только делает код проще для чтения и понимания, но и создаёт устойчивость к рефакторингу. Вы можете менять способ навигации, полностью переделывать систему уведомлений или добавлять новые механизмы аналитики, внося изменения только в одном месте - в обработчике событий во View. При этом логика ViewModel остаётся нетронутой и независимой.

5.5. Масштабируемость и поддержка

Подход естественным образом масштабируется на сложные экраны. Когда у вас появляется несколько независимых блоков с разными состояниями, вы просто создаёте отдельные sealed-иерархии для каждого. Когда нужно добавить новое одноразовое действие (например, запрос разрешения или отправку аналитики), вы просто добавляете новый вариант в sealed interface, и система сама подскажет, где его обработать.

Все эти преимущества проявляются буквально с первого же экрана, где вы замените хаотичные вызовы и SingleLiveEvent на пару sealed interface и правильно настроенный SharedFlow. Это тот редкий случай, когда более строгая архитектура не только предотвращает баги, но и меняет само отношение к побочным эффектам. Из источника хаотичных багов они превращаются в контролируемую, типобезопасную часть системы, которую легко понимать, тестировать и развивать.

6. Когда не стоит усложнять?

Любое архитектурное решение, каким бы продуманным оно ни казалось, должно служить цели, а не становиться самоцелью. Подход с парой sealed-интерфейсов для состояний и событий - мощный инструмент, но он не требует применения на каждом экране подряд. Его ценность раскрывается там, где возникает реальная сложность: побочные эффекты, одноразовые действия, нестабильные взаимодействия между слоями. В остальных случаях он лишь добавляет шаблонный код без пользы.

Возьмём простой экран с деталями профиля: имя, аватар, дата регистрации - и никакой интерактивности. Нет навигации, уведомлений, запросов разрешений. Здесь достаточно одного UiState - возможно, даже обычного data class - и вводить отдельную иерархию событий будет излишеством. То же справедливо для статических заставок, простых диалогов или экранов-справочников. Главный ориентир - наличие боли. Спросите себя: используете ли вы SingleLiveEvent, оборачиваете события в EventWrapper, ловите дублирующиеся тосты после поворота или видите в коде паутину колбэков вроде OnCompleteListener? Если да, значит вы уже столкнулись с проблемой, которую этот паттерн решает напрямую.

Ещё раз, цель здесь - не теоретическая чистота, а практическое облегчение. Мы боремся с конкретными багами: повторной навигацией, потерянными командами, хрупкими зависимостями между ViewModel и View. Если таких проблем нет, то не нужно и решения. Но как только они появляются, лучше не маскировать их очередным костылём, а перевести хаос в управляемую, типобезопасную плоскость. Именно в этом и заключается разумный подход: использовать мощные инструменты не повсеместно, а именно там, где они приносят ощутимую пользу.

Заключение: Следующий уровень контроля

Объединив sealed interface для состояния с sealed interface для событий, мы получаем не просто два типа данных, а полную, типобезопасную модель экрана. Эта модель описывает всё, что может на нём происходить: от того, что пользователь видит в каждый момент времени, до того, что система должна выполнить ровно один раз. Она исключает неопределённость, устраняет целый класс багов и делает поведение приложения предсказуемым не за счёт комментариев в коде или соглашений, которые легко нару��ить, а за счёт самой структуры, проверяемой компилятором.

Такой подход естественным образом ложится в основу современных архитектурных парадигм. Он становится отличной отправной точкой для MVI (Model-View-Intent), легко интегрируется в Redux-подобные схемы управления состоянием и идеально сочетается с реактивным UI, особенно в Jetpack Compose, где декларативный подход и чёткое разделение данных и эффектов особенно ценны. При этом он не требует полного переписывания приложения - его можно внедрять постепенно, начиная с одного самого проблемного экрана. Именно в этом его главная сила: подход масштабируется от отдельного фрагмента до всей кодовой базы, не теряя простоты и ясности. Вы не добавляете сложности, а переводите хаотичные взаимодействия в строгую, контролируемую плоскость.

Попробуйте этот подход на практике. Возьмите любой экран, где вы уже используете sealed class или sealed interface для моделирования состояний, и выделите из него побочные эффекты в отдельную иерархию событий. Вы удивитесь, насколько много скрытой логики окажется упорядоченной, и как быстро исчезнут старые, надоевшие баги вроде двойных тостов или случайных переходов. Это тот редкий случай, когда улучшение архитектуры приносит мгновенное, ощутимое облегчение в повседневной разработке.

Если же вы только начинаете свой путь к надёжной архитектуре, рекомендуем сначала освоить моделирование состояний. Подробное руководство вы найдёте в статье: «Sealed Class в Kotlin для Android: от багов к надёжной архитектуре».