Room-Migrationen – das Schema ändern, ohne Nutzerdaten zu verlieren
Eine neue Spalte in der Entity, und beim nächsten Start ist der Spielstand weg – weil fallbackToDestructiveMigration die Datenbank einfach löscht. So machst du es richtig, inklusive Test.
Room prüft beim Start, ob das Schema in der Datei zur Version im Code passt. Passt es nicht und gibt es keine Migration, wirft es eine IllegalStateException – oder, was schlimmer ist, du hast fallbackToDestructiveMigration() gesetzt und die Datenbank wird kommentarlos neu angelegt. Für den Nutzer heißt das: alles weg.
Der Ablauf
Beim Ändern einer Entity gilt: Version hochzählen und eine Migration schreiben.
@Database(entities = [Notiz::class], version = 2, exportSchema = true)
abstract class AppDb : RoomDatabase() {
abstract fun notizen(): NotizDao
}
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE notiz ADD COLUMN farbe TEXT NOT NULL DEFAULT '#ffffff'")
}
}
val db = Room.databaseBuilder(context, AppDb::class.java, "app.db")
.addMigrations(MIGRATION_1_2)
.build()Wichtig beim Hinzufügen einer NOT NULL-Spalte: Ohne DEFAULT schlägt das ALTER TABLE bei vorhandenen Zeilen fehl. Und der Standardwert muss genau dem entsprechen, was die Entity deklariert – sonst meldet Room beim Start eine Schema-Abweichung.
Spalten umbauen: der Vier-Schritte-Tanz
SQLite kann Spalten nicht beliebig ändern. Für Typänderungen, neue Fremdschlüssel oder entfernte Spalten führt der Weg über eine neue Tabelle:
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("""
CREATE TABLE notiz_neu (
id INTEGER PRIMARY KEY NOT NULL,
titel TEXT NOT NULL,
text TEXT NOT NULL,
erstellt INTEGER NOT NULL DEFAULT 0
)
""".trimIndent())
db.execSQL("INSERT INTO notiz_neu (id, titel, text, erstellt) SELECT id, titel, text, 0 FROM notiz")
db.execSQL("DROP TABLE notiz")
db.execSQL("ALTER TABLE notiz_neu RENAME TO notiz")
db.execSQL("CREATE INDEX index_notiz_titel ON notiz (titel)")
}
}Indizes musst du dabei neu anlegen – sie hängen an der alten Tabelle und verschwinden mit ihr.
Das Schema exportieren (und einchecken)
// build.gradle.kts
ksp { arg("room.schemaLocation", "$projectDir/schemas") }Room legt dort je Version eine JSON-Datei ab. Die gehört ins Git: Sie ist die Grundlage für automatische Migrationstests – und sie zeigt im Code-Review, was sich am Schema geändert hat.
Automatische Migrationen für einfache Fälle
@Database(
entities = [Notiz::class],
version = 4,
autoMigrations = [AutoMigration(from = 3, to = 4)],
exportSchema = true,
)
abstract class AppDb : RoomDatabase()Für hinzugefügte Spalten und Tabellen reicht das. Beim Umbenennen hilfst du mit @RenameColumn nach; bei Typänderungen bleibt es bei Handarbeit.
Testen, bevor es Nutzer merken
@get:Rule
val helper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AppDb::class.java,
)
@Test
fun migriere1zu2() {
helper.createDatabase(TEST_DB, 1).apply {
execSQL("INSERT INTO notiz (id, titel, text) VALUES (1, 'Alt', 'Inhalt')")
close()
}
val db = helper.runMigrationsAndValidate(TEST_DB, 2, true, MIGRATION_1_2)
db.query("SELECT farbe FROM notiz WHERE id = 1").use {
it.moveToFirst()
assertEquals("#ffffff", it.getString(0))
}
}Das ist der eigentliche Punkt: Eine Migration, die nie gegen echte alte Daten gelaufen ist, ist eine Vermutung.
Fallstrick
fallbackToDestructiveMigration() gehört in keine Version, die im Store liegt – höchstens in Debug-Builds, und dann bewusst. Denk außerdem an den Sprung über mehrere Versionen: Ein Nutzer, der drei Updates übersprungen hat, startet mit Version 1 in eine App mit Version 4. Room kettet dafür deine Migrationen – aber nur, wenn sie lückenlos vorhanden sind.
Android-Apps mit lokaler Datenhaltung baue und pflege ich regelmäßig. Melde dich über bymw.de.
Quellen
Du brauchst mehr als ein Snippet?
Ich entwickle Android-Apps in Kotlin und moderne Websites für Selbstständige und kleine Unternehmen — von der ersten Idee bis zum Release.
Projekt anfragen →Verwandte Snippets
stateIn und shareIn – aus einem kalten Flow wird geteilter Zustand
Jeder Sammler eines kalten Flows startet die Arbeit neu – zwei Beobachter, zwei Datenbankabfragen. stateIn und shareIn machen daraus einen Strom, den sich alle teilen, samt aktuellem Wert und Abschalten bei Inaktivität.
viewModelScope – Coroutinen, die mit dem Bildschirm verschwinden
Eine Coroutine, die nach dem Schließen des Bildschirms weiterläuft, schreibt in ein ViewModel, das niemand mehr sieht – und hält im schlimmsten Fall die ganze Activity im Speicher. viewModelScope beendet sie automatisch.
kotlinx.serialization: JSON und data class ohne Handarbeit verbinden
Eine API antwortet mit JSON, deine App will eine data class. Dazwischen steht in vielen Projekten ein handgeschriebener Parser. Mit kotlinx.serialization sind es eine Annotation und eine Zeile – und ein Schalter, ohne den deine App beim nächsten API-Update abstürzt.