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 са immutable State идеално се уклапа у MVI циклус.
Reducer се тестира unit тестовима као чиста функција: задаје се почетни State, шаље се Intent, проверава се коначни State. Middleware се тестира са mock репозиторијумом: проверава се да је након LoadUser позван getUser. ViewModel тест: послати Intent, проверити StateFlow. MVI се тестира лакше од MVVM, јер је Reducer чиста функција без скривених зависности.
Закључак
Развићемо мобилну апликацију под кључ
IT Sectr креира iOS и Android апликације за стартапе и предузећа од 2017. године. Саветоваћемо вас и предложити најбоље решење.
Прочитајте такође