Android Coden
Android 8 min lesen

Room Transactions in Android

Room Transactions schützen zusammengehörige Schreibvorgänge vor halbem Zustand. Du lernst, wann sie in Android sinnvoll sind.

Wenn deine App lokale Daten speichert, reicht ein einzelnes insert oft nicht aus. Viele echte Aktionen bestehen aus mehreren Schreibvorgängen: eine Bestellung speichern, Positionen anlegen, einen Cache-Zeitstempel aktualisieren oder einen Synchronisationsstatus setzen. Room Transactions helfen dir, solche zusammengehörigen Writes als eine Einheit zu behandeln, damit deine Datenbank nicht in einem halbfertigen Zustand landet.

Was ist das?

Room Transactions sind Transaktionen in der Room-Datenbank von Android. Eine Transaktion fasst mehrere Datenbankoperationen so zusammen, dass sie gemeinsam erfolgreich sind oder gemeinsam verworfen werden. Das zentrale Wort dafür ist Atomicity: Eine Aktion wird aus Sicht deiner App unteilbar. Wenn Schritt drei fehlschlägt, dürfen Schritt eins und zwei nicht dauerhaft gespeichert bleiben, falls sie fachlich nur zusammen Sinn ergeben.

Das klingt am Anfang nach einem reinen Datenbankdetail, ist aber für Android-Apps sehr praktisch. Eine App ist ständig mit Unterbrechungen konfrontiert: Netzwerkfehler, Prozessende, knapper Speicher, parallele UI-Aktionen, erneute Synchronisation nach Offline-Zeiten. Wenn du lokale Daten ohne klare Transaktionsgrenzen schreibst, kann eine Liste in Compose plötzlich Daten anzeigen, die zwar technisch gespeichert sind, aber inhaltlich nicht stimmen.

Ein einfaches Beispiel: Du speicherst eine Notiz und zusätzlich mehrere Tags. Wenn die Notiz gespeichert wird, aber das Speichern der Tags fehlschlägt, zeigt die App später eine Notiz ohne erwartete Zuordnung. Vielleicht ist das in deiner Fachlogik erlaubt. Vielleicht ist es ein Fehler. Genau diese Entscheidung ist der Kern: Eine Room Transaction brauchst du dort, wo mehrere Writes gemeinsam eine fachliche Änderung darstellen.

Im Android-Kontext gehört diese Logik in die Data Layer. Dein UI-Code sollte nicht wissen müssen, dass eine Benutzeraktion intern aus drei Tabellenänderungen besteht. Ein Composable zeigt State an und ruft eine Aktion im ViewModel auf. Das ViewModel delegiert an ein Repository oder eine Use-Case-Funktion. Dort, nahe an der Datenquelle, entscheidest du, welche Operationen zusammen in einer Transaktion laufen.

Wie funktioniert es?

Room baut auf SQLite auf und bietet dir mehrere Wege, Transaktionen zu verwenden. Der wichtigste Einstieg ist die Annotation @Transaction auf einer DAO-Methode. Wenn Room diese Methode ausführt, öffnet es eine Datenbanktransaktion, führt den Methodeninhalt aus und bestätigt die Änderungen erst am Ende. Tritt eine Exception auf, wird die Transaktion zurückgerollt.

Für einfache Reads wird @Transaction oft genutzt, wenn Room relationale Daten konsistent laden soll. In diesem Artikel geht es aber um Writes: mehrere Schreiboperationen, die zusammengehören. Bei suspendierenden DAO-Methoden kannst du @Transaction ebenfalls nutzen, wenn die Methode innerhalb eines DAO mehrere andere DAO-Operationen aufruft. Alternativ kannst du auf Datenbankebene withTransaction verwenden, zum Beispiel in einem Repository, wenn mehrere DAOs beteiligt sind.

Das mentale Modell ist: Du markierst nicht jede Datenbankoperation automatisch als Transaktion. Du markierst eine fachliche Grenze. Eine Transaktion ist nicht „ein bisschen sicherer speichern“, sondern eine konkrete Aussage: Diese Änderungen bilden eine Einheit. Ohne diese Einheit kann deine Datenbank einen Zustand enthalten, den deine App nicht korrekt interpretieren kann.

