Android Coden
Android 7 min lesen

Tombstones und Löschungen bei Offline-Sync

Löschungen brauchen beim Offline-Sync eigene Daten. So bleiben App, Cache und Server konsistent.

Wenn deine App Daten offline bearbeiten kann, ist Löschen schwieriger als es zuerst wirkt. Ein gelöschter Eintrag darf nicht nur aus der lokalen Room-Datenbank verschwinden, denn der Server und andere Geräte müssen diese Änderung ebenfalls erfahren. Tombstones lösen genau dieses Problem: Sie halten eine Löschung so lange sichtbar, bis alle relevanten Sync-Schritte abgeschlossen sind.

Was ist das?

Ein Tombstone ist ein Datensatz, der nicht mehr als aktiver Inhalt gilt, aber als Löschmarkierung erhalten bleibt. Statt eine Notiz, Aufgabe oder Nachricht sofort physisch aus der lokalen Datenbank zu entfernen, speicherst du zum Beispiel isDeleted = true, deletedAt und einen Sync-Status. Der Eintrag ist für die UI gelöscht, bleibt aber für die Datenlogik noch wichtig.

Das mentale Modell ist: Löschen ist im Offline-Sync eine Datenänderung wie Erstellen oder Bearbeiten. Wenn du offline eine Aufgabe löschst, kennt nur dein Gerät diese Aktion. Der Server weiß noch nichts davon. Ein zweites Gerät könnte dieselbe Aufgabe weiterhin anzeigen. Ohne Tombstone kann deine App beim nächsten Sync nicht sicher unterscheiden, ob ein Datensatz absichtlich gelöscht wurde oder ob er nur noch nicht geladen ist.

Im modernen Android-Kontext gehört dieses Thema in die Data Layer. Deine Compose-UI sollte nicht direkt entscheiden, wie Löschungen synchronisiert werden. Sie ruft eine Repository-Funktion auf, etwa deleteTask(id). Das Repository aktualisiert lokale Daten, plant den Sync und sorgt dafür, dass Flows aus der Datenbank nur aktive Einträge an die UI liefern. So bleibt die Oberfläche reaktiv, während die Konsistenzregeln an einer zentralen Stelle liegen.

Tombstones sind besonders relevant bei Offline-First-Apps. Dort ist die lokale Datenbank oft die wichtigste Quelle für die UI. Netzwerkoperationen laufen später, wiederholt oder im Hintergrund. Das ist gut für Bedienbarkeit, erzeugt aber eine klare Pflicht: Jede Änderung muss nachvollziehbar sein, auch eine Löschung. Sonst riskierst du, dass gelöschte Inhalte nach einem Sync wieder auftauchen.

Wie funktioniert es?

Typisch ist ein Datenmodell mit fachlichen Feldern und Sync-Metadaten. Für eine Aufgabe könnten das id, title, updatedAt, isDeleted, deletedAt und pendingSync sein. Die UI filtert gelöschte Einträge aus. Der Sync-Code liest dagegen gezielt auch Tombstones, weil er sie an den Server senden muss.

Der Ablauf sieht häufig so aus: Der Nutzer tippt in Compose auf Löschen. Das ViewModel ruft das Repository auf. Das Repository setzt lokal isDeleted und deletedAt. Dadurch verschwindet der Eintrag sofort aus der Liste, weil der Flow nur nicht gelöschte Daten liefert. Danach wird eine Hintergrundsynchronisierung geplant oder angestoßen. Wenn der Server die Löschung bestätigt, kann der Tombstone entweder markiert bleiben oder nach einer Aufbewahrungsfrist entfernt werden.

Der Zeitstempel ist dabei mehr als Dekoration. Er hilft dir bei Konflikten. Angenommen, Gerät A löscht eine Aufgabe offline um 10:00 Uhr. Gerät B bearbeitet dieselbe Aufgabe um 10:05 Uhr ebenfalls offline. Beim Sync muss dein System entscheiden, welche Änderung gewinnt. Diese Entscheidung hängt von deiner Produktlogik ab. Manche Apps verwenden „neueste Änderung gewinnt“, andere schützen bestimmte Inhalte stärker. Wichtig ist: Ohne gespeicherte Löschinformation hast du keine saubere Entscheidungsgrundlage.

Auch die Server-API spielt eine Rolle. Manche Backends bieten ein eigenes Delete-Endpoint und speichern serverseitig ebenfalls Tombstones. Andere erwarten eine Änderung am Datensatz, etwa deletedAt. Für die App ist entscheidend, dass die Datenebene ein klares Protokoll kennt: Welche lokalen Löschungen sind noch ausstehend? Welche wurden bestätigt? Welche sind fehlgeschlagen? Welche müssen erneut versucht werden?

In Android solltest du solche Regeln nicht in Composables verteilen. Compose beobachtet Zustand und sendet Nutzeraktionen weiter. Das ViewModel koordiniert UI-State. Das Repository kapselt Datenquellen, also Room, Netzwerk und Sync-Status. Diese Trennung passt zur empfohlenen Architektur: Die Data Layer bietet eine klare API, während Details zu Cache, Netzwerk und Konflikten verborgen bleiben.

Eine wichtige Konsequenz: DELETE FROM tasks WHERE id = :id ist bei Offline-First selten der erste Schritt. Physisches Löschen kann später sinnvoll sein, etwa nach erfolgreichem Server-Sync und Ablauf einer Frist. Zuerst brauchst du aber die Markierung. Sonst löscht du den einzigen lokalen Beweis dafür, dass etwas gelöscht werden sollte.

