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-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.
| Komponens | Szerep az MVI-ben | Példa |
|---|---|---|
| Intent | Felhasználó vagy rendszer szándéka | LoadUser, Refresh, SubmitForm |
| State | A képernyő megváltoztathatatlan állapota | sealed class UserState |
| Reducer | Tiszta függvény: State + Intent → State | fun reduce(state, intent) -> state |
| Middleware | Mellékhatások feldolgozása | Há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 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.
// 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 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.
// 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.
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érium | MVVM | MVI |
|---|---|---|
| Állapot | Több LiveData/StateFlow | Egyetlen sealed class State |
| Adatfolyam | Kétirányú (View → ViewModel, LiveData → View) | Egyirányú (Intent → Reducer → State → View) |
| Mellékhatások | Közvetlenül a ViewModel-ben | Middleware/EffectHandler-en keresztül |
| Tesztelés | ViewModel unit tesztjei | Reducer + Middleware unit tesztjei |
| Sablonkód | Minimális | Reducer + 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.
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
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.
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.
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.
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.
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
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.
Olvassa el is