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Обробка побічних ефектівМережевий запит, запис у БД

Цикл MVI складається з п'яти кроків: 1) View відправляє Intent (наприклад, LoadUser(42)); 2) Middleware (EffectHandler) виконує побічний ефект — мережевий запит; 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, делегує побічні ефекти 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 та побічні ефекти — у MVI чистий Reducer не може виконувати мережеві запити. Middleware (також EffectHandler або Bootstrapper) обробляє Intent, виконує побічний ефект та емітить новий 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 вирішує питання тестування, навігації та побічних ефектів з коробки.

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)
Побічні ефектиУ 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, тестування побічних ефектів та інтеграцію з 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 — окремий шар для побічних ефектів (мережа, БД, навігація)
  • MVI vs MVVM — MVI строгіший і передбачуваніший, MVVM простіший і швидший
  • Android — MVIKotlin або Orbit для складних екранів; MVVM для простих
  • iOS — TCA (The Composable Architecture) — стандарт MVI на SwiftUI

Ми розробимо мобільний застосунок під ключ

IT Sectr створює застосунки для iOS та Android для стартапів і бізнесу з 2017 року. Ми проконсультуємо вас і запропонуємо найкраще рішення.

Обговорити проект

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