Room-Migrationen sicher planen
Room-Migrationen schützen lokale Daten bei Schemaänderungen. Du lernst, Versionen und Tests gezielt einzusetzen.
Wenn deine App lokale Daten mit Room speichert, wird ihr Datenbankschema nicht für immer gleich bleiben. Neue Features brauchen neue Spalten, Tabellen werden umbenannt, Felder bekommen andere Bedeutungen. Room-Migrationen sorgen dafür, dass diese Änderungen bei einem App-Update kontrolliert ablaufen, ohne dass du Nutzerdaten verlierst oder die App beim Start abstürzt.
Was ist das?
Eine Room-Migration ist eine definierte Umstellung deiner lokalen SQLite-Datenbank von einer Version auf eine andere. Du sagst Room damit: Wenn eine installierte App noch Datenbank-Version 1 hat und der neue App-Code Version 2 erwartet, führe diese SQL-Schritte aus, damit die vorhandenen Daten zum neuen Schema passen.
Das mentale Modell ist wichtig: Deine Datenbank ist nicht nur ein technisches Detail im Gerät. Sie ist ein Vertrag zwischen App-Versionen. Version 1 deiner App hat Daten in einer bestimmten Struktur gespeichert. Version 2 darf diese Struktur nicht stillschweigend anders interpretieren. Wenn du das Schema änderst, brauchst du einen Pfad von alt nach neu.
Im modernen Android-Kontext gehört Room meistens zur Data Layer. Repositorys greifen auf DAOs zu, ViewModels beobachten Daten als Flow, und Compose zeigt den aktuellen Zustand an. Für die UI ist es egal, ob eine Liste aus dem Netzwerk oder aus Room kommt. Für die Qualität deiner App ist es aber entscheidend, dass die lokale Datenquelle über Updates hinweg stabil bleibt.
Das ist besonders wichtig bei Offline-First-Apps. Wenn die App auch ohne Netzwerk funktionieren soll, ist die lokale Datenbank oft die verlässlichste Quelle. Eine fehlerhafte Migration kann dann nicht nur einen einzelnen Screen betreffen, sondern den gesamten Arbeitsfluss: gespeicherte Entwürfe, synchronisierte Objekte, Warteschlangen für spätere Uploads oder lokale Einstellungen.
Wie funktioniert es?
Room verwaltet eine Datenbankversion über die @Database-Annotation. Erhöhst du version, erwartet Room entweder eine passende Migration oder eine andere klare Strategie. Ohne Migration erkennt Room beim Öffnen der Datenbank: Das gespeicherte Schema passt nicht zur erwarteten Version. In einer produktiven App führt das typischerweise zu einem Fehler beim Starten der Datenbank.
Eine Migration besteht aus Startversion, Zielversion und SQL-Anweisungen. Diese SQL-Anweisungen werden direkt auf der bestehenden Datenbank ausgeführt. Dabei geht es nicht nur darum, dass die Tabellen am Ende irgendwie existieren. Die vorhandenen Daten müssen korrekt erhalten, umgeformt oder bewusst entfernt werden.
Typische Schemaänderungen sind:
- Eine neue Spalte wird zu einer bestehenden Tabelle hinzugefügt.
- Eine neue Tabelle wird eingeführt.
- Ein Index wird ergänzt.
- Eine Spalte wird umbenannt oder in eine neue Tabelle ausgelagert.
- Ein Feld wird von optional zu verpflichtend geändert.
Nicht jede Änderung ist gleich riskant. Eine neue nullable Spalte ist meist einfach. Eine neue nicht-nullbare Spalte braucht einen Default-Wert oder eine Datenumformung. Das Umbenennen einer Tabelle oder Spalte ist gefährlicher, weil du sicherstellen musst, dass bestehende Daten nicht verloren gehen. Noch anspruchsvoller wird es, wenn du fachliche Bedeutung änderst, etwa wenn aus einem einzelnen Statusfeld mehrere Zustände werden.
Room kann für bestimmte Fälle Auto-Migrations nutzen. Das hilft bei einfachen, eindeutig beschreibbaren Änderungen. Trotzdem solltest du das nicht als Freibrief verstehen. Sobald Daten inhaltlich umgebaut werden müssen, brauchst du eine manuelle Migration. Als Lernregel gilt: Wenn du die Änderung nicht in einem Satz als reine Strukturänderung erklären kannst, prüfe manuell, ob die Datenlogik betroffen ist.
In der täglichen Android-Entwicklung taucht das Thema oft unspektakulär auf. Du ergänzt ein Feld in einer Entity, passt einen DAO-Query an, der Compiler ist zufrieden, und die App läuft auf einem frischen Emulator. Genau dort entsteht die Stolperfalle: Ein frischer Installationszustand testet keine Migration. Nutzerinnen und Nutzer installieren aber nicht jeden Release neu. Sie aktualisieren von einer älteren Version mit echten Daten.
Darum gehören Migrationen zur Release-Praxis. Bei jeder Änderung an einer Room-Entity solltest du fragen: Ändert sich das Schema? Wenn ja: Welche alte Version kann im Feld existieren? Gibt es einen getesteten Pfad zur neuen Version? Diese Fragen wirken am Anfang streng, sparen dir aber spätere Fehler, die schwer zu reproduzieren sind.
In der Praxis
Stell dir eine kleine Notizen-App vor. Version 1 speichert Notizen mit id, title und content. In Version 2 möchtest du eine Spalte updatedAt ergänzen, damit du später sortieren kannst. Wenn die Spalte nicht nullable sein soll, brauchen bestehende Zeilen einen Wert. Eine Migration könnte so aussehen:
@Database(
entities = [NoteEntity::class],
version = 2
)
abstract class AppDatabase : RoomDatabase() {
abstract fun noteDao(): NoteDao
}
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
ALTER TABLE notes
ADD COLUMN updatedAt INTEGER NOT NULL DEFAULT 0
""".trimIndent()
)
}
}
val database = Room.databaseBuilder(
context,
AppDatabase::class.java,
"app.db"
)
.addMigrations(MIGRATION_1_2)
.build()
Der entscheidende Punkt ist der Default-Wert. Ohne ihn könnte SQLite bestehende Zeilen nicht mit einer nicht-nullbaren Spalte ergänzen. Für neue Datensätze setzt dein App-Code später einen echten Zeitwert. Für alte Datensätze kannst du 0 als bewusst gewählten Fallback behandeln, oder du berechnest in einer komplexeren Migration einen passenderen Wert.
Eine typische Stolperfalle ist fallbackToDestructiveMigration(). Diese Option löscht die vorhandene Datenbank und erstellt sie neu, wenn keine Migration vorhanden ist. Das kann für Prototypen, interne Tools oder bewusst flüchtige Caches akzeptabel sein. Für Nutzerdaten ist es meistens falsch. Wenn du gespeicherte Inhalte, Offline-Änderungen oder Sync-Zustände verlierst, hast du kein technisches Randproblem, sondern einen echten Qualitätsfehler.
Eine weitere Stolperfalle ist der Sprung über mehrere Versionen. Nutzer können Version 1 überspringen, lange nicht aktualisieren und dann direkt Version 4 installieren. Room braucht dann einen gültigen Migrationspfad. Du kannst Migrationen von 1 nach 2, 2 nach 3 und 3 nach 4 registrieren. Room kann diese Kette verwenden. Wenn du nur 3 nach 4 testest, übersiehst du ältere Installationen.
Tests sind deshalb kein Extra. Sie sind die praktische Kontrolle, ob dein Schema und deine SQL-Schritte zusammenpassen. Mit MigrationTestHelper kannst du eine alte Datenbank anlegen, Testdaten einfügen, die Migration ausführen und prüfen, ob die Daten danach korrekt lesbar sind. Der Test sollte nicht nur prüfen, dass die App nicht abstürzt. Er sollte fachlich relevante Werte prüfen.
Ein einfacher Testgedanke wäre: Lege in Version 1 eine Notiz an. Migriere nach Version 2. Prüfe, dass Titel und Inhalt erhalten bleiben und updatedAt den erwarteten Default-Wert hat. Bei komplexeren Änderungen prüfst du zusätzlich DAO-Abfragen, Sortierungen und Constraints. So verbindest du technische Migration mit dem Verhalten, das deine App wirklich braucht.
@Test
fun migrate1To2_keepsExistingNotes() {
helper.createDatabase(TEST_DB, 1).apply {
execSQL(
"INSERT INTO notes (id, title, content) VALUES (1, 'Einkauf', 'Milch')"
)
close()
}
val db = helper.runMigrationsAndValidate(
TEST_DB,
2,
true,
MIGRATION_1_2
)
val cursor = db.query("SELECT title, content, updatedAt FROM notes WHERE id = 1")
assertThat(cursor.moveToFirst()).isTrue()
assertThat(cursor.getString(0)).isEqualTo("Einkauf")
assertThat(cursor.getString(1)).isEqualTo("Milch")
assertThat(cursor.getLong(2)).isEqualTo(0L)
cursor.close()
}
Für Code-Reviews kannst du dir eine kurze Regel merken: Jede Änderung an einer @Entity braucht eine sichtbare Antwort auf die Versionsfrage. Entweder bleibt das Schema unverändert, oder die Datenbankversion steigt und eine Migration wird ergänzt, oder es gibt eine begründete Entscheidung, warum Daten verworfen werden dürfen. Diese Antwort sollte im Pull Request klar erkennbar sein.
Achte außerdem darauf, Migrationen klein und nachvollziehbar zu halten. Eine Migration sollte genau den Schritt zwischen zwei Versionen beschreiben. Wenn du viele fachliche Umbauten in einer einzigen Migration versteckst, wird sie schwer testbar. Besser ist ein klarer Versionsverlauf, der zur Produktentwicklung passt.
In Compose-Projekten siehst du Migrationen selten direkt in der UI. Trotzdem wirkt sich ihre Qualität dort aus. Wenn ein Flow aus dem DAO nach einem Update keine Daten mehr liefert, zeigt Compose korrekt einen leeren Zustand an, obwohl die Ursache in der Datenbank liegt. Deshalb solltest du bei Update-Fehlern nicht nur Composables und ViewModels prüfen, sondern auch die lokale Datenquelle und ihre Versionierung.
Fazit
Room-Migrationen sind der kontrollierte Update-Pfad für lokale Daten. Baue dir die Gewohnheit auf, bei jeder Schemaänderung sofort an Versionen, Datenbestand und Tests zu denken. Übe das mit einer kleinen Beispiel-App: Erstelle Version 1, speichere Testdaten, ändere das Schema, schreibe die Migration und prüfe sie mit einem Migrationstest. Im Debugger und im Code-Review solltest du anschließend erklären können, welche Daten vor dem Update existieren, welche SQL-Schritte ausgeführt werden und warum das Ergebnis zur neuen App-Version passt.