Observable Reads: Daten lesen, die deine UI aktuell halten
Observable Reads machen lokale Datenänderungen für die UI sichtbar. Du lernst, warum Flow dabei der Standardweg ist.
Observable Reads bedeuten: Du liest lokale Daten nicht nur einmal aus, sondern beobachtest sie fortlaufend. Sobald sich die persistierten Daten ändern, bekommt deine App einen neuen Wert und kann die Oberfläche aktualisieren. In modernen Android-Apps ist das besonders wichtig, weil Daten oft aus mehreren Richtungen kommen: aus der Datenbank, aus dem Netzwerk, aus Nutzeraktionen und aus Hintergrund-Synchronisation. Wenn du Observable Reads sauber einsetzt, bleibt deine UI näher an der Wahrheit deiner App-Daten.
Was ist das?
Ein Observable Read ist ein Lesezugriff, der Änderungen weitergibt. Statt getTasks() einmal aufzurufen und eine Liste zurückzubekommen, stellst du dir eher observeTasks() vor: Die Funktion liefert einen Datenstrom. Dieser Strom sendet den aktuellen Datenbestand und danach weitere Werte, wenn sich der gespeicherte Zustand ändert.
Im Android-Kontext ist dafür meistens Kotlin Flow die passende Abstraktion. Ein Flow<List<Task>> kann aus deiner Datenbank kommen, im Repository weitergereicht und im ViewModel in UI-State umgewandelt werden. Die UI sammelt diesen State ein und rendert neu, wenn ein neuer Wert ankommt. Bei Jetpack Compose passt dieses Modell sehr gut, weil Compose ohnehin deklarativ arbeitet: Du beschreibst, wie die Oberfläche für einen bestimmten Zustand aussieht. Ändert sich der Zustand, wird der relevante Teil der UI neu aufgebaut.
Das Problem, das Observable Reads lösen, ist in echten Apps schnell sichtbar. Du speicherst zum Beispiel eine Aufgabe als erledigt. Wenn die Liste nur durch einen einmaligen Read geladen wurde, musst du manuell daran denken, die Liste neu zu laden oder lokal zu verändern. Das wird fehleranfällig, sobald mehrere Screens, Hintergrundjobs oder Sync-Prozesse dieselben Daten ändern. Mit einem beobachtbaren lokalen Read wird die Datenbank zur stabilen Quelle: Wer Daten ändert, schreibt in die lokale Persistenz. Wer Daten anzeigen will, beobachtet sie.
Dieses Denken gehört eng zur Data-Layer-Architektur und zu Offline-First. Die UI sollte nicht ständig direkt fragen: “Was sagt gerade das Netzwerk?” Sie sollte in vielen Fällen fragen: “Was steht lokal als aktueller App-Zustand bereit?” Das Netzwerk aktualisiert dann die lokale Quelle, und die Oberfläche reagiert über den Flow. So entsteht ein robusterer Ablauf, der auch bei schlechter Verbindung nachvollziehbar bleibt.
Wie funktioniert es?
Das mentale Modell ist ein Rohr mit Werten. Am Anfang steht eine Quelle, häufig eine Datenbankabfrage. Diese Quelle liefert nicht nur den ersten Wert, sondern kann später weitere Werte senden. Dazwischen können Operatoren stehen, die Werte filtern, sortieren, zusammenführen oder in ein UI-Modell übersetzen. Am Ende sammelt ein Consumer den Flow ein, zum Beispiel das ViewModel oder die Compose-UI.
Wichtig ist der Unterschied zwischen kalten und aktiven Datenströmen. Ein Flow startet seine Arbeit normalerweise erst, wenn er gesammelt wird. Das ist nützlich, weil die Datenbank nicht ohne Bedarf beobachtet werden muss. Im ViewModel wandelst du solche Flows oft in StateFlow um, damit die UI immer einen aktuellen Zustand hat. Dafür wird häufig stateIn verwendet. So bekommt die UI einen stabilen Wert, auch wenn sie nach einer Konfigurationsänderung wieder startet.
In einer typischen Architektur sieht die Kette so aus: Das DAO bietet eine beobachtbare Query an. Das Repository stellt daraus eine fachliche Methode bereit. Das ViewModel transformiert die Daten in einen UI-State. Compose sammelt diesen State lifecycle-bewusst ein und zeigt ihn an.
Der Data Layer bleibt dabei verantwortlich für Datenquellen und Regeln. Das Repository entscheidet zum Beispiel, ob nur lokale Daten beobachtet werden oder ob nebenbei eine Synchronisation gestartet wird. Das ViewModel sollte keine SQL-Details kennen. Die UI sollte nicht wissen, ob die Daten aus Room, DataStore oder einer anderen lokalen Quelle stammen. Genau diese Trennung hilft dir später beim Testen und bei Änderungen.
Coroutines spielen dabei eine tragende Rolle. Flow basiert auf Coroutines und kann asynchron arbeiten, ohne den Main Thread zu blockieren. Datenbank- und Netzwerkzugriffe dürfen die UI nicht einfrieren. Moderne Android-Bibliotheken nehmen dir hier viel Arbeit ab, aber das Grundprinzip bleibt wichtig: lange oder blockierende Arbeit gehört nicht unkontrolliert in den UI-Thread. Du modellierst Datenfluss und Nebenläufigkeit bewusst.
Bei UI-Updates zählt außerdem der Lebenszyklus. Ein Screen kann sichtbar, pausiert oder zerstört sein. Wenn du einen Flow in Compose sammelst, solltest du lifecycle-bewusste APIs verwenden, damit nicht unnötig weiter gesammelt wird, wenn der Screen nicht aktiv ist. In ViewModels nutzt du viewModelScope, weil dort laufende Coroutines an die Lebensdauer des ViewModels gebunden sind.
Eine wichtige Regel lautet: Wenn ein UI-Element persistierte Daten anzeigen soll, die sich nach dem ersten Laden ändern können, bevorzuge einen Observable Read. Ein einmaliger Read passt eher für Aktionen wie “prüfe genau jetzt diesen Wert” oder für einmalige Initialisierung. Für Listen, Detailansichten, Zähler, Sync-Status oder gespeicherte Einstellungen ist ein beobachtbarer Ansatz meistens stabiler.
In der Praxis
Stell dir eine Aufgaben-App vor. Die Liste soll sofort reagieren, wenn eine Aufgabe eingefügt, gelöscht oder als erledigt markiert wird. Zusätzlich kann ein Hintergrund-Sync neue Aufgaben aus dem Netzwerk in die lokale Datenbank schreiben. Die UI sollte davon nichts Besonderes wissen müssen. Sie beobachtet nur die lokale Liste.
DAO
@Dao
interface TaskDao {
@Query("SELECT * FROM tasks ORDER BY createdAt DESC")
fun observeTasks(): Flow<List<TaskEntity>>
@Query("SELECT * FROM tasks WHERE id = :id")
fun observeTask(id: String): Flow<TaskEntity?>
@Update
suspend fun updateTask(task: TaskEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertTasks(tasks: List<TaskEntity>)
}
Der entscheidende Punkt ist der Rückgabetyp. observeTasks() liefert keinen einmaligen List<TaskEntity>-Wert, sondern einen Flow<List<TaskEntity>>. Wenn Room erkennt, dass sich die betroffene Tabelle geändert hat, kann die Query erneut ausgewertet werden und einen neuen Wert senden. Deine Liste bleibt dadurch mit der lokalen Datenbank verbunden.
Repository
class TaskRepository(
private val taskDao: TaskDao
) {
fun observeTasks(): Flow<List<Task>> {
return taskDao.observeTasks()
.map { entities ->
entities.map { entity ->
Task(
id = entity.id,
title = entity.title,
done = entity.done
)
}
}
}
suspend fun setDone(task: Task, done: Boolean) {
taskDao.updateTask(
TaskEntity(
id = task.id,
title = task.title,
done = done,
createdAt = task.createdAt
)
)
}
}
Das Repository übersetzt Datenbankmodelle in Domain- oder App-Modelle. Es versteckt die Persistenzdetails vor dem Rest der App. Beachte auch die Richtung: Die UI ruft nicht “lade nach dem Speichern alles neu” auf. Sie löst eine Änderung aus, die Änderung wird gespeichert, und der Observable Read sendet den neuen Zustand.
ViewModel
data class TaskListUiState(
val tasks: List<Task> = emptyList(),
val isLoading: Boolean = true
)
class TaskListViewModel(
private val repository: TaskRepository
) : ViewModel() {
val uiState: StateFlow<TaskListUiState> =
repository.observeTasks()
.map { tasks ->
TaskListUiState(
tasks = tasks,
isLoading = false
)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = TaskListUiState()
)
fun onDoneChanged(task: Task, done: Boolean) {
viewModelScope.launch {
repository.setDone(task, done)
}
}
}
Das ViewModel macht aus dem Datenstrom einen State, den die UI direkt anzeigen kann. StateFlow ist hier praktisch, weil immer ein letzter bekannter Wert vorhanden ist. SharingStarted.WhileSubscribed(5_000) sorgt dafür, dass der Flow aktiv bleibt, solange die UI ihn nutzt, und nach kurzer Pause nicht sofort neu gestartet werden muss. Das ist kein Muss für jede App, aber ein sinnvoller Standard für viele UI-nahe Flows.
Compose-UI
@Composable
fun TaskListScreen(
viewModel: TaskListViewModel
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
LazyColumn {
items(uiState.tasks, key = { it.id }) { task ->
Row {
Checkbox(
checked = task.done,
onCheckedChange = { checked ->
viewModel.onDoneChanged(task, checked)
}
)
Text(text = task.title)
}
}
}
}
Die UI bleibt schlank. Sie sammelt den State, zeigt ihn an und meldet Nutzeraktionen zurück. Sie entscheidet nicht selbst, wann die Datenbank neu gelesen werden muss. Genau das ist der praktische Nutzen: Der Datenfluss ist klarer, und viele manuelle Refresh-Pfade verschwinden.
Eine typische Stolperfalle ist, Observable Reads mit manuellen UI-Listen zu vermischen. Beispiel: Du beobachtest tasks, aber nach einem Checkbox-Klick änderst du zusätzlich direkt eine mutable Liste im Composable, damit die UI schneller reagiert. Das kann zu doppelten Zuständen führen. Kurz sieht alles richtig aus, später überschreibt ein Datenbank-Update deine lokale Änderung oder zeigt einen alten Stand. Besser ist meistens: Schreibe die Änderung in die Datenquelle und lasse den beobachteten State die Anzeige steuern. Wenn du optimistische Updates brauchst, modellierst du sie bewusst im ViewModel oder Repository.
Eine zweite Stolperfalle ist ein zu breiter Flow. Wenn du für einen Detail-Screen immer die komplette Tabelle beobachtest und dann im ViewModel filterst, erzeugst du unnötige Arbeit. Besser ist eine passende Query wie observeTask(id). Observable Reads sollen aktuell halten, aber nicht beliebig viel Daten bewegen.
Auch Fehlerbehandlung gehört dazu. Ein lokaler Datenbank-Flow sollte in der Regel stabil sein, aber Transformationen können Fehler auslösen. Netzwerk-Sync sollte nicht den UI-Read zerstören. Bei Offline-First ist es oft sauberer, die lokale Anzeige weiterlaufen zu lassen und Sync-Fehler getrennt als Status oder Event zu zeigen. Die Oberfläche bleibt dadurch nutzbar, auch wenn die Aktualisierung aus dem Netz gerade scheitert.
Beim Testen kannst du Observable Reads gut prüfen. In einem Repository-Test erzeugst du eine In-Memory-Datenbank, sammelst den Flow und schreibst danach neue Daten hinein. Dann erwartest du, dass ein weiterer Wert ankommt. In ViewModel-Tests prüfst du, ob aus einer Repository-Emission der richtige UiState entsteht. Im Code-Review achtest du besonders auf diese Fragen: Gibt eine UI-nahe Datenabfrage einen Flow zurück? Wird im ViewModel ein stabiler State daraus? Gibt es neben dem Flow noch einen zweiten, manuellen Zustand für dieselben Daten? Werden Coroutine-Scopes passend gewählt?
Für deinen Alltag kannst du dir eine einfache Entscheidungsregel merken: Daten, die auf dem Screen sichtbar bleiben und aus persistiertem Zustand stammen, liest du beobachtbar. Aktionen dürfen suspend-Funktionen sein, Reads für sichtbaren Zustand bevorzugen Flow. Diese Trennung ist nicht nur Stil. Sie verhindert, dass du nach jeder Schreiboperation verstreute Refresh-Aufrufe ergänzen musst.
Fazit
Observable Reads machen deine Android-App berechenbarer: Die lokale Datenbank wird zur beobachtbaren Quelle, Flow transportiert Änderungen, das ViewModel formt daraus UI-State, und Compose zeigt den aktuellen Zustand an. Prüfe das aktiv in einem kleinen Screen: Erstelle eine Room-Query mit Flow, ändere Daten über eine suspend-Funktion und beobachte im Debugger oder Test, ob die UI ohne manuellen Refresh einen neuen Zustand erhält. Wenn du im Code-Review nur eine Sache kontrollierst, dann diese: Sichtbare persistierte Daten sollten nicht als einmaliger Snapshot enden, wenn sie sich während der Nutzung ändern können.