前言
使用原生的 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
实例会分为三步:
-
为
AppDatabase
对象分配内存空间。 -
调用构造函数,初始化
AppDatabase
对象 -
让
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 应用架构的核心思想。我们并不会在 Activity
或 ViewModel
中直接与 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 架构中的最佳实践。