Offline-First-Capstone mit Room und Sync
Du baust Datenfunktionen, die lokal starten. Sync ergänzt Netzwerkdaten kontrolliert.
Ein Offline-First-Capstone ist eine Abschlussaufgabe für den Datenbereich deiner Android-Roadmap: Du baust ein Feature, das seine Daten aus einer lokalen Quelle liest und diese Daten mit einer entfernten Quelle synchronisiert. Der wichtige Gedanke ist nicht „App ohne Internet“, sondern „App mit verlässlichem lokalen Zustand“. Deine Compose-UI hängt nicht direkt am Netzwerk, sondern beobachtet Daten aus Room. Netzwerkzugriffe, Fehlerbehandlung und Sync-Logik laufen kontrolliert in der Datenschicht.
Was ist das?
Offline-First beschreibt eine Architektur, bei der lokale Daten die primäre Wahrheit für die App-Oberfläche sind. Wenn du eine Aufgabenliste, einen Notizbereich oder einen Warenkorb öffnest, zeigt die App zuerst das, was lokal gespeichert ist. Danach kann sie im Hintergrund mit einem Server abgleichen. Für den Nutzer fühlt sich die App dadurch schneller und robuster an, weil ein schwaches Netz nicht sofort die komplette Funktion blockiert.
Das Wort Capstone meint hier: Du verbindest mehrere gelernte Bausteine zu einem vollständigen Feature. Room speichert strukturierte Daten lokal. Eine Netzwerk-API liefert neue Daten oder nimmt Änderungen entgegen. Ein Repository kapselt die Entscheidung, wann gelesen, geschrieben und synchronisiert wird. Kotlin Coroutines und Flow helfen dir, Änderungen reaktiv an die UI weiterzugeben. Compose kann diese Daten beobachten und den Bildschirm aktualisieren, ohne dass du manuell View-Zustand gegen Datenbank-Zustand verschieben musst.
Im Android-Alltag ist dieses Muster sehr verbreitet. Viele Apps müssen mit Verbindungsabbrüchen, langsamen Servern, Flugmodus, Akku-Sparmodus oder kurzlebigen Prozessen umgehen. Eine reine „Request laden, Response anzeigen“-Denkweise reicht dafür nicht. Du brauchst ein Modell, bei dem die App auch dann sinnvoll bleibt, wenn das Netzwerk gerade nicht verfügbar ist. Genau hier liegt der Lernwert des Capstone: Du übst nicht nur einzelne APIs, sondern eine tragfähige Datenentscheidung.
Für Einsteiger ist das mentale Modell wichtig: Die UI fragt nicht „Was sagt der Server gerade?“, sondern „Was ist der aktuelle lokale Zustand?“. Der Server ist eine Quelle für Updates, aber nicht die direkte Grundlage jedes UI-Frames. Wenn du das verstanden hast, werden viele Architekturentscheidungen klarer. Room steht im Zentrum, Netzwerkzugriffe sind Ergänzungen, und Sync ist die Regel, nach der beide Seiten zusammengeführt werden.
Wie funktioniert es?
Eine typische Offline-First-Funktion hat drei Schichten. Unten liegt die lokale Datenquelle, oft eine Room-Datenbank mit Entity, DAO und Abfragen als Flow. Daneben steht die Remote-Datenquelle, zum Beispiel ein Retrofit-Service oder eine andere API-Schicht. Dazwischen sitzt das Repository. Es ist die Stelle, an der du entscheidest, welche Daten gelesen werden, wann ein Refresh startet, wie Fehler behandelt werden und welche Änderungen noch synchronisiert werden müssen.
Der normale Leseweg beginnt lokal. Das ViewModel ruft eine Repository-Funktion auf, die einen Flow aus Room zurückgibt. Room sendet sofort vorhandene Daten und später jede Änderung. Compose sammelt diesen Flow als UI-State ein. Wenn parallel ein Netzwerk-Refresh läuft und neue Daten in Room geschrieben werden, aktualisiert sich der Bildschirm automatisch. Das ist sauberer als eine UI, die mal aus einer Response und mal aus einer Datenbank liest.
Der Schreibweg ist der kritischere Teil. Wenn der Nutzer eine Änderung auslöst, etwa eine Notiz bearbeitet, kannst du zuerst lokal speichern. Dadurch sieht die UI den neuen Zustand sofort. Danach markierst du den Datensatz als „noch nicht synchronisiert“ oder erzeugst einen Eintrag in einer Warteschlange. Ein Worker oder eine Repository-Funktion versucht später, diese Änderung an den Server zu senden. Bei Erfolg entfernst du die Sync-Markierung. Bei Fehlern bleibt die Änderung lokal erhalten und wird erneut versucht.
Du brauchst dafür klare Zustände. Ein Datensatz kann lokal vorhanden, synchronisiert, geändert, gelöscht oder im Konflikt sein. Für einfache Lernprojekte reichen oft Felder wie updatedAt, syncPending und optional deleted. In echten Projekten hängen die Details vom Backend ab. Wichtig ist: Du solltest nicht nur Daten speichern, sondern auch den Sync-Zustand modellieren. Sonst kannst du offline gemachte Änderungen nicht verlässlich von frisch geladenen Serverdaten unterscheiden.
Die offizielle Android-Architektur empfiehlt eine klare Datenschicht. Das passt hier sehr gut: Das Repository bietet der restlichen App eine fachliche API an, etwa observeNotes(), refresh() oder updateNote(). Die UI muss nicht wissen, ob ein Update aus Room, Netzwerk oder einer Retry-Queue kommt. Dadurch wird dein Code testbarer. Du kannst die lokale und entfernte Datenquelle ersetzen und prüfen, ob das Repository bei Fehlern richtig reagiert.
Eine wichtige Regel lautet: Ein Netzwerkfehler darf nicht automatisch ein leerer Bildschirm sein. Wenn lokale Daten vorhanden sind, zeigst du sie weiter an und ergänzt höchstens einen Status wie „Aktualisierung fehlgeschlagen“. Ebenso sollte ein Refresh nicht ungeprüft lokale Änderungen überschreiben. Wenn ein Datensatz lokal geändert wurde und noch nicht synchronisiert ist, darf ein älterer Serverstand diesen Datensatz nicht still ersetzen. Diese Stolperfalle führt zu Datenverlust, der im Test oft erst spät auffällt.
In der Praxis
Stell dir eine kleine Notizfunktion vor. Der Nutzer sieht eine Liste von Notizen, kann den Text ändern und soll auch ohne Netz weiterarbeiten können. Die UI beobachtet nur Room. Das Repository startet beim Öffnen oder per Pull-to-refresh einen Abgleich mit dem Server. Lokale Änderungen werden mit syncPending = true markiert und später gesendet.
Ein stark vereinfachter Kern kann so aussehen:
@Entity(tableName = "notes")
data class NoteEntity(
@PrimaryKey val id: String,
val text: String,
val updatedAt: Long,
val syncPending: Boolean
)
@Dao
interface NoteDao {
@Query("SELECT * FROM notes ORDER BY updatedAt DESC")
fun observeNotes(): Flow<List<NoteEntity>>
@Upsert
suspend fun upsertAll(notes: List<NoteEntity>)
@Upsert
suspend fun upsert(note: NoteEntity)
@Query("SELECT * FROM notes WHERE syncPending = 1")
suspend fun pendingNotes(): List<NoteEntity>
}
class NotesRepository(
private val dao: NoteDao,
private val api: NotesApi,
private val clock: Clock
) {
fun observeNotes(): Flow<List<Note>> =
dao.observeNotes().map { entities ->
entities.map { it.toDomain() }
}
suspend fun refresh() {
val remoteNotes = api.getNotes()
val localPendingIds = dao.pendingNotes().map { it.id }.toSet()
val safeToStore = remoteNotes
.filterNot { it.id in localPendingIds }
.map { it.toEntity(syncPending = false) }
dao.upsertAll(safeToStore)
}
suspend fun updateText(id: String, text: String) {
dao.upsert(
NoteEntity(
id = id,
text = text,
updatedAt = clock.nowMillis(),
syncPending = true
)
)
}
suspend fun syncPending() {
dao.pendingNotes().forEach { note ->
api.putNote(note.toDto())
dao.upsert(note.copy(syncPending = false))
}
}
}
Dieses Beispiel ist bewusst klein. Es zeigt aber den entscheidenden Datenfluss: observeNotes() liest nur lokal. refresh() holt Remote-Daten und schreibt sie nach Room. updateText() schreibt zuerst lokal. syncPending() sendet lokale Änderungen später. Die UI kann dadurch stabil bleiben, selbst wenn api.getNotes() oder api.putNote() fehlschlägt. In einer echten App würdest du zusätzlich Fehler abfangen, Transaktionen nutzen, Löschungen modellieren, Retry-Regeln definieren und Sync über WorkManager anstoßen.
Eine passende Compose-Verwendung wäre: Das ViewModel stellt einen StateFlow<NotesUiState> bereit, der aus dem Repository-Flow entsteht. Beim Start ruft das ViewModel refresh() auf. Wenn der Nutzer Text ändert, ruft es updateText() auf. Die Composable zeigt die Liste aus dem State. Sie wartet nicht auf eine erfolgreiche Serverantwort, bevor sie die Änderung sichtbar macht. Dadurch fühlt sich die App direkt an, und dein Zustand bleibt trotzdem nachvollziehbar.
Die wichtigste Entscheidungsregel für dein Capstone-Projekt: Lege zuerst fest, welche Quelle die UI lesen darf. Für Offline-First sollte die Antwort fast immer „Room“ sein. Danach legst du fest, welche Aktionen Netzwerk benötigen und wie du Fehler sichtbar machst. Wenn du diese Reihenfolge umdrehst, baust du schnell eine App, die bei gutem WLAN funktioniert, aber bei realer Nutzung unzuverlässig wird.
Eine typische Stolperfalle ist doppelter Zustand. Anfänger speichern Daten in Room, halten aber parallel eine unabhängige Liste im ViewModel, die aus der letzten Netzwerkantwort stammt. Dann entstehen schwer erklärbare Fehler: Nach einem Refresh sieht die UI andere Daten als die Datenbank, nach Prozessneustart fehlen Änderungen, oder Tests prüfen nicht den tatsächlichen Zustand. Besser ist ein einzelner beobachtbarer Pfad: Room zu Repository, Repository zu ViewModel, ViewModel zu Compose.
Eine zweite Stolperfalle ist zu wenig Konfliktlogik. Wenn Nutzer A eine Notiz auf Gerät 1 ändert und Gerät 2 später einen älteren Stand synchronisiert, brauchst du eine Regel. Für Lernprojekte reicht „lokale ausstehende Änderungen gewinnen“. Für echte Produkte kann es komplexer werden: Server-Zeitstempel, Versionsnummern, Merge-Dialoge oder fachliche Prioritäten. Entscheidend ist, dass du eine Regel hast und sie testest. Ohne Regel überschreibt häufig der zuletzt verarbeitete Request den besseren Zustand.
Beim Testen kannst du das Repository isoliert prüfen. Verwende eine In-Memory-Room-Datenbank oder Fake-DAOs, dazu eine Fake-API. Ein guter Test lädt zuerst lokale Daten, simuliert dann einen Netzwerkfehler und prüft, dass die lokalen Daten weiterhin geliefert werden. Ein weiterer Test setzt syncPending = true, ruft refresh() mit einem Remote-Datensatz derselben ID auf und prüft, dass die lokale Änderung nicht überschrieben wird. Damit testest du nicht nur Syntax, sondern die eigentliche Architekturentscheidung.
Auch Debugging ist hier sehr lehrreich. Setze Breakpoints in Repository, DAO und API-Fake. Beobachte, wann Room neue Werte ausgibt. Prüfe die Datenbank mit dem Database Inspector in Android Studio. Schalte das Netzwerk im Emulator aus und ändere Daten. Starte die App neu und kontrolliere, ob der lokale Zustand erhalten bleibt. Danach aktivierst du das Netzwerk wieder und prüfst, ob die ausstehenden Änderungen synchronisiert werden. Diese Übung zeigt dir schnell, ob dein Offline-First-Modell wirklich trägt.
Für Code-Reviews solltest du auf klare Grenzen achten. Greift eine Composable direkt auf die API zu, ist das ein Warnsignal. Enthält das ViewModel SQL-Details oder Konfliktlogik, ist die Verantwortung wahrscheinlich falsch verteilt. Gibt es Netzwerkfehler, die lokale Daten löschen oder durch leere Listen ersetzen, muss die Fehlerbehandlung überarbeitet werden. Gute Offline-First-Features wirken nach außen oft schlicht, sind innen aber sauber getrennt.
Fazit
Ein Offline-First-Capstone prüft, ob du Android-Datenarchitektur praktisch zusammensetzen kannst: Room als lokale Wahrheit, Netzwerk als Update-Quelle und Sync als kontrollierte Verbindung zwischen beiden. Baue ein kleines Feature mit Lesen, lokalem Schreiben, Refresh und ausstehenden Änderungen. Teste Flugmodus, Serverfehler, App-Neustart und konkurrierende Datenstände. Wenn du im Debugger erklären kannst, warum die UI zu jedem Zeitpunkt genau diese Daten zeigt, hast du das zentrale Lernziel erreicht.