← Назад к вопросам

Как обрабатывать события в MVI, которые не хранятся в State?

1.8 Middle🔥 201 комментариев
#Архитектура и паттерны

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

🐱
deepseek-v3.2PrepBro AI5 апр. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Отличный и очень важный вопрос, который вскрывает ключевую философскую разницу между MVI (Model-View-Intent) и другими архитектурами, такими как MVVM. В классическом MVI-подходе, где состояние (State) — это единственный источник правды для UI, такие события представляют собой настоящую дилемму.

Короткий ответ: События, которые не должны храниться в состоянии, обрабатываются через отдельный однонаправленный поток, часто называемый Side Effect, SingleLiveEvent или просто Event. Их ключевая характеристика — однократность потребления.

Проблема "одноразовых" событий

Давайте представим типичные события, которые не должны быть часть State:

  • Навигация: переход на другой экран.
  • Показ Snackbar/Toast: уведомление, которое должно исчезнуть после показа.
  • Запуск Activity/Fragment (например, для выбора файла).
  • Системные диалоги (вроде разрешений).
  • Вибрация или звуковой сигнал.

Почему их опасно класть в State? Рассмотрим пример с ошибкой:

// ПЛОХОЙ ПРИМЕР: Событие как часть состояния
data class ViewState(
    val data: List<Item> = emptyList(),
    val isLoading: Boolean = false,
    val errorMessage: String? = null // Проблема здесь!
)

// В ViewModel:
fun loadData() {
    _state.value = state.value.copy(isLoading = true)
    viewModelScope.launch {
        repository.fetchData()
            .onSuccess { data ->
                _state.value = state.value.copy(data = data, isLoading = false)
            }
            .onFailure { error ->
                // Сообщение об ошибке теперь постоянно в состоянии!
                _state.value = state.value.copy(errorMessage = error.message, isLoading = false)
            }
    }
}

Проблема: Когда View (Activity/Fragment) воссоздается после поворота экрана, она снова подпишется на State и увидит последнее значение — errorMessage. Это приведет к повторному показу Toast/Snackbar, что неверно с точки зрения UX. Кроме того, если пользователь исправит ошибку, сообщение нужно вручную "очищать" из состояния.

Решение: Поток событий (Event)

Правильное решение — создать отдельный поток для одноразовых событий. Вот основные подходы:

1. Подход с запечатанным классом Event и SingleLiveEvent (или SharedFlow)

Создаем sealed class для событий и отдельный MutableSharedFlow или Channel в ViewModel.

// ViewModel
class MyViewModel : ViewModel() {
    // State как обычно
    private val _state = MutableStateFlow(MyViewState())
    val state: StateFlow<MyViewState> = _state.asStateFlow()

    // Отдельный поток для одноразовых событий
    private val _events = MutableSharedFlow<UiEvent>(replay = 0, extraBufferCapacity = 1)
    val events: SharedFlow<UiEvent> = _events.asSharedFlow()

    sealed class UiEvent {
        data class ShowSnackbar(val message: String) : UiEvent()
        object NavigateToDetails : UiEvent()
        data class ShowDialog(val config: DialogConfig) : UiEvent()
    }

    fun onUserAction() {
        viewModelScope.launch {
            // Обработка логики...
            _state.emit(...) // Обновляем состояние
            // И отправляем событие
            _events.emit(UiEvent.ShowSnackbar("Данные сохранены!"))
        }
    }
}

В View мы подписываемся на оба потока:

// Fragment/Activity
lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        // Подписка на состояние
        viewModel.state.collect { state ->
            renderState(state) // Обычный рендеринг UI
        }
    }
}

lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        // Подписка на события
        viewModel.events.collect { event ->
            handleEvent(event) // Обрабатываем одноразовое событие
        }
    }
}

private fun handleEvent(event: MyViewModel.UiEvent) = when (event) {
    is MyViewModel.UiEvent.ShowSnackbar -> {
        Snackbar.make(binding.root, event.message, Snackbar.LENGTH_SHORT).show()
    }
    is MyViewModel.UiEvent.NavigateToDetails -> {
        findNavController().navigate(R.id.action_to_details)
    }
    // ... обработка других событий
}

2. Более строгий MVI с Reducer и Side Effect

В более каноничном подходе (например, с использованием библиотек вроде Orbit, Moby) используется полный цикл: Intent -> Reduce (новое State) + Side Effect (опционально).

Здесь Side Effect — это и есть наш поток событий. Модель выглядит так:

data class MyState(val data: String = "")
sealed class MyEvent { // Side Effect
    object DataSaved : MyEvent()
}
sealed class MyAction {
    object SaveData : MyAction()
}

class MyViewModel : ViewModel() {
    private val _state = MutableStateFlow(MyState())
    val state: StateFlow<MyState> = _state.asStateFlow()

    private val _events = MutableSharedFlow<MyEvent>()
    val events: SharedFlow<MyEvent> = _events.asSharedFlow()

    fun dispatch(action: MyAction) {
        when (action) {
            is MyAction.SaveData -> saveData()
        }
    }

    private fun saveData() {
        viewModelScope.launch {
            // Редуктор (Reducer) в чистом виде
            _state.emit(state.value.copy(data = "Новые данные"))
            // Побочный эффект (Side Effect)
            _events.emit(MyEvent.DataSaved)
        }
    }
}

Ключевые принципы и выводы

  • Разделение ответственности: State описывает что пользователь видит сейчас (все, что должно пережить конфигурационные изменения). Event описывает что должно произойти один раз как реакция на действие.
  • Однократное потребление: Механизм передачи событий (SharedFlow с replay=0, Channel, LiveData с паттерном SingleLiveEvent) должен гарантировать, что событие будет получено подписчиком только один раз, даже если подписка произойдет заново.
  • Тестируемость: Оба потока — и State, и Event — легко тестируются. В unit-тестах мы можем проверить, что при определенном действии было emitted конкретное событие.
  • Предсказуемость: Архитектура остается строго однонаправленной. Поток данных: Пользовательское действие (Intent/Action) -> ViewModel обновляет State и/или отправляет Event -> View рендерит State и реагирует на Event.

Таким образом, обработка событий, не хранящихся в состоянии, не является слабостью MVI, а, наоборот, демонстрирует его строгость и четкое разделение между постоянным состоянием интерфейса и мгновенными командами для него. Использование отдельного потока Event/Side Effect — это признанный best practice в современной MVI-архитектуре для Android.

Как обрабатывать события в MVI, которые не хранятся в State? | PrepBro