Jetpack系列(三):Room数据库——从增删改查到数据库平滑升级

前言

使用原生的 API 来完成数据库数据的增删改查操作,虽然简单易用,但在大型项目中,很容易导致混乱。为此,我们可以选择专门为 Android 数据库设计的 ORM 框架来解决这个问题。

ORM(Object Relational Mapping)对象关系映射。简单来说,就是为面向对象的语言和面向关系的数据库建立了一种映射关系。我们可以使用面向对象的思想来操作数据库,不用接触 SQL 语句,也不用担心会让项目代码变得混乱。

Android 官方给我们提供了一个 ORM 框架,它就是 Room。

使用 Room 进行增删改查

Room 主要由三部分组成:

  • Entity: 用于承载实际数据的实体类。每个实体类都会在数据库中有一张对应的表,表的列是根据实体类中的字段自动生成的。

  • Dao: 数据访问对象。我们会封装对数据库的各种操作,逻辑层可以直接和 Dao 层进行交互。

  • Database: 用于定义数据库的信息。包含数据库版本号、数据表对应的实体类以及 Dao 层的访问实例。

添加依赖

build.gradle.kts 配置文件中添加如下依赖:

kotlin 复制代码
plugins {
    id("com.google.devtools.ksp") version "2.1.0-1.0.28"
}

dependencies {
    // Room 核心运行时库
    implementation("androidx.room:room-runtime:2.7.2")
    // 注解处理器插件
    ksp("androidx.room:room-compiler:2.7.2")
    // Kotlin 扩展和协程支持库
    implementation("androidx.room:room-ktx:2.7.2")
}

注意:KSP 插件的版本号("2.1.0")需要与项目中的 Kotlin 编译器的版本保持一致。

其中 implementation("androidx.room:room-runtime:...") 是 Room 的核心运行时库,所有关于 Room 的 API 都在里面。

ksp("androidx.room:room-compiler:...") 用于在编译期检查、处理注解,并生成对应的模版代码。

implementation("androidx.room:room-ktx:...") 为 Room 添加了许多对于 Kotlin 语言的便利功能,其中最重要的就是让我们可以直接在 DAO 中使用协程的 suspend 挂起函数

定义 Entity

我们定义一个 User 类,然后在类的声明上使用 @Entity 注解,将其声明为一个实体类。

kotlin 复制代码
@Entity
data class User(var firstName: String, var lastName: String, var age: Int) {
    @PrimaryKey(autoGenerate = true)
    var id: Long = 0
}

我们使用了 @PrimaryKey 注解,将 id 字段设为了主键,并且 autoGenerate = true 表示该主键值是自动生成的。

定义 Dao

定义一个 UserDao 接口,在其声明上使用 @Dao 注解,使其变为一个 Dao。

kotlin 复制代码
@Dao
interface UserDao {

    // 将参数传入的 User 对象插入到数据库中,并返回自动生成的主键 id 值
    @Insert
    suspend fun insertUser(user: User): Long

    // 将参数传入的 User 对象更新到数据库中
    @Update
    suspend fun updateUser(newUser: User)

    // 查询所有用户
    @Query("select * from User")
    suspend fun loadAllUsers(): List<User>

    // 查询所有年龄大于 age 的用户
    @Query("select * from User where age > :age")
    suspend fun loadUsersOlderThan(age: Int): List<User>

    // 将参数传入的 User 对象从数据库中删除
    @Delete
    suspend fun deleteUser(user: User)

    // 删除所有姓氏为 lastName 的用户,返回被删除的行数
    @Query("delete from User where lastName = :lastName")
    suspend fun deleteUserByLastName(lastName: String): Int
}

你会发现我们在所有的方法前都加上了 suspend 关键字。这样 Room 会自动处理线程切换,确保数据库操作不会在主线程中执行,而无需我们手动将协程切换到 IO 线程。

我们可以使用 @Insert@Delete@Update@Query 注解,来分别完成增删改查操作。其中增删改操作只需使用注解即可,无需编写 SQL 语句。而查询操作 ,或是需要根据特定条件进行增删改 ,就需要编写 SQL 语句,而且都要使用 @Query 注解

虽然编写 SQL 语句有些不友好,但它最为灵活。且 Room 支持编译时检查 SQL 语句语法,这非常强大,可以让很多错误在编译期就暴露出来。

定义 Database

