MVI (Model-View-Intent) — reaktivní architektonický vzor založený na jednosměrném toku dat a neměnném stavu. Na rozdíl od MVVM, kde ViewModel může mít mnoho StateFlow, MVI definuje jediný stav (State), neměnné záměry (Intent) a čistou funkci reduktoru (Reducer). MVI zaručuje předvídatelnost stavu obrazovky v každém okamžiku. Vzor byl zpopularizován v komunitě Android knihovnami Mosby a Orbit. Více — v MVIKotlin od Arkadii Ivanov.
Hlavní
MVI (Model-View-Intent) — reaktivní architektonický vzor postavený na principech Redux a Cycle.js. Model — neměnný stav obrazovky, Intent — záměr uživatele nebo systému, View — odběr stavu a odesílání Intent. Data se pohybují v cyklu: uživatel interaguje s View → View vytvoří Intent → Intent je zpracován Reducer → Reducer vytvoří nový stav → View obdrží nový stav a překreslí se.
Hlavní rozdíl MVI od MVVM — jediný zdroj pravdy (Single Source of Truth). V MVVM může mít ViewModel několik LiveData/StateFlow (userState, loadingState, errorState), což vede k nekonzistenci: loading=true a user=null současně. V MVI existuje přesně jedna sealed class/interface State popisující celý stav obrazovky. V každém okamžiku je stav obrazovky jednoznačně určen — není možné získat loading=true, když jsou data již načtena. V IT Sectr aplikujeme MVI pro obrazovky se složitou logikou — objednávkové formuláře, vícestupňové registrace, finanční obrazovky — kde je předvídatelnost stavu kritická.
| Komponenta | Role v MVI | Příklad |
|---|---|---|
| Intent | Záměr uživatele nebo systému | LoadUser, Refresh, SubmitForm |
| State | Neměnný stav obrazovky | sealed class UserState |
| Reducer | Čistá funkce: State + Intent → State | fun reduce(state, intent) -> state |
| Middleware | Zpracování vedlejších účinků | Požadavek na síť, zápis do DB |
Cyklus MVI se skládá z pěti kroků: 1) View odešle Intent (např. LoadUser(42)); 2) Middleware (EffectHandler) provede vedlejší účinek — požadavek na síť; 3) Výsledek se vrátí jako nový Intent do systému; 4) Reducer přijme aktuální stav a Intent, vytvoří nový stav; 5) View obdrží nový stav a překreslí se. Každý krok je předvídatelný a testuje se izolovaně.
MVI na Android je implementováno pomocí sealed-tříd pro Intent a State, ViewModel s logikou MVI a Jetpack Compose pro reaktivní zobrazení. ViewModel přijímá Intent z View, deleguje vedlejší účinky na Middleware, spouští Reducer a publikuje nový stav přes StateFlow. Jetpack Compose překresluje UI při změně state — ideální pro cyklus MVI.
// Intent — záměry uživatele
sealed interface UserIntent {
data class LoadUser(val userId: Int) : UserIntent
data object Refresh : UserIntent
}
// State — jediný stav obrazovky
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 — čistá funkce
object UserReducer {
fun reduce(state: UserState, intent: UserIntent): UserState = when (intent) {
is UserIntent.LoadUser -> UserState.Loading
is UserIntent.Refresh -> UserState.Loading
}
}
// ViewModel s 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(/* předchozí 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 odesílá 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 a vedlejší účinky — v MVI nemůže čistý Reducer provádět síťové požadavky. Middleware (také EffectHandler nebo Bootstrapper) zpracovává Intent, provádí vedlejší účinek a emituje nový Intent zpět do cyklu. Knihovny Orbit MVI a MVIKotlin poskytují vestavěnou podporu pro Middleware s testovatelnými efekty. Bez Middleware MVI degraduje na MVVM s dodatečnou strukturou Intent a State.
MVIKotlin od Arkadii Ivanov — nejpopulárnější knihovna MVI pro Kotlin Multiplatform. Podporuje Android, iOS, web a JVM. Poskytuje komponenty: Store (ViewModel), Bootstrapper (počáteční efekty), Reducer, Middleware. K říjnu 2025 knihovna nasbírala 2,5K hvězd na GitHub a používá se v komerčních projektech, včetně aplikací velkých ruských bank. V IT Sectr používáme MVIKotlin pro cross-platform KMP projekty se sdílenou obchodní logikou.
MVI na iOS je implementováno bez Combine-ViewModel, přes cyklus Intent → State. View odesílá Intent přes uzávěr (closure), Reducer — čistá funkce, State — struct s neměnnými poli. SwiftUI překresluje View při změně State, což dokonale zapadá do cyklu MVI bez dalších vlastností @Published. MVI na iOS je obzvláště populární v komunitě SwiftUI vývojářů, kteří přešli z Redux (JavaScript).
// State — neměnná struktura
struct UserState: Equatable {
var user: User?
var isLoading = false
var errorMessage: String?
}
// Intent — enum se záměry
enum UserIntent {
case loadUser(id: Int)
case refresh
case userLoaded(User)
case loadFailed(Error)
}
// Reducer — čistá funkce
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 — vlastní stav a spravuje efekty
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 aktualizuje stav
state = userReducer(state: state, intent: intent)
// 2. Side effects (pokud je potřeba)
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) — nejpopulárnější implementace MVI pro iOS od Point-Free, postavená na SwiftUI a Combine. TCA poskytuje Store, Reducer, Effect a Environment. K říjnu 2025 počet hvězd na GitHub přesahuje 13K — to je de facto standard pro MVI na iOS. TCA se používá v aplikacích Starbucks, Airbnb (částečně) a mnoha indie projektech. Na rozdíl od ručně psaného MVI, TCA řeší otázky testování, navigace a vedlejších účinků z krabice.
MVI vs MVVM na iOS — TCA/MVI poskytuje předvídatelnost stavu, ale vyžaduje více šablonového kódu (Reducer, State, Action). MVVM s @Published je jednodušší pro jednoduché obrazovky. My v IT Sectr používáme MVVM pro 80% obrazovek a MVI (TCA) pro 20% složitých — finanční transakce, vícestupňové formuláře, drag-and-drop rozhraní, kde chyba stavu může stát uživatele peníze.
MVI a MVVM řeší stejný úkol — organizaci prezentační vrstvy — ale s odlišnými přístupy ke správě stavu. MVVM umožňuje více reaktivních zdrojů (LiveData, @Published), což může vést k nekonzistenci. MVI zaručuje přesně jeden stav v každém okamžiku, což jej činí přísnějším a předvídatelnějším, ale zvyšuje objem kódu.
| Kritérium | MVVM | MVI |
|---|---|---|
| Stav | Více LiveData/StateFlow | Jedna sealed class State |
| Tok dat | Obousměrný (View → ViewModel, LiveData → View) | Jednosměrný (Intent → Reducer → State → View) |
| Vedlejší účinky | Přímo ve ViewModel | Přes Middleware/EffectHandler |
| Testování | Unit testy ViewModel | Unit testy Reducer + Middleware |
| Šablonový kód | Minimální | Reducer + State + Intent + Middleware |
Kdy zvolit MVI — obrazovky, kde stav musí být přísně deterministický: finanční operace, košík internetového obchodu, vícestupňové formuláře s validací v každém kroku. V těchto scénářích jsou náklady na chybu stavu (např. zobrazení částky košíku bez jednoho produktu kvůli závodu dvou LiveData) vyšší než náklady na dodatečný kód. V MVVM spoléháte na disciplínu týmu, v MVI — na architekturu.
Kdy MVVM stačí — 80% standardních obrazovek: seznam uživatelů, profil, nastavení, zpravodajský kanál. Zde je jediný stav nadbytečný a dodatečná struktura MVI zpomalí vývoj. V IT Sectr platí pravidlo: pokud má obrazovka 3+ možných stavů s přechody (načítání → data → chyba → opakovat → načítání → data) — MVI. Pokud má obrazovka 1-2 asynchronní operace — MVVM.
Sealed State — nejlepší postup MVI. Stav je definován jako sealed class/interface s variantami Loading, Success(data), Error(message). To zaručuje, že View neskončí v nekonzistentním stavu — data nelze zobrazit při loading=true, protože Loading a Success jsou různé třídy. Všechna data týkající se stavu jsou uvnitř sealed varianty: Success obsahuje uživatele, Error — chybovou zprávu.
Reducer musí zůstat čistou funkcí — bez volání API, DB, SharedPreferences. Čistá funkce přijímá State a Intent, vrací State. Vedlejší účinky (síť, DB, navigace, toasty) jsou zpracovávány v Middleware nebo v Store.dispatch po volání Reducer. Pokud je Reducer kontaminován vedlejšími účinky, MVI ztrácí testovatelnost a předvídatelnost — dostanete MVVM s dodatečnou strukturou bez výhod.
Typické chyby — deklarování State jako data class s nullable poli místo sealed class: data class UserState(val user: User?, val isLoading: Boolean, val error: String?). Toto je ekvivalent MVVM, ne MVI — View musí kontrolovat kombinace polí na platnost. V sealed přístupu jsou neplatné kombinace (isLoading=true a user!=null) nemožné na úrovni typů. Druhá chyba — umístění obchodní logiky do Intent (Intent.LoadUserBeforeXHours) místo vytváření jednoduchých příkazových Intent (Intent.LoadUser) a obchodní logiku — do Middleware.
Často kladené otázky
MVI používá jedinou neměnnou sealed třídu State a jednosměrný tok dat přes Reducer. MVVM umožňuje více LiveData/StateFlow s obousměrným propojením. MVI zaručuje konzistenci stavu na úrovni typů — není možné získat loading=true a user=null současně. MVVM spoléhá na disciplínu vývojáře.
Hlavní: MVIKotlin (Arkadii Ivanov, 2,5K hvězd, Kotlin Multiplatform), Orbit MVI (BabyJ, 1,3K hvězd), Mobius (Spotify, Kotlin/Java). MVIKotlin — nejpopulárnější pro Kotlin, Orbit — nejjednodušší na učení. Všechny tři podporují testovatelný Reducer a Middleware. Pro Jetpack Compose stačí napsat jednoduché MVI bez knihovny přes sealed State + Reducer.
Ne — sealed Intent + sealed State + ViewModel + StateFlow dávají funkční MVI bez závislostí. Knihovny (MVIKotlin, Orbit, TCA) přidávají Middleware, testování vedlejších účinků a integraci s DI. Pro jednoduché projekty je váha knihovny neopodstatněná. Pro složité projekty s 20+ obrazovkami se knihovna vyplatí díky strukturovanému zpracování efektů.
MVI je skvěle vhodné pro iOS přes TCA (The Composable Architecture) — nejpopulárnější architekturu komunity SwiftUI. TCA je vlastně MVI + Redux + Combine. Na iOS lze MVI implementovat i bez TCA přes ObservableObject a čistou funkci reducer. SwiftUI s immutable State dokonale zapadá do cyklu MVI.
Reducer se testuje unit testy jako čistá funkce: zadá se počáteční State, odešle se Intent, zkontroluje se konečný State. Middleware se testuje s mock-repozitářem: kontroluje se, že po LoadUser bylo zavoláno getUser. ViewModel test: odeslat Intent, zkontrolovat StateFlow. MVI se testuje snadněji než MVVM, protože Reducer je čistá funkce bez skrytých závislostí.
Shrnutí
Vyvineme mobilní aplikaci na klíč
IT Sectr vytváří aplikace pro iOS a Android pro startupy a podniky od roku 2017. Poradíme vám a navrhneme nejlepší řešení.
Přečtěte si také