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 架构中的最佳实践。

相关推荐
帅次42 分钟前
Android 高级工程师面试参考答案:架构设计、Jetpack 与 Compose
android·面试·职场和发展·架构·composer·jetpack
limingade44 分钟前
Dialer3.0智能拨号器Android版功能说明书
android·蓝牙电话·手机转sip·手机蓝牙·智能拨号器
JJay.1 小时前
Android BLE 的 notify 和 indicate 到底有什么区别
android
橙子199110161 小时前
Android 异步任务和消息机制
android
被开发耽误的大厨1 小时前
5、Integer缓存池里同一个对象指的是什么?Integer 和String 内存结构逻辑完全一样?
android·java·哈希算法
NoSi EFUL9 小时前
MySQL中ON DUPLICATE KEY UPDATE的介绍与使用、批量更新、存在即更新不存在则插入
android·数据库·mysql
安小牛12 小时前
Android 开发汉字转带声调的拼音
android·java·学习·android studio
聚美智数12 小时前
企业实际控制人查询-公司实控人查询
android·java·javascript
JMchen12313 小时前
第 3 篇|Android 项目结构解析与第一个界面 —— Hello, CSDN!
android·android studio·android 零基础·android 项目结构·android 界面开发
众少成多积小致巨16 小时前
Soong构建入门
android·go·编译器