这部分其实很固定,只需定义数据库的版本号、包含的实体类,提供 Dao 层的访问实例。并且为了确保在整个应用中只有一个 AppDatabase 实例,我们需要使用单例模式。

创建 AppDatabase 抽象类,继承自 RoomDatabase 类。在声明上使用 @Database 注解,然后在注解中声明数据库版本号以及包含的实体类。

kotlin 复制代码
// 数据库版本号,包含的实体类
@Database(version = 1, entities = [User::class])
abstract class AppDatabase : RoomDatabase() {

    // 创建抽象方法,获取 Dao 层的访问实例
    abstract fun userDao(): UserDao

    companion object {
        // 持有 Database 实例
        @Volatile
        private var instance: AppDatabase? = null
        
        // 获取 Database 实例
        fun getDatabase(context: Context): AppDatabase {
            // 双重检查锁定,确保线程安全且性能优越
            instance?.let {
                return it
            }

            synchronized(this) {
                instance?.let { return it }

                return Room.databaseBuilder(
                    context.applicationContext, // 不能直接使用 context,而是要使用 applicationContext 防止内存泄漏
                    AppDatabase::class.java,    // Database 的 Class 类型
                    "app_database"   // 数据库名称
                ).build().also {
                    instance = it
                }
            }
        }
        
    }

}

这里包含了一个最佳实践,我们使用了双重检查锁定 配合 @Volatile 注解来创建单例。

为什么不直接这样写:

kotlin 复制代码
// 获取 Database 实例
@Synchronized
fun getDatabase(context: Context): AppDatabase {
    if (instance == null) {
        instance = Room.databaseBuilder(
            context.applicationContext, // 不能直接使用 context,而是要使用 applicationContext 防止内存泄漏
            AppDatabase::class.java,    // Database 的 Class 类型
            "app_database"              // 数据库名称
        ).build()
    }
    return instance!!
}

因为这种写法,虽然简单、能保证线程安全,但效率不高。@Synchronized 会给整个方法上锁,导致每次调用该方法时,无论 AppDatabase 实例是否被创建,线程都需要排队等待。

而双重检查锁定模式只有在实例为空的时候,才会进入同步代码块(线程需要排队等待)。并且在其中进行第二次检查,防止有多个线程都进入了同步代码块导致重复创建实例。

kotlin 复制代码
fun getDatabase(context: Context): AppDatabase {
    // 第一次检查(无锁):如果实例已存在,直接返回
    instance?.let { return it }
    
    // 同步代码块
    synchronized(this) {
        // 第二次检查(有锁):防止多个线程同时通过第一次检查,然后重复创建实例。
        instance?.let { return it }
        
        // 创建实例
        return Room.databaseBuilder(
            context.applicationContext, 
            AppDatabase::class.java,   
            "app_database"   
        ).build().also {
            instance = it
        }
    }
}

我们还使用了 @Volatile 注解。因为创建 AppDatabase 实例会分为三步:

  1. AppDatabase 对象分配内存空间。

  2. 调用构造函数,初始化 AppDatabase 对象

  3. instance 变量指向该对象的内存地址

如果没有 @Volatile 注解,编译器和 CPU 为了提高效率,可能会调换第二步和第三步的执行顺序(指令重排)。

如果发生这么一种情况:线程 A 执行时,先执行第三步(instance 变量不为空了,对象还没完成初始化),而此时线程 B 在第一次检查时,发现 instance 变量不为空,就会直接返回这个未被初始化的对象。导致使用该变量时,应用会崩溃。

所以 @Volatile 注解的作用是禁止指令重排,让编译器和 CPU 严格按照代码的顺序执行,无需进行优化重排。同时,@Volatile 也保证了内存可见性。就是一个线程对 instance 变量的修改,能够立刻 被其他所有线程看到,防止其他线程获取到旧的、缓存中的 null 值。

两者结合(双重检查锁定和 @Volatile 注解),才彻底保证了线程安全。

测试

在布局中添加用于增删改查的按钮,activity_main.xml 中的代码如下:

xml 复制代码
<Button
    android:id="@+id/addDataBtn"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_gravity="center_horizontal"
    android:text="Add Data" />

<Button
    android:id="@+id/deleteDataBtn"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_gravity="center_horizontal"
    android:text="Delete Data" />

<Button
    android:id="@+id/updateDataBtn"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_gravity="center_horizontal"
    android:text="Update Data" />

