MVI — a Model-View-Intent minta lényege mobilalkalmazásokban

Szerző: IT Sectr Megjelenés: 2026-02-16 Olvasási idő: 10 perc

MVI (Model-View-Intent) — egy reaktív architekturális minta, amely egyirányú adatfolyamon és megváltoztathatatlan állapoton alapul. Ellentétben az MVVM-mel, ahol a ViewModel-nek több StateFlow-ja is lehet, az MVI egyetlen állapotot (State), megváltoztathatatlan szándékokat (Intent) és egy tiszta redukáló függvényt (Reducer) határoz meg. Az MVI garantálja a képernyő állapotának kiszámíthatóságát bármely pillanatban. A mintát az Android közösségben a Mosby és Orbit könyvtárak népszerűsítették. Bővebben — a MVIKotlin Arkadii Ivanov-tól.

Főbb pontok

  • MVI — Model (állapot), View (megjelenítés), Intent (felhasználói szándék) — reaktív ciklus
  • Unidirectional data flow — az adatok egy irányba mozognak: Intent → Reducer → State → View
  • Immutable State — a képernyő állapota — egy megváltoztathatatlan objektum, amely minden változáskor újra létrejön
  • Reducer — tiszta függvény, amely fogadja az aktuális állapotot és Intent-et, és új állapotot ad vissza
  • Side effects — a mellékhatások (hálózat, adatbázis) a Reducer-től elkülönítve, Middleware-en keresztül kerülnek feldolgozásra

Mi az MVI: a Model-View-Intent minta lényege

MVI (Model-View-Intent) — egy reaktív architekturális minta, amely a Redux és Cycle.js elveire épül. Model — a képernyő megváltoztathatatlan állapota, Intent — a felhasználó vagy rendszer szándéka, View — feliratkozás az állapotra és Intent küldése. Az adatok egy ciklusban mozognak: a felhasználó interakcióba lép a View-val → View létrehoz egy Intent-et → Intent feldolgozásra kerül a Reducer által → Reducer létrehoz egy új állapotot → View megkapja az új állapotot és újrarajzolódik.

Az MVI fő különbsége az MVVM-től — egyetlen igazságforrás (Single Source of Truth). Az MVVM-ben a ViewModel-nek több LiveData/StateFlow-ja lehet (userState, loadingState, errorState), ami inkonzisztenciához vezet: loading=true és user=null egyszerre. Az MVI-ben pontosan egy sealed class/interface State létezik, amely a képernyő teljes állapotát írja le. Bármely pillanatban a képernyő állapota egyértelműen meghatározott — lehetetlen loading=true-t kapni, amikor az adatok már betöltődtek. Az IT Sectr-nél az MVI-t összetett logikájú képernyőkhöz alkalmazzuk — megrendelőűrlapok, többlépcsős regisztrációk, pénzügyi képernyők — ahol az állapot kiszámíthatósága kritikus.

KomponensSzerep az MVI-benPélda
IntentFelhasználó vagy rendszer szándékaLoadUser, Refresh, SubmitForm
StateA képernyő megváltoztathatatlan állapotasealed class UserState
ReducerTiszta függvény: State + Intent → Statefun reduce(state, intent) -> state
MiddlewareMellékhatások feldolgozásaHálózati kérés, írás az adatbázisba

MVI-ciklus öt lépésből áll: 1) View elküld egy Intent-et (pl. LoadUser(42)); 2) Middleware (EffectHandler) végrehajtja a mellékhatást — hálózati kérés; 3) Az eredmény új Intent-ként tér vissza a rendszerbe; 4) Reducer fogadja az aktuális állapotot és Intent-et, létrehoz egy új állapotot; 5) View megkapja az új állapotot és újrarajzolódik. Minden lépés kiszámítható és elkülönítve tesztelhető.

MVI Androidon: Intent, Reducer, State Kotlinban

