Почему традиционная модель UI‑состояния приводит к ошибкам
В Android‑приложениях UI часто управляется единственным объектом, который одновременно хранит «текущее состояние» экрана и «одноразовые» действия: навигацию, всплывающие сообщения, запросы к сети. При изменении конфигурации (например, поворот экрана) такие объекты могут быть пересозданы, а события — выполнены несколько раз. Это приводит к багам, трудно читаемому коду и проблемам при тестировании. Кроме того, отсутствие строгой типизации делает добавление новых действий рискованным: легко забыть обработать кейс или случайно вызвать событие в неподходящем месте.
Разделение состояния и событий с помощью sealed‑классов
Kotlin предоставляет sealed‑классы (и sealed‑интерфейсы), которые позволяют описать закрытый набор типов. Их использование в качестве модели экрана решает две задачи одновременно:
- State – неизменяемый снимок UI, который отражает всё, что должно быть отрисовано в любой момент. Он хранит данные, флаги загрузки, выбранные элементы и т. д.
- Event – одноразовые команды, которые должны быть выполнены только один раз: открытие нового экрана, показ
Toast, запрос разрешения и т.п.
Определив два отдельные sealed‑типа, мы получаем компиляторную проверку на полноту when‑выражений, гарантируя, что каждый новый статус или событие будет явно обработан.
sealed interface ScreenState {
object Loading : ScreenState
data class Content(val items: List<Item>) : ScreenState
data class Error(val message: String) : ScreenState
}
sealed interface ScreenEvent {
data class Navigate(val destination: Destination) : ScreenEvent
data class ShowToast(val text: String) : ScreenEvent
object Refresh : ScreenEvent
}
Такой подход делает модель предсказуемой: UI‑слой подписывается только на State, а события «прокручиваются» через отдельный поток.
Реализация в ViewModel: StateFlow и канал событий
ViewModel остаётся центральным элементом, но теперь он управляет двумя потоками:
StateFlow<ScreenState>– холодный поток, который всегда содержит актуальное состояние. UI‑компонент (Fragment/Compose) собирает его черезcollectAsStateи реагирует на изменения.Channel<ScreenEvent>(илиSharedFlow) – горячий поток, из которого UI получает одноразовые команды. После обработки событие удаляется из канала, что исключает повторное выполнение при пересоздании.
class MainViewModel : ViewModel() {
private val _state = MutableStateFlow<ScreenState>(ScreenState.Loading)
val state: StateFlow<ScreenState> = _state.asStateFlow()
private val _event = Channel<ScreenEvent>(Channel.BUFFERED)
val event = _event.receiveAsFlow()
fun loadData() {
viewModelScope.launch {
try {
val data = repository.getItems()
_state.value = ScreenState.Content(data)
} catch (e: Exception) {
_state.value = ScreenState.Error(e.localizedMessage)
}
}
}
fun onItemClicked(itemId: String) {
viewModelScope.launch {
_event.send(ScreenEvent.Navigate(Destination.Detail(itemId)))
}
}
fun onRefreshRequested() {
viewModelScope.launch {
_event.send(ScreenEvent.Refresh)
loadData()
}
}
}
Ключевой момент – StateFlow всегда хранит последнее значение, поэтому при развороте экрана UI сразу получает актуальное состояние без дополнительных запросов. Канал событий, в свою очередь, гарантирует, что Navigate или ShowToast произойдёт только один раз.
Обработка одноразовых эффектов
В традиционных MVVM‑реализациях часто используют LiveData<Event<T>>‑обёртки, которые требуют дополнительного кода для «поглощения» события. Sealed‑классы в сочетании с Channel избавляют от этой лишней сложности. UI‑слой выглядит лаконично:
@Composable
fun MainScreen(viewModel: MainViewModel) {
val state by viewModel.state.collectAsState()
val scaffoldState = rememberScaffoldState()
LaunchedEffect(Unit) {
viewModel.event.collect { event ->
when (event) {
is ScreenEvent.Navigate -> navigate(event.destination)
is ScreenEvent.ShowToast -> scaffoldState.snackbarHostState.showSnackbar(event.text)
ScreenEvent.Refresh -> viewModel.loadData()
}
}
}
// UI‑разметка, реагирующая только на `state`
when (state) {
is ScreenState.Loading -> LoadingIndicator()
is ScreenState.Content -> ContentList((state as ScreenState.Content).items)
is ScreenState.Error -> ErrorScreen((state as ScreenState.Error).message)
}
}
LaunchedEffect собирает поток событий один раз, а when гарантирует, что каждый тип будет обработан. При повороте экрана LaunchedEffect перезапускается, но канал уже пуст, поэтому события не повторятся.
Тестирование и масштабирование
Разделение состояний и событий упрощает юнит‑тесты. Тестировать ViewModel теперь можно без UI‑зависимостей:
@Test
fun `loadData success updates state to Content`() = runTest {
// Arrange
val repo = FakeRepository(success = true)
val vm = MainViewModel(repo)
// Act
vm.loadData()
advanceUntilIdle()
// Assert
assertTrue(vm.state.value is ScreenState.Content)
}
Проверка одноразовых команд делается через канал:
@Test
fun `onItemClicked sends Navigate event`() = runTest {
val vm = MainViewModel(FakeRepository())
vm.onItemClicked("123")
val event = vm.event.first()
assertTrue(event is ScreenEvent.Navigate && event.destination.id == "123")
}
При добавлении нового события (например, ShowDialog) достаточно расширить ScreenEvent и добавить ветку в when. Компилятор сразу укажет, где требуется обработка, что предотвращает «слепые» места в коде.
Практические рекомендации
- Сохраняйте иммутабельность –
Stateвсегда должен быть неизменяемым объектом. Это упрощает сравнение и диффинг в UI‑слоях. - Не смешивайте типы – события, которые могут произойти несколько раз (например, запрос данных), лучше реализовать как отдельный
Event, а не как изменениеState. - Выбирайте подходящий поток –
StateFlowподходит для постоянных состояний,SharedFlowсreplay = 0илиChannel– для одноразовых команд. - Обрабатывайте ошибки в
State– вместо того чтобы бросать исключения в UI, переводите их вScreenState.Error. Это делает UI‑слой полностью реактивным. - Не забывайте о тестах – покрывайте как изменения
State, так и отправкуEvent. Это гарантирует, что новые кейсы не нарушат существующую логику.
Разделяя состояние и события с помощью sealed‑классов, мы получаем типобезопасную, предсказуемую и легко масштабируемую архитектуру экранов Android. Такой подход уменьшает количество багов, упрощает поддержку и делает процесс тестирования более прозрачным, что особенно важно в проектах с интенсивным ростом функционала.