MVI (Model-View-Intent) ist ein reaktives Architekturmuster, das auf unidirektionalem Datenfluss und unveränderlichem Zustand basiert. Im Gegensatz zu MVVM, wo eine ViewModel mehrere StateFlows haben kann, definiert MVI einen einzigen Zustand (State), unveränderliche Absichten (Intent) und eine reine Reducer-Funktion (Reducer). MVI garantiert die Vorhersagbarkeit des Bildschirmzustands zu jedem Zeitpunkt. Das Muster wurde in der Android-Community durch die Bibliotheken Mosby und Orbit populär gemacht. Mehr Informationen in MVIKotlin von Arkadii Ivanov.
Wichtige Erkenntnisse
MVI (Model-View-Intent) ist ein reaktives Architekturmuster, das auf den Prinzipien von Redux und Cycle.js aufbaut. Model ist der unveränderliche Bildschirmzustand, Intent ist eine Benutzer- oder Systemabsicht, View abonniert den Zustand und sendet Intents. Daten fließen in einem Zyklus: der Benutzer interagiert mit der View → die View erstellt einen Intent → der Intent wird vom Reducer verarbeitet → der Reducer erstellt einen neuen Zustand → die View erhält den neuen Zustand und rendert neu.
Der Hauptunterschied zwischen MVI und MVVM ist die einzige Quelle der Wahrheit (Single Source of Truth). In MVVM kann eine ViewModel mehrere LiveData/StateFlow (userState, loadingState, errorState) haben, was zu Inkonsistenz führt: loading=true und user=null gleichzeitig. In MVI gibt es genau eine sealed class/interface State, die den gesamten Bildschirmzustand beschreibt. Zu jedem Zeitpunkt ist der Bildschirmzustand eindeutig bestimmt — es ist unmöglich, loading=true zu haben, wenn Daten bereits geladen sind. Bei IT Sectr verwenden wir MVI für Bildschirme mit komplexer Logik — Bestellformulare, mehrstufige Registrierungen, Finanzbildschirme — wo Zustandsvorhersagbarkeit kritisch ist.
| Komponente | Rolle in MVI | Beispiel |
|---|---|---|
| Intent | Benutzer- oder Systemabsicht | LoadUser, Refresh, SubmitForm |
| State | Unveränderlicher Bildschirmzustand | sealed class UserState |
| Reducer | Reine Funktion: State + Intent → State | fun reduce(state, intent) -> state |
| Middleware | Nebeneffektbehandlung | Netzwerkanfrage, DB-Schreibzugriff |
Der MVI-Zyklus besteht aus fünf Schritten: 1) Die View sendet einen Intent (z.B. LoadUser(42)); 2) Middleware (EffectHandler) führt einen Nebeneffekt aus — eine Netzwerkanfrage; 3) Das Ergebnis wird als neuer Intent in das System zurückgegeben; 4) Reducer nimmt den aktuellen Zustand und Intent und erstellt einen neuen Zustand; 5) Die View erhält den neuen Zustand und rendert neu. Jeder Schritt ist vorhersagbar und isoliert testbar.
MVI auf Android wird mit sealed-Klassen für Intent und State, einer ViewModel mit MVI-Logik und Jetpack Compose für reaktives Rendering implementiert. Die ViewModel empfängt Intent von der View, delegiert Nebeneffekte an Middleware, führt den Reducer aus und veröffentlicht den neuen Zustand über StateFlow. Jetpack Compose rendert die UI neu, wenn sich der Zustand ändert — ideal für den MVI-Zyklus.
// Intent — Benutzerabsichten
sealed interface UserIntent {
data class LoadUser(val userId: Int) : UserIntent
data object Refresh : UserIntent
}
// State — einheitlicher Bildschirmzustand
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 — reine Funktion
object UserReducer {
fun reduce(state: UserState, intent: UserIntent): UserState = when (intent) {
is UserIntent.LoadUser -> UserState.Loading
is UserIntent.Refresh -> UserState.Loading
}
}
// ViewModel mit 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(/* vorherige 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 sendet 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 und Nebeneffekte — in MVI kann ein reiner Reducer keine Netzwerkanfragen durchführen. Middleware (auch EffectHandler oder Bootstrapper) verarbeitet den Intent, führt den Nebeneffekt aus und sendet einen neuen Intent zurück in den Zyklus. Die Bibliotheken Orbit MVI und MVIKotlin bieten integrierte Middleware-Unterstützung mit testbaren Effekten. Ohne Middleware degeneriert MVI zu MVVM mit zusätzlicher Intent- und State-Struktur.
MVIKotlin von Arkadii Ivanov ist die beliebteste MVI-Bibliothek für Kotlin Multiplatform. Sie unterstützt Android, iOS, Web und JVM. Sie bietet Komponenten: Store (ViewModel), Bootstrapper (initiale Effekte), Reducer, Middleware. Stand Oktober 2025 hat die Bibliothek 2,5K Sterne auf GitHub und wird in kommerziellen Projekten eingesetzt, darunter Anwendungen großer russischer Banken. Bei IT Sectr verwenden wir MVIKotlin für plattformübergreifende KMP-Projekte mit gemeinsamer Geschäftslogik.
MVI auf iOS wird ohne Combine-ViewModel, über den Intent → State-Zyklus implementiert. Die View sendet einen Intent über einen Closure, der Reducer ist eine reine Funktion, und State ist eine Struktur mit unveränderlichen Feldern. SwiftUI rendert die View neu, wenn sich State ändert, was perfekt in den MVI-Zyklus ohne zusätzliche @Published-Eigenschaften passt. MVI auf iOS ist besonders beliebt bei SwiftUI-Entwicklern, die von Redux (JavaScript) umgestiegen sind.
// State — unveränderliche Struktur
struct UserState: Equatable {
var user: User?
var isLoading = false
var errorMessage: String?
}
// Intent — enum mit Absichten
enum UserIntent {
case loadUser(id: Int)
case refresh
case userLoaded(User)
case loadFailed(Error)
}
// Reducer — reine 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 — besitzt Zustand und verwaltet Effekte
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 aktualisiert Zustand
state = userReducer(state: state, intent: intent)
// 2. Side effects (falls nötig)
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) ist die beliebteste MVI-Implementierung für iOS von Point-Free, aufgebaut auf SwiftUI und Combine. TCA bietet Store, Reducer, Effect und Environment. Stand Oktober 2025 übersteigen ihre GitHub-Sterne 13K — es ist der De-facto-Standard für MVI auf iOS. TCA wird in Starbucks-Apps, Airbnb (teilweise) und vielen Indie-Projekten verwendet. Im Gegensatz zu benutzerdefiniertem MVI adressiert TCA Tests, Navigation und Nebeneffekte sofort.
MVI vs MVVM auf iOS — TCA/MVI bietet Zustandsvorhersagbarkeit, erfordert aber mehr Boilerplate-Code (Reducer, State, Action). MVVM mit @Published ist für einfache Bildschirme einfacher. Bei IT Sectr verwenden wir MVVM für 80% der Bildschirme und MVI (TCA) für 20% komplexe — Finanztransaktionen, mehrstufige Formulare, Drag-and-Drop-Schnittstellen, wo ein Zustandsfehler den Benutzer Geld kosten könnte.
MVI und MVVM lösen dasselbe Problem — die Organisation der Präsentationsschicht — aber mit unterschiedlichen Ansätzen zur Zustandsverwaltung. MVVM erlaubt mehrere reaktive Quellen (LiveData, @Published), was zu Inkonsistenz führen kann. MVI garantiert genau einen Zustand zu jedem Zeitpunkt, was es strenger und vorhersagbarer macht, aber die Code-Menge erhöht.
| Kriterium | MVVM | MVI |
|---|---|---|
| Zustand | Mehrere LiveData/StateFlow | Einzelne sealed class State |
| Datenfluss | Bidirektional (View → ViewModel, LiveData → View) | Unidirektional (Intent → Reducer → State → View) |
| Nebeneffekte | Direkt in ViewModel | Über Middleware/EffectHandler |
| Tests | Unit-Tests für ViewModel | Unit-Tests für Reducer + Middleware |
| Boilerplate-Code | Minimal | Reducer + State + Intent + Middleware |
Wann MVI wählen — Bildschirme, bei denen der Zustand streng deterministisch sein muss: Finanzoperationen, Einkaufswagen, mehrstufige Formulare mit Validierung in jedem Schritt. In diesen Szenarien überwiegen die Kosten eines Zustandsfehlers (z.B. Anzeige des Warenkorbgesamtbetrags ohne einen Artikel aufgrund einer Racebedingung zwischen zwei LiveData) die Kosten für zusätzlichen Code. Bei MVVM verlassen Sie sich auf Teamdisziplin, bei MVI auf die Architektur.
Wann MVVM ausreicht — 80% der Standardbildschirme: Benutzerliste, Profil, Einstellungen, Nachrichtenfeed. Hier ist ein einzelner Zustand übertrieben und die zusätzliche MVI-Struktur verlangsamt die Entwicklung. Bei IT Sectr gilt die Regel: Wenn ein Bildschirm 3+ mögliche Zustände mit Übergängen hat (Laden → Daten → Fehler → Wiederholen → Laden → Daten) — verwenden Sie MVI. Wenn ein Bildschirm 1-2 asynchrone Operationen hat — verwenden Sie MVVM.
Sealed State — Best Practice in MVI. Der Zustand wird als sealed class/interface mit Varianten definiert: Loading, Success(data), Error(message). Dies garantiert, dass die View nicht in einen inkonsistenten Zustand gerät — Daten können nicht angezeigt werden, wenn loading=true ist, da Loading und Success verschiedene Klassen sind. Alle zustandsbezogenen Daten befinden sich innerhalb der sealed-Variante: Success enthält den Benutzer, Error enthält die Fehlermeldung.
Der Reducer muss eine reine Funktion bleiben — ohne API-, DB- oder SharedPreferences-Aufrufe. Eine reine Funktion nimmt State und Intent und gibt State zurück. Nebeneffekte (Netzwerk, DB, Navigation, Toasts) werden in Middleware oder in Store.dispatch nach dem Aufruf des Reducers behandelt. Wenn der Reducer mit Nebeneffekten verunreinigt wird, verliert MVI Testbarkeit und Vorhersagbarkeit — Sie erhalten MVVM mit zusätzlicher Struktur ohne Vorteile.
Häufige Fehler — Deklarieren von State als data class mit nullable-Feldern anstelle einer sealed class: data class UserState(val user: User?, val isLoading: Boolean, val error: String?). Dies ist äquivalent zu MVVM, nicht MVI — die View muss Feldkombinationen auf Gültigkeit prüfen. Im sealed-Ansatz sind ungültige Kombinationen (isLoading=true und user!=null) auf Typebene unmöglich. Der zweite Fehler ist das Platzieren von Geschäftslogik in Intent (Intent.LoadUserBeforeXHours) anstatt einfache Befehls-Intents (Intent.LoadUser) zu erstellen und die Geschäftslogik in Middleware zu platzieren.
Häufig gestellte Fragen
MVI verwendet eine einzige unveränderliche sealed State-Klasse und unidirektionalen Datenfluss durch einen Reducer. MVVM erlaubt mehrere LiveData/StateFlow mit bidirektionaler Bindung. MVI garantiert Zustandskonsistenz auf Typebene — es ist unmöglich, loading=true und user=null gleichzeitig zu haben. MVVM verlässt sich auf die Disziplin des Entwicklers.
Hauptsächlich: MVIKotlin (Arkadii Ivanov, 2,5K Sterne, Kotlin Multiplatform), Orbit MVI (BabyJ, 1,3K Sterne), Mobius (Spotify, Kotlin/Java). MVIKotlin ist die beliebteste für Kotlin, Orbit ist am einfachsten zu lernen. Alle drei unterstützen testbare Reducer und Middleware. Für Jetpack Compose können Sie einfaches MVI ohne Bibliothek mit sealed State + Reducer schreiben.
Nein — sealed Intent + sealed State + ViewModel + StateFlow ergeben funktionierendes MVI ohne Abhängigkeiten. Bibliotheken (MVIKotlin, Orbit, TCA) fügen Middleware, Nebeneffekttests und DI-Integration hinzu. Für einfache Projekte ist das Bibliotheksgewicht ungerechtfertigt. Für komplexe Projekte mit 20+ Bildschirmen zahlt sich die Bibliothek durch strukturierte Effektbehandlung aus.
MVI funktioniert hervorragend für iOS über TCA (The Composable Architecture) — die beliebteste Architektur der SwiftUI-Community. TCA ist im Wesentlichen MVI + Redux + Combine. Auf iOS können Sie MVI ohne TCA mit ObservableObject und einer reinen Reducer-Funktion implementieren. SwiftUI mit unveränderlichem State passt perfekt in den MVI-Zyklus.
Der Reducer wird mit Unit-Tests als reine Funktion getestet: initialen State setzen, Intent senden, resultierenden State prüfen. Middleware wird mit einem Mock-Repository getestet: überprüfen, ob getUser nach LoadUser aufgerufen wurde. ViewModel-Test: Intent senden, StateFlow prüfen. MVI ist einfacher zu testen als MVVM, weil der Reducer eine reine Funktion ohne versteckte Abhängigkeiten ist.
Zusammenfassung
Wir entwickeln eine mobile Applikation schlüsselfertig
IT Sectr entwickelt seit 2017 iOS- und Android-Apps für Startups und Unternehmen. Wir beraten Sie und schlagen die beste Lösung vor.
Lesen Sie auch