Налаштування архітектури 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: вони не «стани», а «evento». Для них використовуємо 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 тижні. Вартість — після аналізу.







