Android Coden
Android 4 min lesen

MVI und Reducer: Zustandsübergänge sauber modellieren

MVI strukturiert Android-Apps mit Intents, Reducern und unveränderlichem Zustand. So werden komplexe Zustandsübergänge nachvollziehbar und testbar.

MVI (Model-View-Intent) ist ein Architekturmuster, das komplexe Zustandsverwaltung durch drei klar abgegrenzte Bausteine beherrschbar macht: Intents beschreiben Nutzerabsichten, ein Reducer berechnet den nächsten Zustand, und eine unveränderliche State-Klasse trägt das vollständige aktuelle UI-Bild. Das klingt auf den ersten Blick aufwändiger als MVVM – und für einfache Bildschirme stimmt das auch. Sobald aber Formulare, Ladezustände, Fehlermeldungen und Nutzerinteraktionen gleichzeitig zusammenspielen, wird MVI zur verlässlicheren Wahl.

Was ist das?

MVI steht für Model-View-Intent und gehört zur Familie der unidirektionalen Datenflussmuster. Es wurde durch Cycle.js und Redux im Web-Bereich populär und hat im Android-Ökosystem – besonders seit Jetpack Compose – starken Rückenwind bekommen, weil Compose selbst auf unveränderlichem Zustand und Rekomposition aufbaut.

Das Muster definiert drei Rollen:

  • Intent – ein Sealed Interface oder eine Sealed Class, die beschreibt, was der Nutzer tun möchte oder was das System ankündigt (z. B. ButtonClicked, DataLoaded, ErrorOccurred).
  • State – eine unveränderliche Data Class, die den vollständigen Zustand der UI zu einem bestimmten Zeitpunkt enthält. Kein verstecktes Mutable-Feld, keine optionalen Seitenkanäle.
  • Reducer – eine pure Funktion der Form (State, Intent) -> State. Sie enthält keinerlei Seiteneffekte; dasselbe Eingabepaar liefert immer dasselbe Ausgabe-State-Objekt.

Wichtig: MVI ist kein Framework, sondern ein Muster. Es lässt sich direkt in ein ViewModel mit StateFlow einbauen, ohne externe Abhängigkeiten einzuführen.

Wie funktioniert es?

Der Datenfluss ist strikt unidirektional und folgt immer derselben Reihenfolge:

  1. Die UI sendet einen Intent an das ViewModel (z. B. per Aufruf einer processIntent()-Methode).
  2. Das ViewModel leitet den Intent zusammen mit dem aktuellen State in den Reducer.
  3. Der Reducer gibt einen neuen State zurück – unveränderlich und vollständig.
  4. Das ViewModel publiziert den neuen State über StateFlow.
  5. Compose sammelt den State und rendert die UI neu.

Weil jeder Zustandsübergang durch eine einzige Funktion läuft, gibt es keine versteckten Mutationen. Du kannst jeden Übergang als isolierte Einheit testen: assertThat(reduce(currentState, SomeIntent)).isEqualTo(expectedState).

Kotlin Sealed Interfaces eignen sich hervorragend für Intents:

sealed interface LoginIntent {
    data class EmailChanged(val email: String) : LoginIntent
    data class PasswordChanged(val password: String) : LoginIntent
    data object SubmitClicked : LoginIntent
}

Der State als Data Class:

data class LoginState(
    val email: String = "",
    val password: String = "",
    val isLoading: Boolean = false,
    val errorMessage: String? = null
)

Der Reducer als reine Funktion ohne Seiteneffekte:

fun reduce(state: LoginState, intent: LoginIntent): LoginState = when (intent) {
    is LoginIntent.EmailChanged ->
        state.copy(email = intent.email, errorMessage = null)
    is LoginIntent.PasswordChanged ->
        state.copy(password = intent.password)
    LoginIntent.SubmitClicked ->
        state.copy(isLoading = true, errorMessage = null)
}

Im ViewModel hältst du den State in einem MutableStateFlow und rufst reduce() bei jedem eingehenden Intent auf:

class LoginViewModel : ViewModel() {
    private val _state = MutableStateFlow(LoginState())
    val state: StateFlow<LoginState> = _state.asStateFlow()

    fun processIntent(intent: LoginIntent) {
        _state.update { current -> reduce(current, intent) }
        if (intent is LoginIntent.SubmitClicked) launchLogin()
    }
}

Seiteneffekte wie Netzwerkaufrufe landen außerhalb des Reducers – in Coroutines, die nach dem State-Update gestartet werden. Das hält den Reducer rein und testbar.

In der Praxis

MVI zahlt sich besonders bei Bildschirmen mit mehreren gleichzeitigen Zustandsdimensionen aus: ein Formular mit Validierung, ein Ladezustand, ein Fehlerdialog und ein Erfolgszustand können alle im selben State-Objekt nebeneinanderliegen. Mit MVVM und mehreren separaten LiveData- oder StateFlow-Properties passiert es leicht, dass zwei Properties einen inkonsistenten Momentanzustand zeigen – zum Beispiel isLoading = true und gleichzeitig errorMessage != null, weil ein Update die Race Condition verloren hat.

Typische Stolperfalle: Manche Entwickler versuchen, auch Seiteneffekte (Navigation, Toasts, Snackbars) durch den State zu tunneln, indem sie z. B. val navigateTo: Route? = null in die State-Data-Class aufnehmen. Das führt zu unerwünschten Wiederholungen: Nach einem Konfigurationswechsel liest der neue Collector denselben State und navigiert erneut. Besser: Seiteneffekte als Channel<SideEffect> oder als SharedFlow<SideEffect> separat aussenden – sie sind keine Zustände, sondern einmalige Ereignisse.

Entscheidungsregel: Nutze MVI, wenn ein Bildschirm mehr als drei unabhängig mutierbare Felder hat oder wenn Zustandsübergänge bedingt voneinander abhängen – zum Beispiel „Submit-Button erst aktiv, wenn E-Mail valide und Passwort nicht leer”. Bei simplen Listen oder Detail-Screens mit nur einer Ladezustandsvariable reicht ein schlankes MVVM mit einem einzigen uiState: StateFlow<UiState> völlig aus. Das Muster nur der Form halber einzusetzen kostet mehr als es bringt.

Fazit

MVI bringt Ordnung in Bildschirme, bei denen der Zustandsraum wächst und Übergänge komplex werden. Durch den Reducer als pure Funktion ist jeder Übergang isoliert testbar, durch den unveränderlichen State werden Race Conditions ausgeschlossen, und durch die versiegelten Intents ist auf einen Blick erkennbar, welche Ereignisse die UI überhaupt auslösen kann. Um dein Verständnis zu festigen, baue einen kleinen Login- oder Suchbildschirm nach dem MVI-Muster und schreibe Unit-Tests für den Reducer: Gib bekannte State-Intent-Paare hinein und prüfe, ob der zurückgegebene State exakt deiner Erwartung entspricht. Dieser Test zeigt dir sofort, ob deine Zustandslogik wirklich sauber von Seiteneffekten getrennt ist.

Quellen (3)
Redaktion

Geschrieben von

Redaktion

Das Redaktionsteam recherchiert und schreibt Artikel zu aktuellen Themen rund um Tech, Lifestyle und Ratgeber.