Room Database trong Android 2026: Migrations, Relations và Coroutines

Thành thạo Room database trong Android với migrations, entity relations, Kotlin coroutines và Flow. Hướng dẫn đầy đủ với các pattern production-ready cho 2026.

Room Database Android Kotlin SQLite data persistence

Room database cung cấp một lớp trừu tượng mạnh mẽ trên SQLite trong Android, xử lý code boilerplate khiến SQLite thuần trở nên nhàm chán khi làm việc. Với Room 2.6 và Kotlin coroutines, việc xây dựng các lớp database reactive và type-safe đã trở nên đơn giản hơn đáng kể.

Tính năng chính của Room 2.6

Room 2.6 (ổn định trong năm 2026) giới thiệu hỗ trợ migration được cải thiện, tốc độ xử lý KSP nhanh hơn và tích hợp Flow tốt hơn. Hướng dẫn này sử dụng các API mới nhất cho code production-ready.

Thiết lập Room Database với Kotlin KSP

Room yêu cầu ba thành phần cốt lõi: entities (bảng), DAOs (data access objects) và class database. Bộ xử lý annotation KSP tạo code implementation tại thời điểm biên dịch.

Thêm dependencies vào file build.gradle.kts cấp module. KSP đã thay thế KAPT làm bộ xử lý annotation được khuyến nghị từ năm 2024, cung cấp thời gian build nhanh hơn.

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")
}

Artifact room-ktx cung cấp các extension coroutine, cho phép các hàm suspend trong DAOs.

Định nghĩa Entities với Primary Keys và Indices

Entities được ánh xạ trực tiếp vào các bảng SQLite. Mỗi class entity cần ít nhất một primary key. Thêm indices vào các cột được query thường xuyên cải thiện hiệu suất đọc với chi phí ghi chậm hơn một chút.

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
)

Annotation @ColumnInfo tùy chỉnh tên cột khi tên property Kotlin khác với tên cột database mong muốn.

Xây dựng DAOs với Coroutines và Flow

DAOs định nghĩa các thao tác database. Room 2.6 hỗ trợ ba pattern async: hàm suspend cho các thao tác one-shot, Flow cho các stream reactive và ListenableFuture cho tương tác Java.

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
}

Các query trả về Flow tự động phát ra kết quả cập nhật khi dữ liệu cơ bản thay đổi. Điều này loại bỏ logic refresh thủ công trong ViewModels.

Tạo Class Room Database

Class database liên kết entities và DAOs với nhau. Pattern singleton ngăn chặn nhiều instance database, điều có thể gây rò rỉ resource.

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
            }
        }
    }
}

Đặt exportSchema = true tạo các file schema JSON trong thư mục schemas/, hữu ích cho việc xác thực migrations trong các bài test.

Chiến lược Migration cho Production

Không bao giờ sử dụng fallbackToDestructiveMigration() trong production. Điều này xóa tất cả dữ liệu người dùng khi schema thay đổi. Phần tiếp theo đề cập đến việc xử lý migration đúng cách.

Viết Database Migrations An toàn

Migrations bảo toàn dữ liệu người dùng khi schema thay đổi giữa các phiên bản ứng dụng. Room xác thực migrations tại runtime bằng cách so sánh schema mong đợi với database đã được migrate.

Một migration định nghĩa các câu lệnh SQL cần thiết để chuyển đổi database từ phiên bản N sang phiên bản N+1.

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 hỗ trợ chuỗi migration. Nếu người dùng nâng cấp từ phiên bản 1 lên phiên bản 3, Room thực thi MIGRATION_1_2 rồi MIGRATION_2_3 theo thứ tự.

Auto-Migrations trong Room 2.6

Đối với các thay đổi schema đơn giản (thêm cột, bảng hoặc indices), Room có thể tự động tạo migrations. Điều này giảm boilerplate cho các cập nhật đơn giản.

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-migrations yêu cầu exportSchema = true để so sánh các phiên bản. Đối với việc đổi tên cột, đổi tên bảng hoặc xóa, cung cấp một AutoMigrationSpec với các annotation phù hợp.

Sẵn sàng chinh phục phỏng vấn Android?

Luyện tập với mô phỏng tương tác, flashcards và bài kiểm tra kỹ thuật.

Mô hình hóa Entity Relations với Embedded Objects

Room hỗ trợ ba pattern relation: embedded objects, one-to-many và many-to-many. Không giống như ORMs, Room trả về dữ liệu phẳng—relations yêu cầu các query rõ ràng.

One-to-Many: User với Posts

Một user có thể có nhiều posts. Định nghĩa relation sử dụng annotation @Relation trong một data class kết hợp cả hai entities.

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>>

Annotation @Transaction ngăn chặn các lần đọc không nhất quán khi relation bao gồm nhiều query nội bộ.

Many-to-Many: Users và Tags

Relations many-to-many yêu cầu một bảng junction (cross-reference) lưu trữ các cặp foreign keys.

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>
)

Tham số associateBy chỉ định bảng junction liên kết users với tags.

Tích hợp Room với ViewModel và Repository

Kiến trúc sạch tách biệt lớp database khỏi UI. Pattern repository trừu tượng hóa các nguồn dữ liệu, trong khi ViewModel expose state cho Compose UI.

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
        }
    }
}

Operator stateIn chuyển đổi cold Flow từ Room thành hot StateFlow phù hợp cho việc collect trong Compose. Để biết thêm chi tiết về các pattern coroutines, xem hướng dẫn về Kotlin Coroutines.

Testing Room Databases

Room cung cấp builder database in-memory cho testing: Room.inMemoryDatabaseBuilder(). Các test chạy nhanh hơn và không lưu trữ dữ liệu giữa các lần chạy. Luôn test migrations với MigrationTestHelper từ artifact room-testing.

Type Converters cho các Kiểu Phức tạp

Room chỉ lưu trữ các kiểu primitive một cách native. Type converters chuyển đổi các kiểu phức tạp (dates, enums, lists) sang các định dạng có thể lưu trữ.

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()

Đối với việc serialize JSON của các object phức tạp, cân nhắc sử dụng kotlinx.serialization thay vì thao tác string thủ công.

Xử lý Database Operations trong Coroutines Scope

Các thao tác DAO Room nên chạy trên IO dispatcher. Mặc dù Room xử lý threading nội bộ cho các hàm suspend, việc xử lý lỗi ở cấp repository giữ cho 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")

Việc wrap kết quả trong Result<T> cho phép ViewModels xử lý lỗi một cách thanh lịch mà không cần boilerplate try-catch.

Kết luận

Xây dựng một lớp Room database production-ready liên quan đến một số thực hành quan trọng:

  • Sử dụng KSP thay vì KAPT để biên dịch nhanh hơn với Room 2.6
  • Thêm indices vào các cột được sử dụng trong mệnh đề WHERE và điều kiện JOIN
  • Trả về Flow từ DAOs để cập nhật UI tự động khi dữ liệu thay đổi
  • Viết migrations rõ ràng cho các thay đổi schema—không bao giờ sử dụng destructive migration trong production
  • Sử dụng @Transaction cho các query relation để đảm bảo đọc nhất quán
  • Wrap các thao tác database trong Result để xử lý lỗi sạch sẽ trong ViewModels
  • Test migrations với MigrationTestHelper trước khi phát hành cập nhật

Thực hành các pattern này với câu hỏi phỏng vấn Room database để củng cố các khái niệm.

Bắt đầu luyện tập!

Kiểm tra kiến thức với mô phỏng phỏng vấn và bài kiểm tra kỹ thuật.

Thẻ

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

Chia sẻ

Bài viết liên quan