In der Praxis

Stell dir eine Aufgaben-App vor. Sie zeigt eine Liste in Compose, speichert Aufgaben lokal mit Room und synchronisiert mit einem Backend. Der Nutzer kann Aufgaben auch ohne Netz löschen. Für die UI soll die Aufgabe sofort verschwinden. Für den Sync muss sie aber noch auffindbar bleiben.

Ein mögliches Entity sieht so aus:

@Entity(tableName = "tasks")
data class TaskEntity(
    @PrimaryKey val id: String,
    val title: String,
    val updatedAt: Long,
    val isDeleted: Boolean = false,
    val deletedAt: Long? = null,
    val pendingSync: Boolean = false
)

@Dao
interface TaskDao {
    @Query("SELECT * FROM tasks WHERE isDeleted = 0 ORDER BY updatedAt DESC")
    fun observeActiveTasks(): Flow<List<TaskEntity>>

    @Query("SELECT * FROM tasks WHERE pendingSync = 1")
    suspend fun getPendingSyncTasks(): List<TaskEntity>

    @Query("""
        UPDATE tasks
        SET isDeleted = 1,
            deletedAt = :deletedAt,
            updatedAt = :deletedAt,
            pendingSync = 1
        WHERE id = :id
    """)
    suspend fun markDeleted(id: String, deletedAt: Long)

    @Query("DELETE FROM tasks WHERE id = :id AND isDeleted = 1 AND pendingSync = 0")
    suspend fun purgeConfirmedTombstone(id: String)
}

class TaskRepository(
    private val dao: TaskDao,
    private val clock: Clock,
    private val syncScheduler: SyncScheduler
) {
    fun observeTasks(): Flow<List<TaskEntity>> = dao.observeActiveTasks()

    suspend fun deleteTask(id: String) {
        dao.markDeleted(id = id, deletedAt = clock.nowMillis())
        syncScheduler.schedule()
    }
}

Der wichtige Punkt liegt nicht in der Syntax, sondern in der Absicht. Die UI erhält nur aktive Aufgaben. Der Sync-Mechanismus kann getPendingSyncTasks() nutzen und erkennt dort auch gelöschte Einträge. Beim Senden an den Server wird aus isDeleted = true zum Beispiel ein DELETE /tasks/{id} oder ein Update mit deletedAt.

Eine praktische Entscheidungsregel lautet: Wenn eine Löschung offline passieren kann und an andere Geräte oder einen Server weitergegeben werden muss, lösche lokal zuerst logisch, nicht physisch. Physisches Löschen ist ein Aufräumschritt, kein Ersatz für Sync-Zustand.

Eine typische Stolperfalle ist das „Wiederauferstehen“ von Daten. Das passiert, wenn der Client lokal hart löscht, später aber vom Server eine ältere Liste herunterlädt, in der der Eintrag noch vorhanden ist. Da der Client keine Tombstone-Information mehr hat, behandelt er den Serverstand als gültig und schreibt den Datensatz zurück in die lokale Datenbank. Aus Nutzersicht ist das ein Qualitätsproblem: Die App wirkt unzuverlässig, weil sie eine bewusste Aktion rückgängig macht.

Eine zweite Stolperfalle ist zu frühes Aufräumen. Wenn du Tombstones direkt nach einem fehlgeschlagenen Netzwerkversuch entfernst, verlierst du die Chance auf einen erneuten Sync. Netzwerkfehler, Prozessende und leere Akkuzustände sind normale Android-Realität. Deine App muss Wiederholungen aushalten. Ein bestätigter Serverstand oder eine bewusst definierte Aufbewahrungsfrist ist eine bessere Grenze.

Teste dieses Verhalten gezielt. Ein Unit-Test für das Repository kann prüfen, dass deleteTask() nicht physisch löscht, sondern isDeleted, deletedAt und pendingSync setzt. Ein Integrationstest mit Fake-API kann simulieren, dass der erste Sync fehlschlägt und der zweite gelingt. In Code-Reviews solltest du bei jedem Delete fragen: Wer muss von dieser Löschung erfahren, und wo bleibt die Information bis dahin gespeichert?

Für Releases ist das ebenfalls relevant. Änderungen an Sync-Regeln gehören nicht ungeprüft in eine große Nutzergruppe. Nutze interne Tests oder gestufte Veröffentlichungen, wenn du an Löschlogik, Migrationen oder Konfliktregeln arbeitest. Datenverlust und wiederkehrende Einträge sind Fehler, die schwer zu erklären und noch schwerer nachträglich zu korrigieren sind.

Fazit

Tombstones machen Löschungen im Offline-Sync sichtbar, prüfbar und wiederholbar. Du behandelst ein Delete nicht als verschwundenen Datensatz, sondern als Ereignis mit Zustand, Zeitstempel und Synchronisationspflicht. Prüfe dein Verständnis an einer kleinen Room-Übung: Implementiere logisches Löschen, simuliere einen fehlgeschlagenen Sync, starte die App neu und verifiziere per Test oder Debugger, dass die Löschung weiterhin übertragen werden kann.

Quellen (6)
Redaktion

Geschrieben von

Redaktion

Das Redaktionsteam recherchiert und schreibt Artikel zu aktuellen Themen rund um Tech, Lifestyle und Ratgeber.