MVI (Model-View-Intent) — реактивный архитектурный паттерн, основанный на однонаправленном потоке данных и неизменяемом состоянии. В отличие от MVVM, где ViewModel может иметь множество StateFlow, MVI определяет единое состояние (State), неизменяемые намерения (Intent) и чистую функцию-редьюсер (Reducer). MVI гарантирует предсказуемость состояния экрана в любой момент времени. Паттерн популяризован в Android-сообществе библиотеками Mosby и Orbit. Подробнее — в MVIKotlin от Arkadii Ivanov.
Главное
MVI (Model-View-Intent) — реактивный архитектурный паттерн, построенный на принципах Redux и Cycle.js. Model — неизменяемое состояние экрана, Intent — намерение пользователя или системы, View — подписка на состояние и отправка Intent. Данные движутся по циклу: пользователь взаимодействует с View → View создаёт Intent → Intent обрабатывается Reducer → Reducer создаёт новое состояние → View получает новое состояние и перерисовывается.
Главное отличие MVI от MVVM — единственный источник истины (Single Source of Truth). В MVVM ViewModel может иметь несколько LiveData/StateFlow (userState, loadingState, errorState), что ведёт к несогласованности: loading=true и user=null одновременно. В MVI существует ровно один sealed class/interface State, описывающий всё состояние экрана. В любой момент времени состояние экрана однозначно определено — невозможно получить loading=true при уже загруженных данных. В IT Sectr мы применяем MVI для экранов со сложной логикой — формы оформления заказа, многошаговые регистрации, финансовые экраны — где предсказуемость состояния критична.
| Компонент | Роль в MVI | Пример |
|---|---|---|
| Intent | Намерение пользователя или системы | LoadUser, Refresh, SubmitForm |
| State | Неизменяемое состояние экрана | sealed class UserState |
| Reducer | Чистая функция: State + Intent → State | fun reduce(state, intent) -> state |
| Middleware | Обработка side effects | Сетевой запрос, запись в БД |
Цикл MVI состоит из пяти шагов: 1) View отправляет Intent (например, LoadUser(42)); 2) Middleware (EffectHandler) выполняет side effect — сетевой запрос; 3) Результат возвращается как новый Intent внутрь системы; 4) Reducer принимает текущее состояние и Intent, создаёт новое состояние; 5) View получает новое состояние и перерисовывается. Каждый шаг предсказуем и тестируется изолированно.
MVI на Android реализуется через sealed-классы для Intent и State, ViewModel с MVI-логикой и Jetpack Compose для реактивного отображения. ViewModel принимает Intent из View, делегирует side effects в Middleware, запускает Reducer и публикует новое состояние через StateFlow. Jetpack Compose перерисовывает UI при изменении state — идеально для MVI-цикла.
// Intent — намерения пользователя
sealed interface UserIntent {
data class LoadUser(val userId: Int) : UserIntent
data object Refresh : UserIntent
}
// State — единое состояние экрана
sealed interface UserState {
data object Idle : UserState
data object Loading : UserState
data class Success(val user: User) : UserState
data class Error(val message: String) : UserState
}
// Reducer — чистая функция
object UserReducer {
fun reduce(state: UserState, intent: UserIntent): UserState = when (intent) {
is UserIntent.LoadUser -> UserState.Loading
is UserIntent.Refresh -> UserState.Loading
}
}
// ViewModel с MVI
class UserViewModel(
private val repository: UserRepository
) : ViewModel() {
private val _state = MutableStateFlow<UserState>(UserState.Idle)
val state: StateFlow<UserState> = _state.asStateFlow()
fun process(intent: UserIntent) {
val newState = UserReducer.reduce(_state.value, intent)
_state.value = newState
when (intent) {
is UserIntent.LoadUser -> loadUser(intent.userId)
is UserIntent.Refresh -> loadUser(/* предыдущий ID */)
}
}
private fun loadUser(userId: Int) {
viewModelScope.launch {
repository.getUser(userId)
.onSuccess { user ->
_state.value = UserState.Success(user)
}
.onFailure { e ->
_state.value = UserState.Error(e.message ?: "Error")
}
}
}
}
// View отправляет Intent
fun UserScreen(viewModel: UserViewModel) {
val state by viewModel.state.collectAsState()
LaunchedEffect(Unit) { viewModel.process(UserIntent.LoadUser(42)) }
when (state) {
is UserState.Loading -> CircularProgressIndicator()
is UserState.Success -> UserCard((state as UserState.Success).user)
is UserState.Error -> ErrorView((state as UserState.Error).message)
is UserState.Idle -> Text("Press to load")
}
}
Middleware и side effects — в MVI чистый Reducer не может выполнять сетевые запросы. Middleware (также EffectHandler или Bootstrapper) обрабатывает Intent, выполняет side effect и эмиттит новый Intent обратно в цикл. Библиотеки Orbit MVI и MVIKotlin предоставляют встроенную поддержку Middleware с тестируемыми эффектами. Без Middleware MVI вырождается в MVVM с дополнительной структурой Intent и State.
MVIKotlin от Arkadii Ivanov — самая популярная библиотека MVI для Kotlin Multiplatform. Поддерживает Android, iOS, web и JVM. Предоставляет компоненты: Store (ViewModel), Bootstrapper (начальные эффекты), Reducer, Middleware. На октябрь 2025 библиотека собрала 2,5K звезд на GitHub и используется в коммерческих проектах, включая приложения крупных банков РФ. В IT Sectr мы используем MVIKotlin для кросс-платформенных проектов KMP с общей бизнес-логикой.
MVI на iOS реализуется без Combine-ViewModel, через цикл Intent → State. View отправляет Intent через замыкание, Reducer — чистая функция, State — struct с неизменяемыми полями. SwiftUI перерисовывает View при изменении State, что идеально вписывается в MVI-цикл без дополнительных @Published-свойств. MVI на iOS особенно популярен в сообществе SwiftUI-разработчиков, перешедших с Redux (JavaScript).
// State — неизменяемая структура
struct UserState: Equatable {
var user: User?
var isLoading = false
var errorMessage: String?
}
// Intent — enum с намерениями
enum UserIntent {
case loadUser(id: Int)
case refresh
case userLoaded(User)
case loadFailed(Error)
}
// Reducer — чистая функция
func userReducer(state: UserState, intent: UserIntent) -> UserState {
var newState = state
switch intent {
case .loadUser, .refresh:
newState.isLoading = true
newState.errorMessage = nil
case .userLoaded(let user):
newState.isLoading = false
newState.user = user
case .loadFailed(let error):
newState.isLoading = false
newState.errorMessage = error.localizedDescription
}
return newState
}
// Store — владеет состоянием и управляет эффектами
final class UserStore: ObservableObject {
@Published private(set) var state = UserState()
private let service: UserService
init(service: UserService) {
self.service = service
}
func dispatch(_ intent: UserIntent) {
// 1. Reducer обновляет состояние
state = userReducer(state: state, intent: intent)
// 2. Side effects (если нужно)
switch intent {
case .loadUser(let id), .refresh:
service.fetchUser(id: id) { [weak self] result in
switch result {
case .success(let user):
self?.dispatch(.userLoaded(user))
case .failure(let error):
self?.dispatch(.loadFailed(error))
}
}
default: break
}
}
}
TCA (The Composable Architecture) — самая популярная реализация MVI для iOS от Point-Free, построенная на SwiftUI и Combine. TCA предоставляет Store, Reducer, Effect и Environment. На октябрь 2025 звезда на GitHub превышает 13K — это стандарт de facto для MVI на iOS. TCA используется в приложениях Starbucks, Airbnb (частично) и многих инди-проектах. В отличие от самописного MVI, TCA решает вопросы тестирования, навигации и side effects из коробки.
MVI против MVVM на iOS — TCA/MVI даёт предсказуемость состояния, но требует больше шаблонного кода (Reducer, State, Action). MVVM с @Published проще для простых экранов. Мы в IT Sectr используем MVVM для 80% экранов и MVI (TCA) для 20% сложных — финансовые транзакции, многошаговые формы, drag-and-drop интерфейсы, где ошибка состояния может стоить денег пользователя.
MVI и MVVM решают одну задачу — организацию Presentation слоя — но с разными подходами к управлению состоянием. MVVM допускает множественные реактивные источники (LiveData, @Published), что может привести к несогласованности. MVI гарантирует ровно одно состояние в каждый момент времени, что делает его более строгим и предсказуемым, но увеличивает объем кода.
| Критерий | MVVM | MVI |
|---|---|---|
| Состояние | Множественные LiveData/StateFlow | Единый sealed class State |
| Поток данных | Двунаправленный (View → ViewModel, LiveData → View) | Однонаправленный (Intent → Reducer → State → View) |
| Side effects | В ViewModel впрямую | Через Middleware/EffectHandler |
| Тестирование | Unit-тесты ViewModel | Unit-тесты Reducer + Middleware |
| Шаблонный код | Минимальный | Reducer + State + Intent + Middleware |
Когда выбирать MVI — экраны, где состояние должно быть строго детерминированным: финансовые операции, корзина интернет-магазина, многошаговые формы с валидацией на каждом шагу. В этих сценариях цена ошибки состояния (например, показать сумму корзины без одного товара из-за гонки двух LiveData) выше стоимости дополнительного кода. В MVVM вы полагаетесь на дисциплину команды, в MVI — на архитектуру.
Когда MVVM достаточно — 80% стандартных экранов: список пользователей, профиль, настройки, лента новостей. Здесь одиночное состояние избыточно, а дополнительная структура MVI замедлит разработку. В IT Sectr правило такое: если экран имеет 3+ возможных состояния с переходами (загрузка → данные → ошибка → повторить → загрузка → данные) — MVI. Если экран имеет 1-2 асинхронных операции — MVVM.
Sealed State — лучшая практика MVI. Состояние определяется как sealed class/interface с вариантами Loading, Success(data), Error(message). Это гарантирует, что View не попадёт в несогласованное состояние — нельзя отобразить данные при loading=true, потому что Loading и Success — разные классы. Все данные, относящиеся к состоянию, находятся внутри sealed-варианта: Success содержит пользователя, Error — сообщение об ошибке.
Reducer должен оставаться чистой функцией — без вызовов API, БД, SharedPreferences. Чистая функция принимает State и Intent, возвращает State. Побочные эффекты (сеть, БД, навигация, тосты) обрабатываются в Middleware или в Store.dispatch после вызова Reducer. Если Reducer загрязнён побочными эффектами, MVI теряет тестируемость и предсказуемость — вы получаете MVVM с дополнительной структурой без преимуществ.
Типовые ошибки — объявление State как data class с nullable полями вместо sealed class: data class UserState(val user: User?, val isLoading: Boolean, val error: String?). Это эквивалент MVVM, а не MVI — View должна проверять комбинации полей на валидность. В sealed-подходе невалидные комбинации (isLoading=true и user!=null) невозможны на уровне типов. Вторая ошибка — размещение бизнес-логики в Intent (Intent.LoadUserBeforeXHours) вместо создания простых командных Intent (Intent.LoadUser), а бизнес-логику — в Middleware.
Часто задаваемые вопросы
MVI использует единый неизменяемый sealed-класс State и однонаправленный поток данных через Reducer. MVVM допускает множественные LiveData/StateFlow с двунаправленной связью. MVI гарантирует согласованность состояния на уровне типов — невозможно получить loading=true и user=null одновременно. MVVM полагается на дисциплину разработчика.
Основные: MVIKotlin (Arkadii Ivanov, 2,5K звезд, Kotlin Multiplatform), Orbit MVI (BabyJ, 1,3K звезд), Mobius (Spotify, Kotlin/Java). MVIKotlin — самый популярный для Kotlin, Orbit — самый простой для изучения. Все три поддерживают тестируемые Reducer и Middleware. Для Jetpack Compose достаточно написать простой MVI без библиотеки через sealed State + Reducer.
Нет — sealed Intent + sealed State + ViewModel + StateFlow дают работающий MVI без зависимостей. Библиотеки (MVIKotlin, Orbit, TCA) добавляют Middleware, тестирование side effects и интеграцию с DI. Для простых проектов вес библиотеки неоправдан. Для сложных проектов с 20+ экранами библиотека окупается структурированной обработкой эффектов.
MVI отлично подходит для iOS через TCA (The Composable Architecture) — самую популярную архитектуру SwiftUI-сообщества. TCA — это фактически MVI + Redux + Combine. На iOS можно реализовать MVI и без TCA через ObservableObject и чистую функцию reducer. SwiftUI с immutable State идеально ложится на MVI-цикл.
Reducer тестируется unit-тестами как чистая функция: задаётся начальное State, отправляется Intent, проверяется итоговый State. Middleware тестируется с mock-репозиторием: проверяется, что после LoadUser вызвался getUser. ViewModel-тест: отправить Intent, проверить StateFlow. MVI тестируется проще MVVM, потому что Reducer — чистая функция без скрытых зависимостей.
Итоги
Мы разработаем мобильное приложение под ключ
IT Sectr создаёт приложения для iOS и Android для стартапов и бизнеса с 2017 года. Мы проконсультируем вас и предложим наилучшее решение.
Читайте также