Zu dieser Einheit gehören drei wichtige Begriffe. Atomicity bedeutet, dass die Änderung vollständig oder gar nicht übernommen wird. Consistency bedeutet, dass nach der Transaktion wieder ein gültiger Zustand vorliegt, etwa keine Detailzeilen ohne passenden Kopfdatensatz. Writes sind die konkreten Schreiboperationen, also insert, update, delete oder komplexere Upserts. Room nimmt dir viel technische Arbeit ab, aber du musst die fachliche Grenze selbst erkennen.

In einer modernen Android-Architektur sollte die UI keine Transaktionsdetails steuern. Compose reagiert auf State, idealerweise aus Flow, StateFlow oder anderen beobachtbaren Datenquellen. Wenn eine Transaktion abgeschlossen ist, liefert Room neue Daten, dein Repository gibt sie weiter, das ViewModel formt UI-State, und Compose recomposed. Die UI sieht also das Ergebnis einer abgeschlossenen Änderung, nicht die Zwischenschritte.

Das ist besonders wichtig für Offline-first-Apps. Dort wird lokal gespeichert, bevor oder während Daten später mit einem Server abgeglichen werden. Ein typischer Ablauf kann sein: lokalen Datensatz erstellen, Outbox-Eintrag anlegen, Synchronisationsstatus setzen. Wenn nur der Datensatz gespeichert wird, aber der Outbox-Eintrag fehlt, weiß deine App beim nächsten Start nicht mehr, dass diese Änderung noch gesendet werden muss. Eine Transaktion schützt diesen Zusammenhang.

Transaktionen sind aber kein Ersatz für gutes Datenmodell. Wenn deine Tabellen keine passenden Constraints haben, kann eine Transaktion zwar mehrere Writes bündeln, aber nicht automatisch fachliche Regeln erraten. Foreign Keys, eindeutige Indizes und sinnvolle Statuswerte bleiben wichtig. Eine robuste Data Layer kombiniert beides: klare Schema-Regeln und Transaktionen für zusammengehörige Abläufe.

In der Praxis

Stell dir eine Aufgaben-App vor. Der Nutzer erstellt eine Aufgabe und wählt mehrere Labels aus. In der Datenbank hast du drei Tabellen: tasks, labels und eine Verbindungstabelle task_label_cross_ref. Die Aufgabe und ihre Label-Verknüpfungen sollen gemeinsam gespeichert werden. Ohne Transaktion könnte die Aufgabe gespeichert werden, während die Verknüpfungen wegen eines Fehlers fehlen. Dann sieht die UI später eine Aufgabe ohne Labels, obwohl der Nutzer sie ausgewählt hatte.

Ein DAO kann dafür so aussehen:

@Dao
interface TaskDao {
    @Insert
    suspend fun insertTask(task: TaskEntity): Long

    @Insert
    suspend fun insertTaskLabelRefs(refs: List<TaskLabelCrossRef>)

    @Transaction
    suspend fun insertTaskWithLabels(
        task: TaskEntity,
        labelIds: List<Long>
    ) {
        val taskId = insertTask(task)
        val refs = labelIds.map { labelId ->
            TaskLabelCrossRef(taskId = taskId, labelId = labelId)
        }
        insertTaskLabelRefs(refs)
    }
}

Diese Methode macht eine fachliche Aussage: Eine Aufgabe mit ihren Label-Verknüpfungen ist ein gemeinsamer Schreibvorgang. Wenn insertTaskLabelRefs fehlschlägt, bleibt auch insertTask nicht dauerhaft bestehen. Für die App ist das deutlich einfacher zu verstehen als ein Zustand, in dem später repariert werden muss, welche Labels eigentlich gemeint waren.

Wenn mehrere DAOs beteiligt sind, ist eine Transaktion im Repository oft klarer. Room stellt dafür auf der Datenbank eine transaktionale Ausführung bereit:

class TaskRepository(
    private val database: AppDatabase,
    private val taskDao: TaskDao,
    private val syncDao: SyncDao
) {
    suspend fun createOfflineTask(task: TaskEntity, labelIds: List<Long>) {
        database.withTransaction {
            val taskId = taskDao.insertTask(task)
            taskDao.insertTaskLabelRefs(
                labelIds.map { labelId ->
                    TaskLabelCrossRef(taskId = taskId, labelId = labelId)
                }
            )
            syncDao.insertPendingSync(
                PendingSyncEntity(
                    entityType = "task",
                    entityId = taskId,
                    operation = "create"
                )
            )
        }
    }
}

