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) } }
        }
    }
}
MVVMMVI
Состояниенесколько потоководин 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.»

Проверьте себя
1. За счёт какого механизма ViewModel переживает поворот экрана?
Aона сериализуется в Bundle вместе с onSaveInstanceState
Bеё ViewModelStore сохраняется системой как non-configuration instance и передаётся новому экземпляру Activity
Cона хранится в статическом поле внутри библиотеки Lifecycle
DActivity при повороте не пересоздаётся, поэтому ViewModel и не теряется
2. Почему нельзя передавать Activity Context в конструктор ViewModel?
AViewModel запрещает конструкторы с параметрами
BViewModel живёт дольше Activity, поэтому удержит уничтоженный экран — это утечка памяти
CContext нельзя использовать вне главного потока, а ViewModel работает в фоне
Dэто приведёт к ошибке компиляции при использовании Hilt
3. Какое главное преимущество единого immutable state в MVI перед набором отдельных LiveData?
Aон потребляет меньше памяти
Bон позволяет обойтись без ViewModel
Cон исключает противоречивые комбинации состояний и делает поток данных однонаправленным
Dон автоматически сохраняется при смерти процесса