MVVM против MVI и роль ViewModel
Вопрос про архитектуру редко имеет один правильный ответ — оценивают именно ход рассуждений и умение назвать компромиссы.
MVI (Model-View-Intent) — вариация MVVM, где состояние экрана описано одним неизменяемым объектом, а изменять его можно только через явные намерения пользователя, обработанные редьюсером.
Вопрос 1: «Чем MVI отличается от MVVM и что бы вы выбрали?»
Что проверяет интервьюер
Способность рассуждать про компромиссы. Ответ «MVI лучше, потому что современнее» — минус; ответ «зависит от экрана, и вот почему» — плюс.
Развёрнутый ответ
В MVVM ViewModel обычно выставляет несколько независимых потоков состояния, и View подписывается на каждый:
// MVVM: несколько независимых источников состояния
class ProfileViewModel : ViewModel() {
val user: StateFlow<User?> = ...
val isLoading: StateFlow<Boolean> = ...
val error: StateFlow<String?> = ...
}
Здесь возможны противоречивые комбинации: isLoading = true и одновременно непустой error. Такие «невозможные» состояния и есть источник плавающих UI-багов.
В MVI состояние одно и неизменяемое, а вход — закрытый набор намерений:
data class ProfileState(
val user: User? = null,
val isLoading: Boolean = false,
val error: String? = null
)
sealed interface ProfileIntent {
data object Refresh : ProfileIntent
data class ChangeName(val value: String) : ProfileIntent
}
class ProfileViewModel : ViewModel() {
private val _state = MutableStateFlow(ProfileState())
val state: StateFlow<ProfileState> = _state.asStateFlow()
fun onIntent(intent: ProfileIntent) = when (intent) {
ProfileIntent.Refresh -> refresh()
is ProfileIntent.ChangeName -> rename(intent.value)
}
private fun refresh() {
_state.update { it.copy(isLoading = true, error = null) }
viewModelScope.launch {
runCatching { repo.load() }
.onSuccess { user -> _state.update { it.copy(user = user, isLoading = false) } }
.onFailure { e -> _state.update { it.copy(isLoading = false, error = e.message) } }
}
}
}
| MVVM | MVI | |
| Состояние | несколько потоков | один immutable объект |
| Поток данных | двунаправленный по факту | строго однонаправленный |
| Отладка | сложнее: состояние размазано | проще: можно логировать каждый переход |
| Цена | меньше кода | больше boilerplate, лишние рекомпозиции при неаккуратной модели |
| Где уместен | простые экраны, формы | сложные экраны с множеством состояний |
Честный ответ звучит так: MVI выигрывает там, где состояний много и они взаимозависимы, и особенно хорошо ложится на Compose, который и так перерисовывает UI из состояния. На простом экране «список + прогресс» MVI добавит кода без выгоды.
Вопрос 2: «Как ViewModel переживает поворот экрана?»
Что проверяет интервьюер
Знаете ли механику, а не «фреймворк так умеет». И понимаете ли границы: что ViewModel не переживает.
Развёрнутый ответ
ViewModel хранится в ViewModelStore, который принадлежит владельцу (ViewModelStoreOwner — Activity или Fragment). При смене конфигурации Activity уничтожается, но перед этим система забирает у неё «non-configuration instance» — туда и попадает ViewModelStore. Новый экземпляр Activity получает тот же store и, соответственно, те же ViewModel.
Когда экран закрывается окончательно (isFinishing == true), store очищается и у каждой ViewModel вызывается onCleared(). Именно там отменяется viewModelScope и освобождаются ресурсы.
class SearchViewModel(
private val handle: SavedStateHandle,
private val repo: SearchRepository
) : ViewModel() {
override fun onCleared() {
super.onCleared()
// viewModelScope отменяется автоматически; здесь — прочие ресурсы
}
}
Границы, которые обязательно назвать:
- ViewModel не переживает смерть процесса — для этого нужен
SavedStateHandle. - ViewModel не должна хранить ссылки на View, Activity, Fragment, Context. Это утечка, которая живёт дольше экрана. Если нужен application-контекст, используйте
AndroidViewModelили внедряйте зависимость, а не контекст. - ViewModel не должна знать про Android-фреймворк вообще: это делает её тестируемой обычным JVM-тестом без Robolectric.
- ViewModel, полученная через
activityViewModels(), живёт в store Activity — так фрагменты одного экрана делят состояние.
Вопрос 3: «Как отдавать одноразовые события из ViewModel?»
Классическая ловушка: состояние (StateFlow) переживает пересоздание экрана и повторно доставляется новому подписчику. Для события «показать снекбар» это означает, что после поворота он покажется ещё раз.
// события — не состояние
private val _events = Channel<UiEvent>(Channel.BUFFERED)
val events = _events.receiveAsFlow()
fun onSaveClicked() {
viewModelScope.launch {
repo.save()
_events.send(UiEvent.ShowSnackbar("Сохранено"))
}
}
Варианты ответа, которые считаются верными: Channel + receiveAsFlow() (каждое событие получит ровно один подписчик), SharedFlow с replay = 0, либо — более современный подход — моделировать событие как часть состояния с явным «квитированием»: поле message: String?, которое UI обнуляет через onMessageShown().
Типичные ошибки кандидатов
- «ViewModel переживает всё» — забывают про смерть процесса.
- Передают
Contextв конструктор ViewModel «для строк из ресурсов». Решение — передавать идентификатор ресурса наружу или использовать отдельныйResourceProvider. - Делают ViewModel god-объектом на 800 строк вместо того, чтобы вынести логику в use case и репозиторий.
- Смешивают MVVM и MVI наполовину: единый state-объект есть, но UI меняет его напрямую — однонаправленность потеряна.
Как ответить кратко (20–30 секунд)
«MVVM выставляет несколько независимых потоков состояния, MVI — один неизменяемый объект состояния плюс закрытый набор намерений и редьюсер. MVI даёт строго однонаправленный поток и защищает от невозможных комбинаций вроде «загрузка и ошибка одновременно», но стоит дополнительного кода — на простых экранах не окупается, на сложных и в Compose окупается. ViewModel переживает поворот, потому что её
ViewModelStoreсистема сохраняет как non-configuration instance и отдаёт новому экземпляру Activity; при реальном закрытии вызываетсяonClearedи отменяетсяviewModelScope. Смерть процесса она не переживает — для этогоSavedStateHandle. Ссылок на View и Context в ней не держу, а одноразовые события отдаю через Channel, а не StateFlow.»