MVI — podstata vzoru Model-View-Intent v mobilních aplikacích

Autor: IT Sectr Publikováno: 2026-02-16 Doba čtení: 10 min

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 (stav), View (zobrazení), Intent (záměr uživatele) — reaktivní cyklus
  • Unidirectional data flow — data se pohybují jedním směrem: Intent → Reducer → State → View
  • Immutable State — stav obrazovky — neměnný objekt, při každé změně znovu vytvořený
  • Reducer — čistá funkce, která přijímá aktuální stav a Intent, vrací nový stav
  • Side effects — vedlejší účinky (síť, DB) jsou zpracovávány odděleně od Reducer, přes Middleware

Co je MVI: podstata vzoru Model-View-Intent

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á.

KomponentaRole v MVIPříklad
IntentZáměr uživatele nebo systémuLoadUser, Refresh, SubmitForm
StateNeměnný stav obrazovkysealed class UserState
ReducerČistá funkce: State + Intent → Statefun reduce(state, intent) -> state
MiddlewareZpracová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 v Android: Intent, Reducer, State v Kotlin

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.

kotlin
// 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 v iOS: jednosměrný tok ve Swift

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).

swift
// 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.

Srovnání MVI s MVVM: kdy zvolit MVI

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ériumMVVMMVI
StavVíce LiveData/StateFlowJedna sealed class State
Tok datObousměrný (View → ViewModel, LiveData → View)Jednosměrný (Intent → Reducer → State → View)
Vedlejší účinkyPřímo ve ViewModelPřes Middleware/EffectHandler
TestováníUnit testy ViewModelUnit testy Reducer + Middleware
Šablonový kódMinimá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.

Nejlepší postupy MVI a typické chyby

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

Jaký je hlavní rozdíl mezi MVI a MVVM?

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.

Jaké knihovny MVI existují pro Android?

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.

Je potřeba samostatná knihovna pro MVI?

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ů.

Je MVI vhodné pro iOS, nebo je to pouze Android vzor?

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.

Jak testovat 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í

  • MVI (Model-View-Intent) — reaktivní vzor s jednosměrným tokem a jediným stavem
  • Sealed State — zaručuje konzistenci na úrovni typů, vylučuje neplatné kombinace
  • Reducer — čistá funkce State + Intent → State, testovaná bez mock objektů
  • Middleware — samostatná vrstva pro vedlejší účinky (síť, DB, navigace)
  • MVI vs MVVM — MVI je přísnější a předvídatelnější, MVVM jednodušší a rychlejší
  • Android — MVIKotlin nebo Orbit pro složité obrazovky; MVVM pro jednoduché
  • iOS — TCA (The Composable Architecture) — standard MVI na SwiftUI

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í.

Prodiskutovat projekt

Přečtěte si také