Immutability First: Unveränderliche Modelle zuerst
Immutable Modelle machen UI-State berechenbarer. Du lernst, warum das Android-Code sicherer und testbarer macht.
Wenn du Android-Apps baust, arbeitest du ständig mit Zustand: geladene Daten, Eingaben, Ladeanzeigen, Fehlermeldungen und ausgewählte UI-Elemente. Immutability First ist die Gewohnheit, diesen Zustand zuerst als unveränderliche Werte zu modellieren. Dadurch wird dein Code nicht automatisch perfekt, aber er wird leichter zu lesen, zu testen und zu prüfen, besonders wenn Compose, ViewModels, Coroutines und Flow zusammenarbeiten.
Was ist das?
Immutability First bedeutet: Du bevorzugst Modelle, die nach ihrer Erstellung nicht mehr verändert werden. In Kotlin heißt das oft val statt var, data class statt lose veränderter Felder und copy() statt direkter Mutation. Ein Objekt beschreibt dann einen konkreten Zustand zu einem konkreten Zeitpunkt. Wenn sich etwas ändert, erzeugst du einen neuen Zustand.
Das ist im Android-Kontext wichtig, weil UI nicht stillsteht. Daten kommen aus Repositories, Nutzer tippen in Felder, Netzwerkaufrufe liefern Ergebnisse, und Compose rendert die Oberfläche erneut, wenn State sich ändert. Wenn derselbe Zustand an mehreren Stellen veränderbar ist, musst du gedanklich verfolgen, wer ihn wann geändert hat. Genau dort entstehen schwer auffindbare Fehler.
Das mentale Modell ist einfach genug für den Einstieg: Behandle UI-State wie ein Foto, nicht wie eine Tafel. Ein Foto zeigt einen Zustand. Für eine Änderung machst du ein neues Foto. Du überschreibst nicht heimlich einzelne Pixel des alten Fotos. Diese Sicht hilft dir beim Reasoning, also beim Nachvollziehen, warum die App gerade so aussieht, wie sie aussieht.
Unveränderlichkeit ist dabei keine religiöse Regel. Android-Code braucht weiterhin veränderliche Stellen, etwa in MutableStateFlow, Datenbanken, Caches oder internen Buildern. Immutability First heißt aber, dass du die veränderlichen Stellen bewusst begrenzt. Nach außen gibst du möglichst unveränderliche Daten weiter. Besonders in der UI-Schicht ist das wertvoll, weil sie Zustände anzeigen und Nutzeraktionen weiterleiten soll, statt an vielen Stellen selbst Daten zu verändern.
Wie funktioniert es?
In modernem Android sieht ein typischer Datenfluss so aus: Ein ViewModel hält den aktuellen UI-State, verarbeitet Ereignisse und veröffentlicht neue Zustände. Die Compose-Oberfläche liest diesen State und ruft Callbacks auf, wenn der Nutzer etwas tut. Das ViewModel entscheidet dann, welcher neue Zustand entsteht.
Der wichtige Punkt ist die Richtung. Die UI verändert nicht direkt ein Feld in einem Modell. Sie sagt: „Der Nutzer hat diesen Button gedrückt“ oder „dieser Text wurde eingegeben“. Das ViewModel erzeugt daraus einen neuen State. Dadurch bleibt die Verantwortung klar verteilt. Compose kann besser erkennen, dass sich ein Wert geändert hat, und du kannst im Test prüfen, ob auf ein Ereignis der richtige Zustand folgt.
Kotlin unterstützt dieses Vorgehen gut. Eine data class eignet sich für UI-State, weil sie Werte bündelt, lesbar ausgibt, strukturelle Gleichheit mitbringt und mit copy() gezielt verändert werden kann. val sorgt dafür, dass Eigenschaften nicht nachträglich überschrieben werden. Listen sollten nach außen als List<T> sichtbar sein, nicht als MutableList<T>. So kann ein Consumer die Liste nicht unbemerkt verändern.
Bei Compose ist das besonders relevant. Compose reagiert auf State-Änderungen. Wenn du aber eine veränderliche Liste innerhalb eines State-Objekts still änderst, kann die UI unter Umständen nicht sauber erkennen, dass ein neuer Zustand vorliegt. Du siehst dann veraltete Werte, unerwartete Recomposition oder Tests, die nur manchmal fehlschlagen. Ein neuer State-Wert ist klarer: alte Liste plus Änderung ergibt neue Liste, neues State-Objekt, neue UI.
Auch bei Coroutines und Flow hilft Immutability. Nebenläufigkeit wird schwieriger, wenn mehrere Coroutines dasselbe Objekt verändern. Wenn jede Änderung einen neuen Wert produziert, sinkt die Gefahr, dass zwei Abläufe sich gegenseitig verdeckt überschreiben. Du musst trotzdem sauber mit update, Scopes und Fehlerfällen umgehen, aber die Daten selbst sind stabiler.
Die Architektur-Ebene passt dazu: Die UI-Schicht sollte UI-State darstellen. Dieser State ist eine für den Bildschirm passende Beschreibung, nicht zwingend dasselbe Modell wie deine Datenbank-Entität oder dein Netzwerk-DTO. Ein gutes UI-State-Modell enthält zum Beispiel isLoading, items, errorMessage und selectedItemId. Wenn es unveränderlich ist, kannst du in Code-Reviews leichter prüfen, welche Ereignisse welchen Zustand erzeugen.
In der Praxis
Stell dir einen Bildschirm vor, der Aufgaben lädt, eine Auswahl erlaubt und einen Fehler anzeigen kann. Eine mutable Variante würde vielleicht eine Liste direkt verändern und zusätzlich mehrere einzelne Variablen in der UI halten. Das funktioniert am Anfang, wird aber schnell unübersichtlich. Besser ist ein einzelnes unveränderliches State-Modell.
data class TaskUiState(
val isLoading: Boolean = false,
val tasks: List<TaskItem> = emptyList(),
val selectedTaskId: String? = null,
val errorMessage: String? = null
)
data class TaskItem(
val id: String,
val title: String,
val isDone: Boolean
)
class TaskViewModel(
private val repository: TaskRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(TaskUiState())
val uiState: StateFlow<TaskUiState> = _uiState.asStateFlow()
fun selectTask(id: String) {
_uiState.update { current ->
current.copy(selectedTaskId = id)
}
}
fun markDone(id: String) {
_uiState.update { current ->
current.copy(
tasks = current.tasks.map { task ->
if (task.id == id) task.copy(isDone = true) else task
}
)
}
}
fun loadTasks() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
runCatching { repository.loadTasks() }
.onSuccess { loadedTasks ->
_uiState.update {
it.copy(
isLoading = false,
tasks = loadedTasks,
errorMessage = null
)
}
}
.onFailure { error ->
_uiState.update {
it.copy(
isLoading = false,
errorMessage = error.message ?: "Aufgaben konnten nicht geladen werden."
)
}
}
}
}
}
In Compose würdest du diesen State lesen und anzeigen. Die Composable-Funktion sollte nicht selbst in tasks herumändern. Sie bekommt den aktuellen Zustand und ruft Funktionen wie onTaskClick oder onMarkDone auf. Das macht die Oberfläche vorhersehbarer.
@Composable
fun TaskScreen(
uiState: TaskUiState,
onTaskClick: (String) -> Unit,
onMarkDone: (String) -> Unit
) {
when {
uiState.isLoading -> {
CircularProgressIndicator()
}
uiState.errorMessage != null -> {
Text(text = uiState.errorMessage)
}
else -> {
LazyColumn {
items(uiState.tasks, key = { it.id }) { task ->
Row {
Text(
text = task.title,
modifier = Modifier.weight(1f)
)
Checkbox(
checked = task.isDone,
onCheckedChange = { checked ->
if (checked) onMarkDone(task.id)
}
)
}
}
}
}
}
}
Die wichtigste Entscheidungsregel lautet: Alles, was die UI beschreibt, sollte als unveränderlicher UI-State nach außen gehen. Alles, was den State ändern darf, bleibt in einer klaren Besitzerklasse, meist im ViewModel. Nutze intern MutableStateFlow, aber veröffentliche StateFlow. Nutze intern bei Bedarf veränderliche Hilfsstrukturen, aber gib nach außen List, nicht MutableList, zurück.
Eine typische Stolperfalle ist diese Zeile in ähnlicher Form: current.tasks.add(newTask). Wenn tasks eine MutableList ist, veränderst du dieselbe Liste. Das kann dazu führen, dass andere Teile des Codes dieselbe Referenz sehen, ohne dass ein neuer UI-State entstanden ist. Besser ist: current.copy(tasks = current.tasks + newTask). Damit erzeugst du eine neue Liste und ein neues State-Objekt.
Eine zweite Stolperfalle ist zu feine State-Verteilung. Wenn du fünf einzelne mutableStateOf-Variablen für einen Bildschirm verteilst, musst du prüfen, ob alle Kombinationen gültig sind. Kann isLoading = true sein, während gleichzeitig ein alter Fehler sichtbar bleibt? Kann eine Auswahl auf ein Element zeigen, das nicht mehr in der Liste ist? Ein zusammenhängendes UI-State-Modell zwingt dich, solche Beziehungen bewusster zu behandeln.
Für Tests ist Immutability First praktisch. Du kannst ein ViewModel-Ereignis auslösen und danach den erwarteten State vergleichen. Weil data class strukturelle Gleichheit unterstützt, ist ein Test oft direkt lesbar: Nach markDone("42") sollte genau das Element mit dieser ID erledigt sein. Du musst nicht prüfen, ob irgendwo dieselbe Liste heimlich verändert wurde.
In Code-Reviews kannst du dir drei Fragen stellen. Erstens: Wird ein UI-State-Objekt nach außen unveränderlich angeboten? Zweitens: Gibt es MutableList, var oder öffentlich veränderbare Felder in Modellen, die eigentlich State beschreiben? Drittens: Entsteht bei jeder fachlichen Änderung ein neuer State, oder wird ein bestehendes Objekt versteckt angepasst? Diese Fragen decken viele Fehler früh auf.
Auch Debugging wird klarer. Wenn du Log-Ausgaben oder Breakpoints im ViewModel setzt, siehst du eine Folge von Zuständen: leer, lädt, geladen, Fehler, Auswahl geändert. Diese Abfolge ist viel leichter zu verstehen als ein Objekt, dessen innere Felder an mehreren Stellen geändert werden. Gerade als Lernender baust du damit ein Gefühl für Datenfluss auf, das später bei größeren Apps sehr wichtig wird.
Fazit
Immutability First gibt dir eine klare Arbeitsregel für Android-State: Beschreibe Zustände als Werte, ändere sie kontrolliert an einer verantwortlichen Stelle und gib nach außen unveränderliche Modelle weiter. Dadurch werden Compose-UI, ViewModel-Logik, Flow-basierte Datenströme und Tests besser nachvollziehbar. Prüfe das aktiv in deinem nächsten Bildschirm: Ersetze verstreute var-Felder durch eine data class, nutze copy() für Änderungen, setze Breakpoints auf jede State-Aktualisierung und schreibe einen kleinen Test für ein Nutzerereignis. Wenn du danach die Zustandsfolge erklären kannst, hast du das Prinzip verstanden.