Room-Datenbank in Android 2026: Migrationen, Relationen und Coroutines

Beherrsche Room-Datenbanken in Android mit Migrationen, Entity-Relationen, Kotlin Coroutines und Flow. Vollständiges Tutorial mit produktionsreifen Patterns für 2026.

Room Database Android Kotlin SQLite Datenpersistenz

Die Room-Datenbank bietet eine robuste Abstraktionsschicht über SQLite in Android und übernimmt den Boilerplate-Code, der die direkte SQLite-Nutzung mühsam macht. Mit Room 2.6 und Kotlin Coroutines ist der Aufbau reaktiver, typsicherer Datenbankschichten deutlich einfacher geworden.

Room 2.6 Hauptfunktionen

Room 2.6 (stabil seit 2026) führt verbesserte Migrationsunterstützung, schnellere KSP-Verarbeitung und bessere Flow-Integration ein. Dieses Tutorial verwendet diese neuesten APIs für produktionsreifen Code.

Room-Datenbank mit Kotlin KSP einrichten

Room erfordert drei Kernkomponenten: Entities (Tabellen), DAOs (Data Access Objects) und die Datenbankklasse. Der KSP-Annotationsprozessor generiert Implementierungscode zur Kompilierzeit.

Die Dependencies werden zur modul-spezifischen build.gradle.kts hinzugefügt. KSP hat KAPT seit 2024 als empfohlener Annotationsprozessor abgelöst und bietet schnellere Build-Zeiten.

build.gradle.kts (Module :app)kotlin
plugins {
    id("com.google.devtools.ksp") version "2.1.0-1.0.29"
}

dependencies {
    val roomVersion = "2.6.1"
    implementation("androidx.room:room-runtime:$roomVersion")
    implementation("androidx.room:room-ktx:$roomVersion") // Coroutines support
    ksp("androidx.room:room-compiler:$roomVersion")
}

Das room-ktx-Artefakt stellt Coroutine-Erweiterungen bereit, die Suspend-Funktionen in DAOs ermöglichen.

Entities mit Primary Keys und Indizes definieren

Entities werden direkt auf SQLite-Tabellen abgebildet. Jede Entity-Klasse benötigt mindestens einen Primärschlüssel. Das Hinzufügen von Indizes auf häufig abgefragten Spalten verbessert die Leseleistung auf Kosten etwas langsamerer Schreibvorgänge.

User.ktkotlin
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey

@Entity(
    tableName = "users",
    indices = [
        Index(value = ["email"], unique = true), // Unique constraint + index
        Index(value = ["created_at"]) // Index for sorting queries
    ]
)
data class User(
    @PrimaryKey(autoGenerate = true)
    val id: Long = 0,
    val email: String,
    val displayName: String,
    @ColumnInfo(name = "created_at")
    val createdAt: Long = System.currentTimeMillis(),
    val isActive: Boolean = true
)

Die @ColumnInfo-Annotation passt den Spaltennamen an, wenn der Kotlin-Eigenschaftsname vom gewünschten Datenbank-Spaltennamen abweicht.

DAOs mit Coroutines und Flow erstellen

DAOs definieren die Datenbankoperationen. Room 2.6 unterstützt drei asynchrone Patterns: Suspend-Funktionen für einmalige Operationen, Flow für reaktive Streams und ListenableFuture für Java-Interoperabilität.

UserDao.ktkotlin
import androidx.room.*
import kotlinx.coroutines.flow.Flow

@Dao
interface UserDao {
    // One-shot insert - returns the generated ID
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertUser(user: User): Long

    // Batch insert - efficient for multiple records
    @Insert(onConflict = OnConflictStrategy.IGNORE)
    suspend fun insertUsers(users: List<User>)

    // Flow emits new data whenever the table changes
    @Query("SELECT * FROM users WHERE is_active = 1 ORDER BY created_at DESC")
    fun observeActiveUsers(): Flow<List<User>>

    // Suspend function for one-shot read
    @Query("SELECT * FROM users WHERE id = :userId")
    suspend fun getUserById(userId: Long): User?

    // Update returns the number of affected rows
    @Update
    suspend fun updateUser(user: User): Int

    // Delete by primary key
    @Delete
    suspend fun deleteUser(user: User)

    // Custom delete query
    @Query("DELETE FROM users WHERE is_active = 0")
    suspend fun deleteInactiveUsers(): Int
}