MVI Androidon sealed-osztályokkal valósul meg az Intent és State számára, ViewModel-lel MVI-logikával és Jetpack Compose-zal a reaktív megjelenítéshez. A ViewModel fogadja az Intent-et a View-tól, delegálja a mellékhatásokat a Middleware-nek, futtatja a Reducer-t és közzéteszi az új állapotot StateFlow-n keresztül. A Jetpack Compose újrarajzolja a UI-t az állapot változásakor — ideális az MVI-ciklushoz.

kotlin
// Intent — felhasználói szándékok
sealed interface UserIntent {
    data class LoadUser(val userId: Int) : UserIntent
    data object Refresh : UserIntent
}

// State — a képernyő egységes állapota
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 — tiszta függvény
object UserReducer {
    fun reduce(state: UserState, intent: UserIntent): UserState = when (intent) {
        is UserIntent.LoadUser -> UserState.Loading
        is UserIntent.Refresh -> UserState.Loading
    }
}

// ViewModel MVI-vel
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(/* előző 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 elküldi az Intent-et
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 és mellékhatások — az MVI-ben a tiszta Reducer nem hajthat végre hálózati kéréseket. A Middleware (szintén EffectHandler vagy Bootstrapper) feldolgozza az Intent-et, végrehajtja a mellékhatást és egy új Intent-et bocsát ki vissza a ciklusba. Az Orbit MVI és MVIKotlin könyvtárak beépített támogatást nyújtanak a Middleware-hez tesztelhető effektusokkal. Middleware nélkül az MVI MVVM-mé degradálódik, plusz Intent és State struktúrával.

MVIKotlin Arkadii Ivanov-tól — a legnépszerűbb MVI könyvtár Kotlin Multiplatformhoz. Támogatja az Androidot, iOS-t, webet és JVM-et. Komponenseket biztosít: Store (ViewModel), Bootstrapper (kezdeti effektusok), Reducer, Middleware. 2025 októberére a könyvtár 2,5K csillagot gyűjtött a GitHub-on, és kereskedelmi projektekben használják, beleértve nagy orosz bankok alkalmazásait is. Az IT Sectr-nél az MVIKotlin-t használjuk cross-platform KMP projektekhez közös üzleti logikával.

MVI iOS-en: egyirányú adatfolyam Swiftben

MVI iOS-en Combine-ViewModel nélkül, az Intent → State cikluson keresztül valósul meg. A View lezáráson (closure) keresztül küldi az Intent-et, a Reducer — tiszta függvény, a State — struct megváltoztathatatlan mezőkkel. A SwiftUI újrarajzolja a View-t az állapot változásakor, ami tökéletesen illeszkedik az MVI-ciklusba további @Published tulajdonságok nélkül. Az MVI iOS-en különösen népszerű a SwiftUI-fejlesztők közösségében, akik a Redux-ról (JavaScript) váltottak.

swift
// State — megváltoztathatatlan struktúra
struct UserState: Equatable {
    var user: User?
    var isLoading = false
    var errorMessage: String?
}

// Intent — enum szándékokkal
enum UserIntent {
    case loadUser(id: Int)
    case refresh
    case userLoaded(User)
    case loadFailed(Error)
}

// Reducer — tiszta függvény
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 — birtokolja az állapotot és kezeli az effektusokat
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 frissíti az állapotot
        state = userReducer(state: state, intent: intent)
        // 2. Side effects (ha szükséges)
        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) — a legnépszerűbb MVI implementáció iOS-re a Point-Free-tól, SwiftUI-re és Combine-ra építve. A TCA Store-t, Reducer-t, Effect-et és Environment-et biztosít. 2025 októberére a GitHub csillagok száma meghaladja a 13K-t — ez a de facto szabvány az MVI számára iOS-en. A TCA-t a Starbucks, az Airbnb (részben) és sok indie projekt alkalmazásaiban használják. Ellentétben a kézzel írt MVI-vel, a TCA a tesztelés, navigáció és mellékhatások problémáit dobozból megoldja.

MVI kontra MVVM iOS-en — a TCA/MVI kiszámíthatóságot nyújt, de több sablonkódot igényel (Reducer, State, Action). Az MVVM @Published-del egyszerűbb az egyszerű képernyőkhöz. Mi az IT Sectr-nél az MVVM-t használjuk a képernyők 80%-ánál és az MVI-t (TCA) a 20% összetettnél — pénzügyi tranzakciók, többlépcsős űrlapok, drag-and-drop interfészek, ahol az állapothiba pénzbe kerülhet a felhasználónak.

MVI összehasonlítása MVVM-mel: mikor válassza az MVI-t

Az MVI és az MVVM ugyanazt a feladatot oldja meg — a Presentation réteg megszervezését — de eltérő megközelítéssel az állapotkezeléshez. Az MVVM több reaktív forrást enged meg (LiveData, @Published), ami inkonzisztenciához vezethet. Az MVI pontosan egy állapotot garantál minden pillanatban, ami szigorúbbá és kiszámíthatóbbá teszi, de növeli a kód mennyiségét.

KritériumMVVMMVI
ÁllapotTöbb LiveData/StateFlowEgyetlen sealed class State
AdatfolyamKétirányú (View → ViewModel, LiveData → View)Egyirányú (Intent → Reducer → State → View)
MellékhatásokKözvetlenül a ViewModel-benMiddleware/EffectHandler-en keresztül
TesztelésViewModel unit tesztjeiReducer + Middleware unit tesztjei
SablonkódMinimálisReducer + State + Intent + Middleware

Mikor válassza az MVI-t — olyan képernyők, ahol az állapotnak szigorúan determinisztikusnak kell lennie: pénzügyi műveletek, online áruház kosara, többlépcsős űrlapok validációval minden lépésben. Ezekben a forgatókönyvekben az állapothiba költsége (pl. a kosár összegének megjelenítése egy termék nélkül két LiveData versenyfutása miatt) magasabb, mint a plusz kód költsége. Az MVVM-ben a csapat fegyelmére hagyatkozik, az MVI-ben — az architektúrára.

Mikor elegendő az MVVM — a szabványos képernyők 80%-a: felhasználói lista, profil, beállítások, hírfolyam. Itt az egyetlen állapot redundáns, és az MVI plusz struktúrája lassítja a fejlesztést. Az IT Sectr-nél a szabály: ha a képernyőnek 3+ lehetséges állapota van átmenetekkel (betöltés → adat → hiba → újrapróbálkozás → betöltés → adat) — MVI. Ha a képernyőnek 1-2 aszinkron művelete van — MVVM.

MVI legjobb gyakorlatok és tipikus hibák

Sealed State — az MVI legjobb gyakorlata. Az állapot sealed class/interface-ként van meghatározva Loading, Success(data), Error(message) variánsokkal. Ez garantálja, hogy a View nem kerül inkonzisztens állapotba — adatok nem jeleníthetők meg loading=true esetén, mert a Loading és a Success különböző osztályok. Az állapothoz tartozó összes adat a sealed variánson belül található: a Success tartalmazza a felhasználót, az Error — a hibaüzenetet.

A Reducer-nek tiszta függvénynek kell maradnia — API, adatbázis, SharedPreferences hívások nélkül. A tiszta függvény fogadja az State-et és Intent-et, és visszaadja az State-et. A mellékhatások (hálózat, adatbázis, navigáció, toast-ok) a Middleware-ben vagy a Store.dispatch-ben kerülnek feldolgozásra a Reducer meghívása után. Ha a Reducer mellékhatásokkal szennyezett, az MVI elveszíti tesztelhetőségét és kiszámíthatóságát — MVVM-et kap plusz struktúrával, előnyök nélkül.

Tipikus hibák — az State deklarálása data class-ként nullable mezőkkel sealed class helyett: data class UserState(val user: User?, val isLoading: Boolean, val error: String?). Ez az MVVM megfelelője, nem MVI — a View-nak ellenőriznie kell a mezők kombinációit érvényesség szempontjából. A sealed megközelítésben az érvénytelen kombinációk (isLoading=true és user!=null) lehetetlenek a típus szintjén. Második hiba — üzleti logika elhelyezése az Intent-ben (Intent.LoadUserBeforeXHours) ahelyett, hogy egyszerű parancs Intent-eket (Intent.LoadUser) hozna létre, az üzleti logikát pedig a Middleware-ben helyezné el.

Gyakran Ismételt Kérdések

Mi a fő különbség az MVI és az MVVM között?

Az MVI egyetlen megváltoztathatatlan sealed State osztályt és egyirányú adatfolyamot használ a Reducer-en keresztül. Az MVVM több LiveData/StateFlow-t enged meg kétirányú kötéssel. Az MVI garantálja az állapot konzisztenciáját a típusok szintjén — lehetetlen egyszerre loading=true és user=null értéket kapni. Az MVVM a fejlesztő fegyelmére hagyatkozik.

Milyen MVI könyvtárak léteznek Androidra?

Főbbek: MVIKotlin (Arkadii Ivanov, 2,5K csillag, Kotlin Multiplatform), Orbit MVI (BabyJ, 1,3K csillag), Mobius (Spotify, Kotlin/Java). Az MVIKotlin a legnépszerűbb Kotlinhoz, az Orbit a legegyszerűbb tanuláshoz. Mindhárom támogatja a tesztelhető Reducer-t és Middleware-t. Jetpack Compose-hoz elég egy egyszerű MVI-t írni könyvtár nélkül sealed State + Reducer segítségével.

Szükséges egy külön könyvtár az MVI-hez?

Nem — a sealed Intent + sealed State + ViewModel + StateFlow működő MVI-t ad függőségek nélkül. A könyvtárak (MVIKotlin, Orbit, TCA) Middleware-t, mellékhatások tesztelését és DI-integrációt adnak hozzá. Egyszerű projektekhez a könyvtár súlya indokolatlan. Összetett, 20+ képernyős projekteknél a könyvtár megtérül a strukturált effektusfeldolgozással.

Alkalmas az MVI iOS-re, vagy ez csak Android minta?

Az MVI kiválóan alkalmas iOS-re a TCA (The Composable Architecture) révén — a SwiftUI közösség legnépszerűbb architektúrája. A TCA tulajdonképpen MVI + Redux + Combine. iOS-en az MVI TCA nélkül is megvalósítható ObservableObject és tiszta reducer függvény segítségével. A SwiftUI a megváltoztathatatlan State-tel tökéletesen illeszkedik az MVI-ciklusba.

Hogyan kell tesztelni az MVI-t?

A Reducer unit tesztekkel tesztelhető tiszta függvényként: megadunk egy kezdeti State-et, elküldünk egy Intent-et, ellenőrizzük a végső State-et. A Middleware mock-adatbázissal tesztelhető: ellenőrizzük, hogy LoadUser után meghívódott-e a getUser. ViewModel-teszt: elküldeni egy Intent-et, ellenőrizni a StateFlow-t. Az MVI könnyebben tesztelhető, mint az MVVM, mert a Reducer tiszta függvény rejtett függőségek nélkül.

Összefoglalás

  • MVI (Model-View-Intent) — reaktív minta egyirányú adatfolyammal és egyetlen állapottal
  • Sealed State — garantálja a konzisztenciát a típus szintjén, kizárva az érvénytelen kombinációkat
  • Reducer — tiszta függvény State + Intent → State, tesztelve mock objektumok nélkül
  • Middleware — külön réteg a mellékhatások számára (hálózat, adatbázis, navigáció)
  • MVI vs MVVM — az MVI szigorúbb és kiszámíthatóbb, az MVVM egyszerűbb és gyorsabb
  • Android — MVIKotlin vagy Orbit összetett képernyőkhöz; MVVM az egyszerűekhez
  • iOS — TCA (The Composable Architecture) — MVI szabvány SwiftUI-n

Kulcsrakész mobilalkalmazást fejlesztünk

Az IT Sectr 2017 óta készít iOS és Android alkalmazásokat induló vállalkozásoknak és vállalkozásoknak. Tanácsot adunk, és a legjobb megoldást javasoljuk.

Projekt megbeszélése

Olvassa el is