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 за cross-platform KMP проекти със споделена бизнес логика.
MVI на iOS се имплементира без Combine-ViewModel, чрез цикъла Intent → State. View изпраща Intent чрез затваряне (closure), 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 (частично) и много indie проекти. За разлика от ръчно написания 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 с неизменимо State се вписва перфектно в MVI цикъла.
Reducer се тества с unit тестове като чиста функция: задава се начално State, изпраща се Intent, проверява се крайното State. Middleware се тества с mock-репозиторий: проверява се, че след LoadUser е извикан getUser. ViewModel тест: изпратете Intent, проверете StateFlow. MVI се тества по-лесно от MVVM, защото Reducer е чиста функция без скрити зависимости.
Обобщение
Ще разработим мобилно приложение под ключ
IT Sectr създава iOS и Android приложения за стартъпи и бизнеси от 2017 г. Ще ви консултираме и ще предложим най-доброто решение.
Прочетете също