MVI — суть паттерна Model-View-Intent в мобильных приложениях

Автор: IT Sectr Опубликовано: 2026-02-16 Время чтения: 10 мин

MVI (Model-View-Intent) — реактивный архитектурный паттерн, основанный на однонаправленном потоке данных и неизменяемом состоянии. В отличие от MVVM, где ViewModel может иметь множество StateFlow, MVI определяет единое состояние (State), неизменяемые намерения (Intent) и чистую функцию-редьюсер (Reducer). MVI гарантирует предсказуемость состояния экрана в любой момент времени. Паттерн популяризован в Android-сообществе библиотеками Mosby и Orbit. Подробнее — в MVIKotlin от Arkadii Ivanov.

Главное

  • MVI — Model (состояние), View (отображение), Intent (намерение пользователя) — реактивный цикл
  • Unidirectional data flow — данные движутся в одном направлении: Intent → Reducer → State → View
  • Immutable State — состояние экрана — неизменяемый объект, пересоздаваемый при каждом изменении
  • Reducer — чистая функция, принимающая текущее состояние и Intent, возвращающая новое состояние
  • Side effects — побочные эффекты (сеть, БД) обрабатываются отдельно от Reducer, через Middleware

Что такое MVI: суть паттерна Model-View-Intent

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 → Statefun 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: Intent, Reducer, State на Kotlin

MVI на Android реализуется через sealed-классы для Intent и State, ViewModel с MVI-логикой и Jetpack Compose для реактивного отображения. ViewModel принимает Intent из View, делегирует side effects в Middleware, запускает Reducer и публикует новое состояние через StateFlow. Jetpack Compose перерисовывает UI при изменении state — идеально для MVI-цикла.

kotlin
// 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: однонаправленный поток на Swift

MVI на iOS реализуется без Combine-ViewModel, через цикл Intent → State. View отправляет Intent через замыкание, Reducer — чистая функция, State — struct с неизменяемыми полями. SwiftUI перерисовывает View при изменении State, что идеально вписывается в MVI-цикл без дополнительных @Published-свойств. MVI на iOS особенно популярен в сообществе SwiftUI-разработчиков, перешедших с Redux (JavaScript).

swift
// 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: когда выбирать MVI

MVI и MVVM решают одну задачу — организацию Presentation слоя — но с разными подходами к управлению состоянием. MVVM допускает множественные реактивные источники (LiveData, @Published), что может привести к несогласованности. MVI гарантирует ровно одно состояние в каждый момент времени, что делает его более строгим и предсказуемым, но увеличивает объем кода.

КритерийMVVMMVI
СостояниеМножественные LiveData/StateFlowЕдиный sealed class State
Поток данныхДвунаправленный (View → ViewModel, LiveData → View)Однонаправленный (Intent → Reducer → State → View)
Side effectsВ ViewModel впрямуюЧерез Middleware/EffectHandler
ТестированиеUnit-тесты ViewModelUnit-тесты Reducer + Middleware
Шаблонный кодМинимальныйReducer + State + Intent + Middleware

Когда выбирать MVI — экраны, где состояние должно быть строго детерминированным: финансовые операции, корзина интернет-магазина, многошаговые формы с валидацией на каждом шагу. В этих сценариях цена ошибки состояния (например, показать сумму корзины без одного товара из-за гонки двух LiveData) выше стоимости дополнительного кода. В MVVM вы полагаетесь на дисциплину команды, в MVI — на архитектуру.

Когда MVVM достаточно — 80% стандартных экранов: список пользователей, профиль, настройки, лента новостей. Здесь одиночное состояние избыточно, а дополнительная структура MVI замедлит разработку. В IT Sectr правило такое: если экран имеет 3+ возможных состояния с переходами (загрузка → данные → ошибка → повторить → загрузка → данные) — MVI. Если экран имеет 1-2 асинхронных операции — MVVM.

Лучшие практики MVI и типовые ошибки

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 от MVVM?

MVI использует единый неизменяемый sealed-класс State и однонаправленный поток данных через Reducer. MVVM допускает множественные LiveData/StateFlow с двунаправленной связью. MVI гарантирует согласованность состояния на уровне типов — невозможно получить loading=true и user=null одновременно. MVVM полагается на дисциплину разработчика.

Какие библиотеки MVI существуют для Android?

Основные: 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.

Нужна ли отдельная библиотека для MVI?

Нет — sealed Intent + sealed State + ViewModel + StateFlow дают работающий MVI без зависимостей. Библиотеки (MVIKotlin, Orbit, TCA) добавляют Middleware, тестирование side effects и интеграцию с DI. Для простых проектов вес библиотеки неоправдан. Для сложных проектов с 20+ экранами библиотека окупается структурированной обработкой эффектов.

Подходит ли MVI для iOS или это Android-паттерн?

MVI отлично подходит для iOS через TCA (The Composable Architecture) — самую популярную архитектуру SwiftUI-сообщества. TCA — это фактически MVI + Redux + Combine. На iOS можно реализовать MVI и без TCA через ObservableObject и чистую функцию reducer. SwiftUI с immutable State идеально ложится на MVI-цикл.

Как тестировать MVI?

Reducer тестируется unit-тестами как чистая функция: задаётся начальное State, отправляется Intent, проверяется итоговый State. Middleware тестируется с mock-репозиторием: проверяется, что после LoadUser вызвался getUser. ViewModel-тест: отправить Intent, проверить StateFlow. MVI тестируется проще MVVM, потому что Reducer — чистая функция без скрытых зависимостей.

Итоги

  • MVI (Model-View-Intent) — реактивный паттерн с однонаправленным потоком и единственным состоянием
  • Sealed State — гарантирует согласованность на уровне типов, исключая невалидные комбинации
  • Reducer — чистая функция State + Intent → State, тестируется без mock-объектов
  • Middleware — отдельный слой для side effects (сеть, БД, навигация)
  • MVI vs MVVM — MVI строже и предсказуемее, MVVM проще и быстрее
  • Android — MVIKotlin или Orbit для сложных экранов; MVVM для простых
  • iOS — TCA (The Composable Architecture) — стандарт MVI на SwiftUI

Мы разработаем мобильное приложение под ключ

IT Sectr создаёт приложения для iOS и Android для стартапов и бизнеса с 2017 года. Мы проконсультируем вас и предложим наилучшее решение.

Обсудить проект

Читайте также