Flow-Abfragen geben automatisch aktualisierte Ergebnisse aus, wenn sich die zugrunde liegenden Daten ändern. Dies eliminiert manuelle Aktualisierungslogik in ViewModels.

Die Room-Datenbankklasse erstellen

Die Datenbankklasse verbindet Entities und DAOs. Ein Singleton-Pattern verhindert mehrere Datenbankinstanzen, die Ressourcenlecks verursachen würden.

AppDatabase.ktkotlin
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase

@Database(
    entities = [User::class, Post::class],
    version = 1,
    exportSchema = true // Generates schema JSON for migration validation
)
abstract class AppDatabase : RoomDatabase() {
    abstract fun userDao(): UserDao
    abstract fun postDao(): PostDao

    companion object {
        @Volatile
        private var INSTANCE: AppDatabase? = null

        fun getInstance(context: Context): AppDatabase {
            return INSTANCE ?: synchronized(this) {
                val instance = Room.databaseBuilder(
                    context.applicationContext,
                    AppDatabase::class.java,
                    "app_database"
                )
                    .fallbackToDestructiveMigration() // Only for development
                    .build()
                INSTANCE = instance
                instance
            }
        }
    }
}

Die Einstellung exportSchema = true generiert JSON-Schema-Dateien im schemas/-Ordner, die für die Validierung von Migrationen in Tests nützlich sind.

Produktions-Migrationsstrategie

Verwende niemals fallbackToDestructiveMigration() in der Produktion. Dies löscht alle Benutzerdaten bei Schema-Änderungen. Der nächste Abschnitt behandelt die korrekte Migrationshandhabung.

Sichere Datenbankmigrationen schreiben

Migrationen bewahren Benutzerdaten, wenn sich das Schema zwischen App-Versionen ändert. Room validiert Migrationen zur Laufzeit, indem es das erwartete Schema mit der migrierten Datenbank vergleicht.

Eine Migration definiert die SQL-Anweisungen, die benötigt werden, um die Datenbank von Version N zu Version N+1 zu transformieren.

Migrations.ktkotlin
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase

// Migration from version 1 to 2: add phone_number column
val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(db: SupportSQLiteDatabase) {
        db.execSQL("ALTER TABLE users ADD COLUMN phone_number TEXT")
    }
}

// Migration from version 2 to 3: add posts table with foreign key
val MIGRATION_2_3 = object : Migration(2, 3) {
    override fun migrate(db: SupportSQLiteDatabase) {
        db.execSQL("""
            CREATE TABLE IF NOT EXISTS posts (
                id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
                user_id INTEGER NOT NULL,
                title TEXT NOT NULL,
                content TEXT NOT NULL,
                created_at INTEGER NOT NULL,
                FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
            )
        """.trimIndent())
        db.execSQL("CREATE INDEX index_posts_user_id ON posts(user_id)")
    }
}

// Register migrations in database builder
fun getInstance(context: Context): AppDatabase {
    return INSTANCE ?: synchronized(this) {
        Room.databaseBuilder(
            context.applicationContext,
            AppDatabase::class.java,
            "app_database"
        )
            .addMigrations(MIGRATION_1_2, MIGRATION_2_3)
            .build()
            .also { INSTANCE = it }
    }
}

Room unterstützt Migrationsketten. Wenn ein Benutzer von Version 1 auf Version 3 aktualisiert, führt Room MIGRATION_1_2 und dann MIGRATION_2_3 nacheinander aus.

Auto-Migrationen in Room 2.6

Für einfache Schema-Änderungen (Hinzufügen von Spalten, Tabellen oder Indizes) kann Room Migrationen automatisch generieren. Dies reduziert Boilerplate-Code für unkomplizierte Updates.

AppDatabase.ktkotlin
@Database(
    entities = [User::class, Post::class],
    version = 3,
    autoMigrations = [
        AutoMigration(from = 1, to = 2), // Adding nullable column
        AutoMigration(
            from = 2,
            to = 3,
            spec = AutoMigration2To3::class // Complex changes need spec
        )
    ],
    exportSchema = true
)
abstract class AppDatabase : RoomDatabase() {
    // DAOs...
}

// Migration spec for changes that need hints
@RenameColumn(tableName = "users", fromColumnName = "name", toColumnName = "display_name")
class AutoMigration2To3 : AutoMigrationSpec

