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 | Обробка побічних ефектів | Мережевий запит, запис у БД |
Цикл MVI складається з п'яти кроків: 1) View відправляє Intent (наприклад, LoadUser(42)); 2) Middleware (EffectHandler) виконує побічний ефект — мережевий запит; 3) Результат повертається як новий Intent всередину системи; 4) Reducer приймає поточний стан та Intent, створює новий стан; 5) View отримує новий стан і перемальовується. Кожен крок передбачуваний і тестується ізольовано.
MVI на Android реалізується через sealed-класи для Intent та State, ViewModel з MVI-логікою та Jetpack Compose для реактивного відображення. ViewModel приймає Intent від View, делегує побічні ефекти 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 та побічні ефекти — у 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 реалізується без 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 вирішує питання тестування, навігації та побічних ефектів з коробки.
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) |
| Побічні ефекти | У 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, тестування побічних ефектів та інтеграцію з 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 року. Ми проконсультуємо вас і запропонуємо найкраще рішення.
Читайте також