<Button
    android:id="@+id/queryDataBtn"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_gravity="center_horizontal"
    android:text="Query Data" />

MainActivity 中实现按钮的点击逻辑:

kotlin 复制代码
// onCreate() 方法中
val userDao = AppDatabase.getDatabase(this).userDao()

val user1 = User("Tom", "Brady", 40)
val user2 = User("Tom", "Hanks", 63)

binding.addDataBtn.setOnClickListener { // 无需指定 Dispatchers.IO
    lifecycleScope.launch {
        user1.id = userDao.insertUser(user1)
        user2.id = userDao.insertUser(user2)
    }
}

binding.updateDataBtn.setOnClickListener {
    lifecycleScope.launch {
        user1.age = 42
        userDao.updateUser(user1)
    }
}

binding.deleteDataBtn.setOnClickListener {
    lifecycleScope.launch {
        userDao.deleteUserByLastName("Hanks")
    }
}

binding.queryDataBtn.setOnClickListener {
    lifecycleScope.launch {
        for (user in userDao.loadAllUsers()) {
            Log.d("MainActivity", user.toString())
        }
    }
}

注意:在方法前加上了 suspend 关键字,所以我们无需将协程切换到 IO 线程池中执行。

运行程序,点击 "Add Data" 按钮,再点击 "Query Data" 按钮,可以在日志中看到:

less 复制代码
D/MainActivity    com.example.jetpacktest    User(firstName=Tom, lastName=Brady, age=40)
D/MainActivity    com.example.jetpacktest    User(firstName=Tom, lastName=Hanks, age=63)

然后,点击 "Update Data" 按钮,再点击 "Query Data" 按钮,可以看到:

less 复制代码
D/MainActivity    com.example.jetpacktest    User(firstName=Tom, lastName=Brady, age=42)
D/MainActivity    com.example.jetpacktest    User(firstName=Tom, lastName=Hanks, age=63)

最后,点击 "Delete Data" 按钮,再点击 "Query Data" 按钮,可以看到:

less 复制代码
D/MainActivity    com.example.jetpacktest    User(firstName=Tom, lastName=Brady, age=42)

Room 的数据库升级

随着需求和版本的变更,数据库表结构也需要进行升级。

新增数据表

假如,我们需要新增一张 Book 表。我们首先需要创建 Book 实体类和 BookDao 接口

kotlin 复制代码
@Entity
data class Book(var name: String, var pages: Int) {
    @PrimaryKey(autoGenerate = true)
    var id: Long = 0
}
kotlin 复制代码
@Dao
interface BookDao {
    @Insert
    suspend fun insertBook(book: Book): Long

    @Query("select * from Book")
    suspend fun loadAllBooks(): List<Book>
}

然后,在 AppDatabase 中编写数据库升级的逻辑:

kotlin 复制代码
// 版本号变更为 2,新增 Book 实体类的声明
@Database(version = 2, entities = [User::class, Book::class])
abstract class AppDatabase : RoomDatabase() {

    abstract fun userDao(): UserDao

    // 新增获取 Dao 层访问实例的方法
    abstract fun bookDao(): BookDao

    companion object {
        // 数据库版本从 1 升级到 2 的迁移逻辑
        private val MIGRATION_1_2 = object : Migration(1, 2) {
            override fun migrate(db: SupportSQLiteDatabase) {
                // 执行 SQL
                db.execSQL("create table Book (id integer primary key autoincrement not null, name text not null, pages integer not null)")
            }
        }


        @Volatile
        private var instance: AppDatabase? = null

        fun getDatabase(context: Context): AppDatabase {
            instance?.let {
                return it
            }

            synchronized(this) {
                instance?.let { return it }

                return Room.databaseBuilder(
                    context.applicationContext, 
                    AppDatabase::class.java,    
                    "app_database"  
                )
                 // 新增这一行,将迁移逻辑添加到构建器中
                .addMigrations(MIGRATION_1_2)
                .build().also {
                    instance = it
                }
            }
           
        }
        
    }

}

在现有表中添加字段

有时,我们只需要修改表结构。比如往 Book 表中新增一个 author 字段。

步骤也是类似的,首先修改 Book 的实体类:

kotlin 复制代码
@Entity
data class Book(var name: String, var pages: Int, var author: String) {
    @PrimaryKey(autoGenerate = true)
    var id: Long = 0
}