Auto-Migrationen erfordern exportSchema = true zum Vergleich der Versionen. Für Spaltenumbenennungen, Tabellenumbenennungen oder Löschungen muss eine AutoMigrationSpec mit den entsprechenden Annotationen bereitgestellt werden.

Bereit für deine Android-Interviews?

Übe mit unseren interaktiven Simulatoren, Flashcards und technischen Tests.

Entity-Relationen mit Embedded Objects modellieren

Room unterstützt drei Relationsmuster: eingebettete Objekte, One-to-Many und Many-to-Many. Im Gegensatz zu ORMs gibt Room flache Daten zurück — Relationen erfordern explizite Abfragen.

One-to-Many: Benutzer mit Beiträgen

Ein Benutzer kann mehrere Beiträge haben. Die Relation wird mit der @Relation-Annotation in einer Datenklasse definiert, die beide Entities kombiniert.

Post.ktkotlin
@Entity(
    tableName = "posts",
    foreignKeys = [
        ForeignKey(
            entity = User::class,
            parentColumns = ["id"],
            childColumns = ["user_id"],
            onDelete = ForeignKey.CASCADE // Delete posts when user deleted
        )
    ],
    indices = [Index("user_id")] // Required for foreign key performance
)
data class Post(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    @ColumnInfo(name = "user_id") val userId: Long,
    val title: String,
    val content: String,
    @ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis()
)

// UserWithPosts.kt - Relation container
data class UserWithPosts(
    @Embedded val user: User,
    @Relation(
        parentColumn = "id",
        entityColumn = "user_id"
    )
    val posts: List<Post>
)

// In UserDao.kt
@Transaction // Ensures atomic read of user + posts
@Query("SELECT * FROM users WHERE id = :userId")
suspend fun getUserWithPosts(userId: Long): UserWithPosts?

@Transaction
@Query("SELECT * FROM users WHERE is_active = 1")
fun observeActiveUsersWithPosts(): Flow<List<UserWithPosts>>

Die @Transaction-Annotation verhindert inkonsistente Lesevorgänge, wenn die Relation intern mehrere Abfragen umfasst.

Many-to-Many: Benutzer und Tags

Many-to-Many-Relationen erfordern eine Junction-Tabelle (Kreuzverweis), die Paare von Fremdschlüsseln speichert.

Tag.ktkotlin
@Entity(tableName = "tags")
data class Tag(
    @PrimaryKey(autoGenerate = true) val tagId: Long = 0,
    val name: String
)

// UserTagCrossRef.kt - Junction table
@Entity(
    tableName = "user_tag_cross_ref",
    primaryKeys = ["userId", "tagId"],
    foreignKeys = [
        ForeignKey(entity = User::class, parentColumns = ["id"], childColumns = ["userId"], onDelete = ForeignKey.CASCADE),
        ForeignKey(entity = Tag::class, parentColumns = ["tagId"], childColumns = ["tagId"], onDelete = ForeignKey.CASCADE)
    ]
)
data class UserTagCrossRef(
    val userId: Long,
    val tagId: Long
)

// UserWithTags.kt
data class UserWithTags(
    @Embedded val user: User,
    @Relation(
        parentColumn = "id",
        entityColumn = "tagId",
        associateBy = Junction(UserTagCrossRef::class)
    )
    val tags: List<Tag>
)

Der associateBy-Parameter spezifiziert die Junction-Tabelle, die Benutzer mit Tags verknüpft.

Room mit ViewModel und Repository integrieren

Eine saubere Architektur trennt die Datenbankschicht von der UI. Das Repository-Pattern abstrahiert Datenquellen, während ViewModel den Zustand für Compose UI bereitstellt.

UserRepository.ktkotlin
class UserRepository(private val userDao: UserDao) {

    val activeUsers: Flow<List<User>> = userDao.observeActiveUsers()

    suspend fun createUser(email: String, displayName: String): Long {
        val user = User(email = email, displayName = displayName)
        return userDao.insertUser(user)
    }

    suspend fun deactivateUser(userId: Long) {
        userDao.getUserById(userId)?.let { user ->
            userDao.updateUser(user.copy(isActive = false))
        }
    }
}

// UserViewModel.kt
class UserViewModel(
    private val repository: UserRepository
) : ViewModel() {

    val users: StateFlow<List<User>> = repository.activeUsers
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000),
            initialValue = emptyList()
        )

    fun createUser(email: String, name: String) {
        viewModelScope.launch {
            repository.createUser(email, name)
            // No manual refresh needed - Flow emits automatically
        }
    }
}

