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 за cross-platform KMP проекти със споделена бизнес логика.

MVI в iOS: еднопосочен поток на Swift

MVI на iOS се имплементира без Combine-ViewModel, чрез цикъла Intent → State. View изпраща Intent чрез затваряне (closure), 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 (частично) и много 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: кога да изберете 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 с неизменимо 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 г. Ще ви консултираме и ще предложим най-доброто решение.

Обсъдете проекта

Прочетете също