修改 AppDatabase

kotlin 复制代码
// 版本号改为 3
@Database(version = 3, entities = [User::class, Book::class])
abstract class AppDatabase : RoomDatabase() {

    ...

    companion object {
        ...

        // 数据库版本从 2 升级到 3 的升级逻辑
        private val MIGRATION_2_3 = object : Migration(2, 3) {
            override fun migrate(db: SupportSQLiteDatabase) {
                db.execSQL("alter table Book add column author text not null default 'unknown' ")
            }
        }

        @Volatile
        private var instance: AppDatabase? = null

        fun getDatabase(context: Context): AppDatabase {
            instance?.let {
                return it
            }

            synchronized(this) {
                instance?.let { return it }

                return Room.databaseBuilder(
                    context.applicationContext, 
                    AppDatabase::class.java,    
                    "app_database"  
                )
                // 添加所有的迁移逻辑
                .addMigrations(MIGRATION_1_2, MIGRATION_2_3)
                .build().also {
                    instance = it
                }
            }
           
        }


    }

}

自动迁移 (Auto Migration)

对于简单的操作,我们可以使用官方在 Room 2.4.0 引入的自动迁移 功能。比如,我们现在要升级到数据库版本 4,给 User 表增加一个 nickname 字段。

我们只需修改 User 实体类:

kotlin 复制代码
@Entity
data class User(
    var firstName: String, 
    var lastName: String, 
    var age: Int,
    var nickname: String? = null // 新增字段
) {
    @PrimaryKey(autoGenerate = true)
    var id: Long = 0
}

然后在 @Database 注解中声明即可。

kotlin 复制代码
@Database(
    version = 4,
    entities = [User::class, Book::class],
    autoMigrations = [
        AutoMigration(from = 3, to = 4) // 声明从版本 3 到版本 4 的自动迁移
    ]
)
abstract class AppDatabase : RoomDatabase()

Room 会自动完成此次升级,非常方便。当然,对于复杂的操作(如重命名列、拆分表),我们还是要靠手动的 Migration

破坏性迁移

在项目的开发测试阶段,我们可能会频繁修改表结构,不想编写繁琐的数据库升级逻辑。这时,我们可以这样:

kotlin 复制代码
Room.databaseBuilder(...)
    .addMigrations(...)
    // 添加这行代码
    .fallbackToDestructiveMigration()
    .build()

当 Room 在升级数据库,找不到对应的迁移逻辑时,会直接删除、重建整个数据库中的所有表。这个操作会导致用户数据全部丢失,只可在开发调试时使用。


至此,你已经学会了 Room 的核心用法。

最后我们来简单介绍一下 MVVM 应用架构的核心思想。我们并不会在 ActivityViewModel 中直接与 Dao 层进行交互。而是会引入仓库层 (Repository),我们与它进行交互,并不关心数据来自网络还是数据库。

例如,创建一个 UserRepository 类,把所有和 User 数据相关的操作都封装在里面。

kotlin 复制代码
class UserRepository(private val userDao: UserDao) {
    suspend fun getAllUsers() = userDao.loadAllUsers()
    suspend fun insertUser(user: User) = userDao.insertUser(user)
    ...
}

将数据操作封装在仓库层,是 MVVM 架构中的最佳实践。

相关推荐
哲科软件6 小时前
跨平台开发的抉择:Flutter vs 原生安卓(Kotlin)的优劣对比与选型建议
android·flutter·kotlin
jyan_敬言12 小时前
【C++】string类(二)相关接口介绍及其使用
android·开发语言·c++·青少年编程·visual studio
程序员老刘12 小时前
Android 16开发者全解读
android·flutter·客户端
福柯柯13 小时前
Android ContentProvider的使用
android·contenprovider
不想迷路的小男孩13 小时前
Android Studio 中Palette跟Component Tree面板消失怎么恢复正常
android·ide·android studio
餐桌上的王子13 小时前
Android 构建可管理生命周期的应用(一)
android
菠萝加点糖13 小时前
Android Camera2 + OpenGL离屏渲染示例
android·opengl·camera
用户20187928316713 小时前
🌟 童话:四大Context徽章诞生记
android
yzpyzp13 小时前
Android studio在点击运行按钮时执行过程中输出的compileDebugKotlin 这个任务是由gradle执行的吗
android·gradle·android studio
aningxiaoxixi14 小时前
安卓之service
android