Der stateIn-Operator konvertiert den kalten Flow von Room in einen heißen StateFlow, der für Compose Collection geeignet ist. Weitere Details zu Coroutine-Patterns finden sich im Leitfaden zu Kotlin Coroutines.

Room-Datenbanken testen

Room bietet einen In-Memory-Datenbank-Builder für Tests: Room.inMemoryDatabaseBuilder(). Tests laufen schneller und persistieren keine Daten zwischen den Durchläufen. Migrationen sollten immer mit MigrationTestHelper aus dem room-testing-Artefakt getestet werden.

Type Converter für komplexe Typen

Room speichert nativ nur primitive Typen. Type Converter transformieren komplexe Typen (Datumswerte, Enums, Listen) in speicherbare Formate.

Converters.ktkotlin
import androidx.room.TypeConverter
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset

class Converters {
    // Instant <-> Long (milliseconds)
    @TypeConverter
    fun fromInstant(instant: Instant?): Long? = instant?.toEpochMilli()

    @TypeConverter
    fun toInstant(millis: Long?): Instant? = millis?.let { Instant.ofEpochMilli(it) }

    // LocalDate <-> String (ISO format)
    @TypeConverter
    fun fromLocalDate(date: LocalDate?): String? = date?.toString()

    @TypeConverter
    fun toLocalDate(dateStr: String?): LocalDate? = dateStr?.let { LocalDate.parse(it) }

    // List<String> <-> JSON string
    @TypeConverter
    fun fromStringList(list: List<String>?): String? =
        list?.joinToString(separator = ",")

    @TypeConverter
    fun toStringList(data: String?): List<String>? =
        data?.split(",")?.filter { it.isNotBlank() }
}

// Register in database class
@Database(...)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase()

Für die JSON-Serialisierung komplexer Objekte empfiehlt sich die Verwendung von kotlinx.serialization anstelle manueller String-Manipulation.

Datenbankoperationen im Coroutines-Scope handhaben

Room-DAO-Operationen sollten auf dem IO-Dispatcher ausgeführt werden. Obwohl Room das Threading intern für Suspend-Funktionen handhabt, hält Error-Handling auf Repository-Ebene den Code robust.

UserRepository.kt with error handlingkotlin
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

class UserRepository(private val userDao: UserDao) {

    suspend fun createUser(email: String, displayName: String): Result<Long> {
        return withContext(Dispatchers.IO) {
            try {
                val id = userDao.insertUser(
                    User(email = email, displayName = displayName)
                )
                Result.success(id)
            } catch (e: SQLiteConstraintException) {
                // Unique constraint violation (duplicate email)
                Result.failure(DuplicateEmailException(email))
            }
        }
    }

    suspend fun deleteAllInactive(): Int = withContext(Dispatchers.IO) {
        userDao.deleteInactiveUsers()
    }
}

class DuplicateEmailException(email: String) : Exception("Email already exists: $email")

Das Wrappen von Ergebnissen in Result<T> ermöglicht es ViewModels, Fehler elegant zu behandeln, ohne Try-Catch-Boilerplate.

Fazit

Der Aufbau einer produktionsreifen Room-Datenbankschicht umfasst mehrere Schlüsselpraktiken:

  • KSP anstelle von KAPT für schnellere Kompilierung mit Room 2.6 verwenden
  • Indizes auf Spalten hinzufügen, die in WHERE-Klauseln und JOIN-Bedingungen verwendet werden
  • Flow von DAOs zurückgeben für automatische UI-Updates bei Datenänderungen
  • Explizite Migrationen für Schema-Änderungen schreiben — niemals destruktive Migration in der Produktion verwenden
  • @Transaction für Relationsabfragen verwenden, um konsistente Lesevorgänge zu gewährleisten
  • Datenbankoperationen in Result wrappen für sauberes Error-Handling in ViewModels
  • Migrationen mit MigrationTestHelper vor der Veröffentlichung von Updates testen

Diese Patterns können mit Room-Datenbank-Interviewfragen vertieft werden.

Fang an zu üben!

Teste dein Wissen mit unseren Interview-Simulatoren und technischen Tests.

Tags

#android
#room
#kotlin
#sqlite
#coroutines
#database

Teilen

Verwandte Artikel