Как обрабатывать события в MVI, которые не хранятся в State?
Комментарии (1)
Ответ сгенерирован нейросетью и может содержать ошибки
Отличный и очень важный вопрос, который вскрывает ключевую философскую разницу между 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.