Hier erkennst du den Offline-first-Bezug: Die lokale Aufgabe und der ausstehende Sync-Eintrag gehören zusammen. Wird der Sync-Eintrag nicht gespeichert, kann die App die Änderung später nicht zuverlässig an den Server senden. Wird nur der Sync-Eintrag gespeichert, zeigt er auf Daten, die nicht existieren. Beides ist inkonsistent.

Eine gute Entscheidungsregel lautet: Verwende eine Transaktion, wenn ein Nutzer- oder Sync-Ereignis mehrere Tabellen oder mehrere Zeilen so verändert, dass ein Teilerfolg fachlich falsch wäre. Verwende keine große Transaktion nur aus Gewohnheit. Lange Transaktionen können andere Datenbankzugriffe blockieren und machen Fehler schwerer zu verstehen. Halte sie kurz, klar und auf die konkrete Datenänderung begrenzt.

Eine typische Stolperfalle ist, Netzwerkaufrufe in eine Datenbanktransaktion zu legen. Das ist fast immer eine schlechte Idee. Eine Transaktion sollte lokale Datenbankarbeit kapseln, keine langsamen oder unsicheren externen Operationen. Wenn du erst einen Server aufrufst und dann lokal speicherst, oder lokal speicherst und später synchronisierst, trenne diese Schritte sauber. Für Offline-first-Logik speicherst du lokale Änderung und Sync-Auftrag transaktional; der spätere Netzwerkversuch läuft außerhalb.

Eine zweite Stolperfalle betrifft Exceptions. Eine Transaktion wird nur zuverlässig zurückgerollt, wenn der Fehler als Exception aus dem transaktionalen Block herausläuft. Wenn du Fehler innerhalb der Transaktion abfängst und trotzdem normal zurückkehrst, kann Room die Transaktion als erfolgreich betrachten. Fange Fehler daher entweder außerhalb ab oder wirf sie nach dem Logging wieder, wenn die Änderung nicht übernommen werden darf.

Auch für Compose ist die Grenze wichtig. Starte keine komplexen Datenbankschreibfolgen direkt aus einem Composable heraus. Ein Button ruft eine ViewModel-Funktion auf, diese startet eine Coroutine im passenden Scope, und die eigentliche Transaktionslogik liegt im Repository oder DAO. So bleibt dein UI deklarativ und testbar. Der angezeigte State folgt dem Datenstand, statt selbst Zwischenschritte zu koordinieren.

Testen solltest du Transaktionen nicht nur im Erfolgsfall. Ein sinnvoller Test prüft auch, dass bei einem Fehler kein Teilzustand gespeichert bleibt. Du kannst mit einer In-Memory-Room-Datenbank arbeiten und gezielt ungültige Daten verwenden, zum Beispiel eine Foreign-Key-Verletzung in der Verbindungstabelle. Danach fragst du die Datenbank ab und prüfst, dass weder die Aufgabe noch unvollständige Referenzen vorhanden sind.

Code-Reviews sind ebenfalls hilfreich. Frage bei jeder neuen Schreibfunktion: Welche Datenbankänderungen gehören fachlich zusammen? Was passiert, wenn die App nach dem ersten Write beendet wird? Kann die UI danach einen Zustand anzeigen, den sie nicht sinnvoll behandeln kann? Gibt es einen Test für den Fehlerpfad? Diese Fragen sind oft wertvoller als eine lange Liste aller Room-APIs.

In CI solltest du solche Datenbanktests regelmäßig laufen lassen. Gerade bei Schemaänderungen, neuen Constraints oder geänderten Sync-Regeln können Transaktionsgrenzen versehentlich beschädigt werden. Ein Test, der Atomicity prüft, schützt dich vor Fehlern, die erst nach mehreren App-Starts oder Offline-Szenarien sichtbar würden.

Fazit

Room Transactions geben dir eine klare Methode, zusammengehörige Writes in Android als eine fachliche Einheit zu speichern. Nutze sie dort, wo ein Teilerfolg deine Daten inkonsistent machen würde, besonders bei mehreren Tabellen, Offline-first-Abläufen und lokalen Sync-Aufträgen. Prüfe dein Verständnis aktiv: Schreibe eine kleine DAO-Methode mit zwei Inserts, erzwinge im Test einen Fehler beim zweiten Insert und kontrolliere danach, ob wirklich nichts gespeichert wurde. Genau an solchen Fehlerfällen lernst du, ob deine Data Layer zuverlässig arbeitet.

Quellen (6)
Redaktion

Geschrieben von

Redaktion

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