Read-only- und Mutable-Collections in Kotlin
Du lernst, wann read-only Collections reichen und wann MutableList passt. So vermeidest du ungewollte Zustandsänderungen.
Collections sind in Android-Apps überall: Listen aus einer API, Einträge in einer Datenbank, UI-Modelle für Compose, Validierungsfehler in einem Formular. Die wichtige Frage ist nicht nur, welche Daten darin liegen, sondern auch, wer diese Daten ändern darf. Wenn du read-only Interfaces bevorzugst und Mutable Collections gezielt kapselst, reduzierst du Nebenwirkungen und machst deinen Code leichter testbar.
Was ist das?
In Kotlin gibt es bei Collections eine klare Unterscheidung zwischen read-only und mutable. Eine read-only Collection wie List<T>, Set<T> oder Map<K, V> erlaubt dir, Werte zu lesen, aber nicht über dieses Interface zu ändern. Eine mutable Collection wie MutableList<T>, MutableSet<T> oder MutableMap<K, V> erlaubt zusätzlich Operationen wie add, remove, clear oder das Überschreiben eines Elements.
Wichtig ist das mentale Modell: Read-only bedeutet in Kotlin nicht automatisch, dass die zugrunde liegenden Daten technisch unveränderlich sind. Es bedeutet zuerst, dass du über diese Referenz keine Änderungsfunktionen aufrufen kannst. Wenn dieselbe Liste intern weiterhin als MutableList existiert, kann sie an anderer Stelle trotzdem verändert werden. Trotzdem ist das read-only Interface sehr wertvoll, weil es die Absicht deiner API ausdrückt: Wer diese Daten bekommt, soll sie konsumieren, nicht verändern.
Im Android-Kontext ist diese Trennung ein Baustein für Safety. ViewModels, Repositorys und Use Cases arbeiten oft mit Daten, die über mehrere Schichten fließen. Wenn jede Schicht eine MutableList weiterreicht, kann jeder Aufrufer den Zustand verändern. Das macht Fehler schwer zu finden, besonders wenn UI, Hintergrundarbeit und Tests denselben Datenfluss berühren. Gibst du dagegen List nach außen und hältst MutableList nur intern, wird klarer, wo Änderungen entstehen dürfen.
Wie funktioniert es?
Kotlin trennt Collection-Typen über Interfaces. List enthält Leseoperationen wie size, get, contains und Iteration. MutableList erweitert dieses Modell um Schreiboperationen. Dadurch kann eine Funktion sehr präzise ausdrücken, was sie braucht. Eine Funktion, die nur Elemente anzeigen oder filtern soll, sollte List<Article> akzeptieren. Eine Funktion, die Elemente sortiert, entfernt oder ergänzt, braucht entweder eine MutableList<Article> oder sollte eine neue Liste zurückgeben.
Diese Entscheidung beeinflusst Architektur. In modernen Android-Apps ist es üblich, Datenfluss nach außen stabil zu halten. Ein ViewModel kann intern veränderbaren Zustand verwalten, aber nach außen nur read-only State anbieten. In Compose ist das besonders relevant: Die UI soll aus State gerendert werden. Wenn du eine Liste heimlich mutierst, ohne einen neuen beobachtbaren State-Wert zu setzen, kann Compose unter Umständen nicht so reagieren, wie du es erwartest. Der Fehler wirkt dann wie ein UI-Problem, obwohl die Ursache im Datenmodell liegt.
Ein nützliches Prinzip lautet: Mutationen gehören an kontrollierte Stellen. Das kann ein Repository sein, das einen Cache aktualisiert. Es kann ein ViewModel sein, das auf eine Nutzeraktion reagiert. Es sollte aber nicht irgendeine Composable-Funktion oder ein beliebiger Helper sein, der zufällig Zugriff auf eine MutableList bekommen hat. Je kleiner der Bereich ist, in dem Veränderung erlaubt ist, desto einfacher ist das Debugging.
Außerdem hilft dir read-only Code beim Testen. Tests werden klarer, wenn Eingaben nicht während der Ausführung verändert werden. Eine Funktion, die eine List annimmt und eine neue List zurückgibt, ist meist leichter zu prüfen als eine Funktion, die eine übergebene MutableList direkt verändert. In Code-Reviews kannst du deshalb gezielt fragen: Muss diese Funktion wirklich eine MutableList bekommen, oder reicht List plus Rückgabewert?
In der Praxis
Stell dir eine Aufgabenliste in einer Android-App vor. Das ViewModel hält intern eine Liste von Aufgaben. Die UI soll diese Aufgaben anzeigen, aber nicht direkt verändern. Änderungen sollen über klare Funktionen laufen, etwa addTask oder removeTask.
data class Task(
val id: String,
val title: String,
val done: Boolean
)
class TaskViewModel {
private val mutableTasks = mutableListOf<Task>()
val tasks: List<Task>
get() = mutableTasks.toList()
fun addTask(title: String) {
val task = Task(
id = System.currentTimeMillis().toString(),
title = title,
done = false
)
mutableTasks.add(task)
}
fun markDone(id: String) {
val index = mutableTasks.indexOfFirst { it.id == id }
if (index != -1) {
val oldTask = mutableTasks[index]
mutableTasks[index] = oldTask.copy(done = true)
}
}
}
Die UI bekommt hier nur List<Task>. Sie kann die Liste anzeigen, aber nicht add oder remove aufrufen. Das ViewModel bleibt die Stelle, an der Änderungen passieren. toList() erzeugt zusätzlich eine Kopie der aktuellen Liste. Das ist wichtig, weil ein reines Hochstufen von MutableList<Task> zu List<Task> zwar die Schreibfunktionen am Typ versteckt, aber nicht verhindert, dass dieselbe Instanz intern weiter verändert wird. Für viele UI-Modelle ist eine Kopie beim Veröffentlichen deshalb eine robuste Entscheidung.
In einer echten Compose-App würdest du den Zustand oft mit StateFlow, mutableStateOf oder anderen beobachtbaren State-Haltern verbinden. Das Grundprinzip bleibt gleich: Nach außen lieferst du einen lesbaren Zustand, Änderungen laufen über Funktionen. Eine Composable sollte nicht selbst eine fremde MutableList bearbeiten, weil sie dadurch schwerer wiederverwendbar und schwerer testbar wird.
Eine typische Stolperfalle ist diese Signatur:
fun renderTasks(tasks: MutableList<Task>) {
tasks.removeAll { it.done }
}
Der Name klingt harmlos, aber die Funktion verändert die übergebene Liste. Wenn ein anderer Teil der App diese Liste ebenfalls nutzt, fehlen danach plötzlich erledigte Aufgaben. Besser ist eine read-only Eingabe mit einem neuen Ergebnis:
fun visibleTasks(tasks: List<Task>): List<Task> {
return tasks.filterNot { it.done }
}
Diese Variante ist leichter zu verstehen: Aus einer Liste entsteht eine gefilterte Liste. Die ursprünglichen Daten bleiben unverändert. Das passt gut zu UI-Code, zu Unit-Tests und zu sauberem Architekturdesign.
Als Entscheidungsregel kannst du dir merken: Verwende List, solange eine Funktion nur lesen, filtern, zählen, anzeigen oder transformieren soll. Verwende MutableList nur dort, wo die Funktion ausdrücklich den vorhandenen Container verändern muss. Gib MutableList möglichst nicht über öffentliche Properties, Konstruktoren oder Callback-Parameter nach außen. Wenn du doch eine mutable Collection brauchst, dokumentiere die Verantwortung klar über Namen und Tests.
Prüfe dein Verständnis praktisch mit einem kleinen Refactoring: Suche in deinem Projekt nach MutableList in öffentlichen APIs. Frage bei jedem Treffer, ob der Aufrufer wirklich ändern darf. Wenn nicht, ändere den Typ zu List und passe den Code so an, dass Änderungen über gezielte Methoden laufen. Danach schreibst du einen Unit-Test, der sicherstellt, dass eine Filter- oder Sortierfunktion die ursprüngliche Liste nicht verändert. In der CI sind solche Tests wertvoll, weil sie unbeabsichtigte Mutationen früh sichtbar machen.
Fazit
Read-only Collections sind kein akademisches Detail, sondern ein Werkzeug für ruhigeren Android-Code. Du machst damit sichtbar, welche Schicht lesen darf und welche Schicht Zustand verändern darf. Nutze MutableList bewusst für interne Arbeitsschritte, aber gib nach außen bevorzugt List zurück. Übe das an einem ViewModel oder Repository: Setze Breakpoints an den Änderungsfunktionen, schreibe einen Test gegen unbeabsichtigte Mutation und achte im Code-Review darauf, ob veränderbarer Zustand wirklich nötig ist.