Caching-Strategie
Caching entscheidet, wann gespeicherte Daten reichen. Du lernst, wie Freshness, Stale Data und Invalidation zusammenhängen.
Eine Caching-Strategie beantwortet eine scheinbar einfache, in echten Android-Apps aber sehr wichtige Frage: Darf die App gerade mit gespeicherten Daten arbeiten, oder muss sie neue Daten laden? Wenn du diese Frage nicht bewusst beantwortest, entscheidet dein Code zufällig. Dann zeigt Compose vielleicht alte Inhalte an, dein Repository lädt zu oft aus dem Netzwerk, oder Nutzer sehen nach einer Änderung noch einen alten Zustand. Gute Caching-Regeln machen deine App schneller, robuster bei schlechtem Netz und fachlich zuverlässiger.
Was ist das?
Eine Caching-Strategie ist eine feste Regel, wie deine App mit lokal gespeicherten Daten umgeht. Der Cache kann eine Room-Datenbank, ein Datastore-Wert, eine Datei, ein Speicherbereich im Repository oder auch ein HTTP-Cache sein. Entscheidend ist nicht nur, dass Daten gespeichert werden. Entscheidend ist, wann diese Daten als frisch genug gelten und wann sie ungültig werden.
Im Android-Kontext sitzt diese Entscheidung meistens in der Data Layer. Das Repository ist dafür ein guter Ort, weil es zwischen UI, lokaler Datenquelle und Remote-Datenquelle vermittelt. Deine Compose-Oberfläche sollte nicht selbst entscheiden müssen, ob ein Produkt, ein Profil oder eine Nachrichtenliste neu geladen werden muss. Sie sollte einen Zustand beobachten, etwa über Flow oder StateFlow, und diesen Zustand anzeigen.
Das mentale Modell ist: Lokale Daten sind nicht automatisch falsch, aber sie haben ein Alter und einen Kontext. Ein Wetterwert von vor fünf Minuten kann brauchbar sein. Ein Kontostand von vor fünf Minuten kann schon problematisch sein. Eine Liste von App-Einstellungen bleibt vielleicht tagelang gültig. Eine Caching-Strategie macht diese Unterschiede explizit.
Dabei spielen drei Begriffe zusammen. Freshness beschreibt, ob Daten für den aktuellen Zweck noch aktuell genug sind. Stale Data sind Daten, die zwar vorhanden, aber fachlich zu alt oder möglicherweise überholt sind. Invalidation ist der Vorgang, mit dem du Daten als ungültig markierst, entfernst oder gezielt neu laden lässt.
Für Lernende ist wichtig: Caching ist keine reine Performance-Technik. Es ist Teil der Produktlogik. Wenn du falsche Cache-Regeln wählst, kann eine App schnell wirken und trotzdem unzuverlässig sein. Wenn du zu streng bist, lädt die App ständig nach und verliert Offline-Fähigkeit. Gute Android-Architektur sucht deshalb den passenden Mittelweg.
Wie funktioniert es?
Eine typische Android-App hat mehrere Datenquellen. Die lokale Quelle liefert schnell und funktioniert offline. Die Remote-Quelle liefert neue Daten, ist aber langsamer, kann fehlschlagen und kostet Akku sowie Datenvolumen. Die Caching-Strategie legt fest, in welcher Reihenfolge diese Quellen verwendet werden.
Ein häufiges Muster ist „local first, refresh in background“. Die App zeigt zuerst lokale Daten aus Room an. Parallel oder bei Bedarf startet das Repository einen Refresh über die API. Wenn neue Daten ankommen, werden sie in Room gespeichert. Da die UI einen Flow aus Room beobachtet, aktualisiert sie sich automatisch. Das passt gut zu Compose, weil dein UI-Zustand aus Datenströmen abgeleitet werden kann.
Damit das kontrolliert funktioniert, brauchst du Metadaten. Häufig speicherst du nicht nur die eigentlichen Daten, sondern auch einen Zeitstempel wie lastUpdated. Manchmal reicht ein globaler Zeitstempel pro Tabelle oder Ressource. Manchmal brauchst du ihn pro Datensatz. Welche Variante passt, hängt vom Fachfall ab. Eine Nachrichtenliste kann einen Listen-Zeitstempel haben. Ein Nutzerprofil braucht vielleicht einen eigenen Zeitstempel, weil es unabhängig von anderen Daten geladen wird.
Freshness wird dann als Regel formuliert. Beispiel: „Die Produktliste gilt 15 Minuten als frisch.“ Oder: „Das Profil wird bei jedem App-Start erneuert, darf aber offline aus dem Cache angezeigt werden.“ Oder: „Nach einer erfolgreichen Bearbeitung wird der betroffene Datensatz sofort ungültig.“ Diese Regeln sollten im Repository oder in einer nahen Hilfsklasse liegen, nicht verteilt in mehreren ViewModels.
Invalidation passiert auf unterschiedliche Arten. Zeitbasierte Invalidation nutzt ein Ablaufintervall. Ereignisbasierte Invalidation reagiert auf Aktionen, zum Beispiel Login, Logout, Änderung eines Filters oder Speichern eines Formulars. Manuelle Invalidation kann über Pull-to-refresh oder einen Retry-Button ausgelöst werden. In Offline-First-Apps kommen zusätzlich Synchronisationsregeln hinzu: Lokale Änderungen werden gespeichert, später mit dem Server abgeglichen und können dabei Konflikte erzeugen.
Ein häufiger Fehler ist, „Cache vorhanden“ mit „Cache gültig“ zu verwechseln. Nur weil Room Daten liefert, sind sie nicht automatisch passend. Du brauchst eine zweite Information: Wie alt sind sie, und wurden sie durch ein Ereignis ungültig? Ohne diese Information wird dein UI schwer nachvollziehbar. Nutzer sehen Inhalte, aber du kannst nicht erklären, ob sie aktuell sind.
Ein weiterer wichtiger Punkt ist Fehlerbehandlung. Wenn ein Refresh fehlschlägt, solltest du nicht immer die gesamte Ansicht leeren. Oft ist es besser, vorhandene Daten weiter anzuzeigen und zusätzlich einen Fehlerstatus oder eine dezente Meldung bereitzustellen. So bleibt die App nutzbar. In der Data Layer trennst du deshalb Datenzustand und Ladezustand: Daten können vorhanden sein, während ein Refresh läuft oder fehlgeschlagen ist.
Auch Qualität und Release-Praxis gehören dazu. Cache-Fehler fallen oft nicht bei einem einzelnen Klick auf. Sie entstehen nach App-Neustart, Netzwechsel, Zeitablauf oder wiederholten Synchronisationen. Deshalb solltest du die Regeln testbar schreiben. Wenn die Freshness-Logik in kleinen Funktionen steckt und eine abstrahierte Uhr verwendet, kannst du sie in Unit-Tests prüfen. In CI sollten solche Tests stabil laufen, weil Cache-Regeln sonst bei Refactorings leicht beschädigt werden.
In der Praxis
Stell dir eine App vor, die Artikel lädt. Die Liste soll schnell erscheinen und offline verfügbar sein. Gleichzeitig soll sie nicht stundenlang veraltet bleiben. Eine sinnvolle Regel könnte lauten: Die Artikelliste darf zehn Minuten aus dem Cache kommen. Wenn sie älter ist, wird ein Refresh gestartet. Währenddessen zeigt die UI weiter die gespeicherten Artikel an.
Das Repository könnte stark vereinfacht so aussehen:
class ArticleRepository(
private val local: ArticleDao,
private val remote: ArticleApi,
private val clock: Clock
) {
private val maxAgeMillis = 10 * 60 * 1000L
fun observeArticles(): Flow<List<Article>> {
return local.observeArticles()
}
suspend fun refreshIfStale() {
val metadata = local.getArticleMetadata()
val now = clock.nowMillis()
val isMissing = metadata == null
val isStale = metadata != null &&
now - metadata.lastUpdatedMillis > maxAgeMillis
if (!isMissing && !isStale) return
val articles = remote.fetchArticles()
local.replaceArticles(
articles = articles,
lastUpdatedMillis = now
)
}
suspend fun invalidateArticles() {
local.clearArticleMetadata()
}
}
Die wichtige Idee steckt nicht in der konkreten Syntax, sondern in der Trennung der Aufgaben. observeArticles() liefert Daten für die UI. refreshIfStale() entscheidet über Freshness. invalidateArticles() macht die Daten absichtlich ungültig, zum Beispiel nach Logout, nach Änderung eines Themenfilters oder nach einer Serveraktion, die die Liste beeinflusst.
In Compose würdest du die Daten beobachten und einen Refresh zum passenden Zeitpunkt auslösen, zum Beispiel beim Öffnen des Screens im ViewModel. Das ViewModel ruft nicht selbst die API auf und prüft auch nicht jedes Detail der Cache-Regel. Es koordiniert nur den Use Case. Dadurch bleibt die Fachregel dort, wo du sie testen kannst.
Eine praktische Entscheidungsregel lautet: Je größer der Schaden durch veraltete Daten, desto kürzer oder strenger muss die Freshness-Regel sein. Bei rein informativen Inhalten kannst du großzügiger sein. Bei Preisen, Berechtigungen, Sicherheitszuständen oder Zahlungsdaten brauchst du strengere Regeln und oft eine sichtbare Aktualisierung. Frage dich immer: Was passiert, wenn der Nutzer auf Basis dieser Daten eine Entscheidung trifft?
Eine typische Stolperfalle ist ein Refresh bei jedem Screen-Aufruf. Das sieht zunächst sauber aus, weil scheinbar immer aktuelle Daten geladen werden. In der Praxis erzeugt es unnötige Netzwerklast, flackernde Ladezustände und schlechte Offline-Erfahrung. Besser ist eine klare Regel: Beim Öffnen lokale Daten anzeigen, dann nur neu laden, wenn sie fehlen, zu alt sind oder durch ein Ereignis ungültig wurden.
Eine zweite Stolperfalle ist fehlende Invalidation nach Schreiboperationen. Wenn der Nutzer einen Artikel favorisiert, aber deine Liste weiter den alten Favoritenstatus aus Room zeigt, wirkt die App kaputt. Du hast dann mehrere Optionen: Du aktualisierst den lokalen Datensatz optimistisch, du lädst den betroffenen Datensatz neu, oder du invalidierst die Liste. Welche Lösung passt, hängt davon ab, wie zuverlässig die Serverantwort ist und wie teuer der Refresh wäre.
Für Tests solltest du die Uhr kontrollieren. Verwende in der Logik keine direkte Abfrage wie System.currentTimeMillis(), wenn du die Freshness-Regel prüfen willst. Eine kleine Clock-Abstraktion erlaubt dir, im Test die Zeit vorzustellen. Dann kannst du Fälle abdecken wie: Cache fehlt, Cache ist frisch, Cache ist abgelaufen, Remote-Fehler bei vorhandenen Daten. Genau solche Tests helfen dir, die Regel bei späteren Änderungen nicht versehentlich umzubauen.
Im Code-Review kannst du Cache-Logik mit wenigen Fragen prüfen. Wo wird das Alter der Daten gespeichert? Wer entscheidet, ob sie frisch sind? Welche Ereignisse machen sie ungültig? Was sieht der Nutzer, wenn der Refresh fehlschlägt? Gibt es Tests für die Grenze zwischen frisch und veraltet? Wenn diese Fragen nicht beantwortet werden können, ist die Caching-Strategie noch zu implizit.
Fazit
Eine gute Caching-Strategie macht sichtbar, wann gespeicherte Daten akzeptabel sind und wie sie wieder frisch werden. Für moderne Android-Apps gehört diese Regel in die Data Layer, nah an Repository, lokaler Datenquelle und Remote-Datenquelle. Übe das an einem kleinen Screen: Speichere Daten mit Zeitstempel, beobachte sie aus der UI, simuliere abgelaufene Daten im Test und prüfe im Debugger, ob dein Repository wirklich nur dann neu lädt, wenn deine Regel es verlangt.