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 тестови ViewModel-аUnit тестови 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. године. Саветоваћемо вас и предложити најбоље решење.

Разговарајте о пројекту

Прочитајте такође