MVI (Model-View-Intent) — ett reaktivt arkitekturmönster baserat på enkelriktat dataflöde och oföränderligt tillstånd. Till skillnad från MVVM, där ViewModel kan ha flera StateFlow, definierar MVI ett enda tillstånd (State), oföränderliga avsikter (Intent) och en ren reducerfunktion (Reducer). MVI garanterar förutsägbarhet av skärmens tillstånd när som helst. Mönstret populariserades i Android-gemenskapen av biblioteken Mosby och Orbit. Mer — i MVIKotlin från Arkadii Ivanov.
Huvudsakliga
MVI (Model-View-Intent) — ett reaktivt arkitekturmönster byggt på principerna från Redux och Cycle.js. Model — skärmens oföränderliga tillstånd, Intent — användarens eller systemets avsikt, View — prenumeration på tillståndet och sändning av Intent. Data rör sig i en cykel: användaren interagerar med View → View skapar en Intent → Intent bearbetas av Reducer → Reducer skapar ett nytt tillstånd → View tar emot det nya tillståndet och ritas om.
Huvudskillnaden mellan MVI och MVVM — en enda källa till sanning (Single Source of Truth). I MVVM kan ViewModel ha flera LiveData/StateFlow (userState, loadingState, errorState), vilket leder till inkonsekvens: loading=true och user=null samtidigt. I MVI finns det exakt en sealed class/interface State som beskriver hela skärmens tillstånd. När som helst är skärmens tillstånd unikt bestämt — det är omöjligt att få loading=true när data redan har laddats. På IT Sectr tillämpar vi MVI för skärmar med komplex logik — beställningsformulär, flerstegsregistreringar, finansiella skärmar — där förutsägbarhet av tillståndet är avgörande.
| Komponent | Roll i MVI | Exempel |
|---|---|---|
| Intent | Användarens eller systemets avsikt | LoadUser, Refresh, SubmitForm |
| State | Skärmens oföränderliga tillstånd | sealed class UserState |
| Reducer | Ren funktion: State + Intent → State | fun reduce(state, intent) -> state |
| Middleware | Hantering av sidoeffekter | Nätverksbegäran, skrivning till DB |
MVI-cykeln består av fem steg: 1) View skickar en Intent (t.ex. LoadUser(42)); 2) Middleware (EffectHandler) utför sidoeffekten — nätverksbegäran; 3) Resultatet återkommer som en ny Intent i systemet; 4) Reducer tar emot aktuellt tillstånd och Intent, skapar ett nytt tillstånd; 5) View tar emot det nya tillståndet och ritas om. Varje steg är förutsägbart och testas isolerat.
MVI på Android implementeras via sealed-klasser för Intent och State, ViewModel med MVI-logik och Jetpack Compose för reaktiv visning. ViewModel tar emot Intent från View, delegerar sidoeffekter till Middleware, kör Reducer och publicerar det nya tillståndet via StateFlow. Jetpack Compose ritar om UI när state ändras — idealiskt för MVI-cykeln.
// Intent — användarens avsikter
sealed interface UserIntent {
data class LoadUser(val userId: Int) : UserIntent
data object Refresh : UserIntent
}
// State — skärmens enda tillstånd
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 — ren funktion
object UserReducer {
fun reduce(state: UserState, intent: UserIntent): UserState = when (intent) {
is UserIntent.LoadUser -> UserState.Loading
is UserIntent.Refresh -> UserState.Loading
}
}
// ViewModel med 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(/* föregående 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 skickar 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 och sidoeffekter — i MVI kan den rena Reducer inte utföra nätverksbegäranden. Middleware (även EffectHandler eller Bootstrapper) bearbetar Intent, utför sidoeffekten och sänder en ny Intent tillbaka i cykeln. Biblioteken Orbit MVI och MVIKotlin tillhandahåller inbyggt stöd för Middleware med testbara effekter. Utan Middleware degenererar MVI till MVVM med extra Intent- och State-struktur.
MVIKotlin från Arkadii Ivanov — det populäraste MVI-biblioteket för Kotlin Multiplatform. Stöder Android, iOS, web och JVM. Tillhandahåller komponenter: Store (ViewModel), Bootstrapper (initiala effekter), Reducer, Middleware. I oktober 2025 har biblioteket samlat 2,5K stjärnor på GitHub och används i kommersiella projekt, inklusive applikationer från stora ryska banker. På IT Sectr använder vi MVIKotlin för cross-platform KMP-projekt med delad affärslogik.
MVI på iOS implementeras utan Combine-ViewModel, via Intent → State-cykeln. View skickar Intent via en closure, Reducer — ren funktion, State — struct med oföränderliga fält. SwiftUI ritar om View när State ändras, vilket passar perfekt i MVI-cykeln utan ytterligare @Published-egenskaper. MVI på iOS är särskilt populärt i gemenskapen av SwiftUI-utvecklare som har gått över från Redux (JavaScript).
// State — oföränderlig struktur
struct UserState: Equatable {
var user: User?
var isLoading = false
var errorMessage: String?
}
// Intent — enum med avsikter
enum UserIntent {
case loadUser(id: Int)
case refresh
case userLoaded(User)
case loadFailed(Error)
}
// Reducer — ren funktion
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 — äger tillståndet och hanterar effekter
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 uppdaterar tillståndet
state = userReducer(state: state, intent: intent)
// 2. Side effects (om det behövs)
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) — den populäraste MVI-implementeringen för iOS från Point-Free, byggd på SwiftUI och Combine. TCA tillhandahåller Store, Reducer, Effect och Environment. I oktober 2025 överstiger antalet stjärnor på GitHub 13K — detta är de facto-standarden för MVI på iOS. TCA används i applikationerna Starbucks, Airbnb (delvis) och många indie-projekt. Till skillnad från handskriven MVI löser TCA problem med testning, navigering och sidoeffekter direkt ur lådan.
MVI vs MVVM på iOS — TCA/MVI ger förutsägbarhet av tillståndet, men kräver mer mallkod (Reducer, State, Action). MVVM med @Published är enklare för enkla skärmar. Vi på IT Sectr använder MVVM för 80% av skärmarna och MVI (TCA) för 20% komplexa — finansiella transaktioner, flerstegsformulär, drag-and-play-gränssnitt, där ett tillståndsfel kan kosta användaren pengar.
MVI och MVVM löser samma uppgift — organisering av presentationslagret — men med olika metoder för tillståndshantering. MVVM tillåter flera reaktiva källor (LiveData, @Published), vilket kan leda till inkonsekvens. MVI garanterar exakt ett tillstånd varje ögonblick, vilket gör det mer rigoröst och förutsägbart, men ökar kodmängden.
| Kriterium | MVVM | MVI |
|---|---|---|
| Tillstånd | Flera LiveData/StateFlow | En sealed class State |
| Dataflöde | Tvåvägs (View → ViewModel, LiveData → View) | Enkelriktat (Intent → Reducer → State → View) |
| Sidoeffekter | Direkt i ViewModel | Via Middleware/EffectHandler |
| Testning | Unit-tester av ViewModel | Unit-tester av Reducer + Middleware |
| Mallkod | Minimal | Reducer + State + Intent + Middleware |
När ska man välja MVI — skärmar där tillståndet måste vara strikt deterministiskt: finansiella operationer, varukorg i webbutik, flerstegsformulär med validering vid varje steg. I dessa scenarier är kostnaden för ett tillståndsfel (t.ex. att visa varukorgsbeloppet utan en produkt på grund av en kapplöpning mellan två LiveData) högre än kostnaden för extra kod. I MVVM förlitar du dig på teamets disciplin, i MVI — på arkitekturen.
När MVVM är tillräckligt — 80% av standardskärmarna: användarlista, profil, inställningar, nyhetsflöde. Här är ett enda tillstånd överflödigt och den extra MVI-strukturen saktar ner utvecklingen. På IT Sectr är regeln: om skärmen har 3+ möjliga tillstånd med övergångar (laddning → data → fel → försök igen → laddning → data) — MVI. Om skärmen har 1-2 asynkrona operationer — MVVM.
Sealed State — bästa praxis för MVI. Tillståndet definieras som en sealed class/interface med varianterna Loading, Success(data), Error(message). Detta garanterar att View inte hamnar i ett inkonsekvent tillstånd — data kan inte visas vid loading=true, eftersom Loading och Success är olika klasser. All data som hör till tillståndet finns inuti sealed-varianten: Success innehåller användaren, Error — felmeddelandet.
Reducer måste förbli en ren funktion — utan API-anrop, DB, SharedPreferences. Den rena funktionen tar emot State och Intent, returnerar State. Sidoeffekter (nätverk, DB, navigering, toasts) hanteras i Middleware eller i Store.dispatch efter anrop av Reducer. Om Reducer är förorenad med sidoeffekter förlorar MVI sin testbarhet och förutsägbarhet — du får MVVM med extra struktur utan fördelar.
Typiska misstag — att deklarera State som en data class med nullable fält istället för en sealed class: data class UserState(val user: User?, val isLoading: Boolean, val error: String?). Detta är motsvarigheten till MVVM, inte MVI — View måste kontrollera fältkombinationer för giltighet. I sealed-metoden är ogiltiga kombinationer (isLoading=true och user!=null) omöjliga på typnivå. Andra misstaget — att placera affärslogik i Intent (Intent.LoadUserBeforeXHours) istället för att skapa enkla kommandon Intent (Intent.LoadUser) och affärslogiken — i Middleware.
Vanliga frågor
MVI använder en enda oföränderlig sealed State-klass och enkelriktat dataflöde via Reducer. MVVM tillåter flera LiveData/StateFlow med tvåvägsbindning. MVI garanterar tillståndskonsistens på typnivå — det är omöjligt att få loading=true och user=null samtidigt. MVVM förlitar sig på utvecklarens disciplin.
Huvudsakliga: MVIKotlin (Arkadii Ivanov, 2,5K stjärnor, Kotlin Multiplatform), Orbit MVI (BabyJ, 1,3K stjärnor), Mobius (Spotify, Kotlin/Java). MVIKotlin — mest populärt för Kotlin, Orbit — enklast att lära sig. Alla tre stöder testbar Reducer och Middleware. För Jetpack Compose räcker det att skriva en enkel MVI utan bibliotek via sealed State + Reducer.
Nej — sealed Intent + sealed State + ViewModel + StateFlow ger en fungerande MVI utan beroenden. Bibliotek (MVIKotlin, Orbit, TCA) lägger till Middleware, testning av sidoeffekter och integration med DI. För enkla projekt är bibliotekets vikt oberättigad. För komplexa projekt med 20+ skärmar lönar sig biblioteket genom strukturerad effekthantering.
MVI passar utmärkt för iOS via TCA (The Composable Architecture) — den populäraste arkitekturen i SwiftUI-gemenskapen. TCA är i själva verket MVI + Redux + Combine. På iOS kan MVI implementeras utan TCA via ObservableObject och en ren reducer-funktion. SwiftUI med immutable State passar perfekt i MVI-cykeln.
Reducer testas med unit-tester som en ren funktion: ett initialt State ges, en Intent skickas, det slutliga State kontrolleras. Middleware testas med ett mock-repository: man kontrollerar att getUser anropades efter LoadUser. ViewModel-test: skicka en Intent, kontrollera StateFlow. MVI är lättare att testa än MVVM, eftersom Reducer är en ren funktion utan dolda beroenden.
Sammanfattning
Vi utvecklar en mobil applikation nyckelfärdigt
IT Sectr skapar iOS- och Android-applikationer för startups och företag sedan 2017. Vi ger dig råd och föreslår den bästa lösningen.
Läs också