Настройка архитектуры MVI для Android-приложения
MVI — Model-View-Intent — это не просто эволюция MVVM. Это смена парадигмы: вместо двусторонних привязок данных вы получаете однонаправленный поток, где состояние UI предсказуемо в любой момент времени. Это особенно важно, когда пользователь одновременно тянет список вниз для обновления, нажимает кнопку и приходит push-уведомление — три события, которые MVVM с мутабельными LiveData может обработать в непредсказуемом порядке.
Принципы MVI, которые меняют подход к отладке
Единственный источник истины — UiState. Весь экран описывается одной иммутабельной структурой. Нет isLoading = true в одном месте и showError() в другом — есть UiState.Loading, UiState.Success(data), UiState.Error(message). Текущее состояние экрана — всегда один объект.
Intent — не Android Intent. В MVI это действие пользователя: RefreshIntent, SearchIntent(query), LoadMoreIntent. ViewModel принимает поток Intent-ов и преобразует их в состояния.
Воспроизводимость. Если знаешь начальное состояние и последовательность Intent-ов — можешь точно воспроизвести итоговое состояние. Это делает баг-репорты тестируемыми.
Реализация на Kotlin + Coroutines
data class ProfileUiState(
val isLoading: Boolean = false,
val profile: UserProfile? = null,
val error: String? = null,
val isRefreshing: Boolean = false
)
sealed class ProfileIntent {
data class Load(val userId: String) : ProfileIntent()
object Refresh : ProfileIntent()
data class Follow(val targetId: String) : ProfileIntent()
}
@HiltViewModel
class ProfileViewModel @Inject constructor(
private val getProfile: GetUserProfileUseCase,
private val followUser: FollowUserUseCase
) : ViewModel() {
private val _state = MutableStateFlow(ProfileUiState())
val state: StateFlow<ProfileUiState> = _state.asStateFlow()
fun processIntent(intent: ProfileIntent) {
when (intent) {
is ProfileIntent.Load -> loadProfile(intent.userId)
is ProfileIntent.Refresh -> refreshProfile()
is ProfileIntent.Follow -> followUser(intent.targetId)
}
}
private fun loadProfile(userId: String) {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
getProfile(userId).fold(
onSuccess = { _state.update { s -> s.copy(isLoading = false, profile = it) } },
onFailure = { _state.update { s -> s.copy(isLoading = false, error = it.message) } }
)
}
}
}
В Composable:
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(userId) {
viewModel.processIntent(ProfileIntent.Load(userId))
}
Кнопка отправляет viewModel.processIntent(ProfileIntent.Follow(targetId)) — и никакой прямой мутации UI.
Side Effects: канал для одноразовых событий
StateFlow не подходит для навигации и показа Toast: они не «состояния», а «события». Для них используем Channel или SharedFlow:
private val _effects = Channel<ProfileEffect>(Channel.BUFFERED)
val effects = _effects.receiveAsFlow()
sealed class ProfileEffect {
data class NavigateToEdit(val userId: String) : ProfileEffect()
data class ShowSnackbar(val message: String) : ProfileEffect()
}
В Fragment/Activity подписываемся на effects в lifecycleScope.launch { viewModel.effects.collect { ... } }.
Сравнение с MVVM в контексте сложных экранов
| Характеристика | MVVM | MVI |
|---|---|---|
| Состояние | Несколько StateFlow |
Один UiState |
| Предсказуемость | Зависит от дисциплины | Архитектурно гарантирована |
| Параллельные события | Возможны race conditions | Обрабатываются последовательно |
| Тестируемость | Хорошая | Отличная (Given/When/Then по состояниям) |
| Порог входа | Низкий | Средний |
Для простых CRUD-экранов MVVM достаточен. MVI оправдан при: экранах с несколькими источниками событий, сложных UI-состояниях с несколькими флагами, командах с высокими требованиями к тестовому покрытию.
Orbit MVI — готовый фреймворк
Писать MVI с нуля на каждом проекте — дублирование. Orbit MVI (orbit-mvi) — библиотека от Mobile Native Foundation, которая даёт лаконичный DSL:
class ProfileViewModel : ContainerHost<ProfileUiState, ProfileEffect>, ViewModel() {
override val container = container<ProfileUiState, ProfileEffect>(ProfileUiState())
fun load(userId: String) = intent {
reduce { state.copy(isLoading = true) }
val profile = getProfile(userId).getOrThrow()
reduce { state.copy(isLoading = false, profile = profile) }
}
}
orbit-mvi совместим с Hilt и хорошо тестируется через test { } блок из orbit-testing.
Что входит в настройку
Выбор подхода: ручная реализация или Orbit MVI. Настройка базового контракта UiState/Intent/Effect. Реализация образцового модуля с тестами через turbine + kotlinx-coroutines-test. Документация для команды с примерами обработки edge cases.
Сроки
Настройка MVI с нуля (структура + первый модуль с тестами): 3–5 дней. Миграция MVVM-проекта на MVI: 2–4 недели. Стоимость — после анализа.







