Jetpack Room

1,950 阅读8分钟

1 Room 简介

Room 持久性库是 Android Jetpack 的一部分,它在 SQLite 的基础上提供了一个抽象层,让用户能够在充分利用 SQLite 的强大功能的同时,获享更强健的数据库访问机制。

该库可帮助您在运行应用的设备上创建应用数据的缓存。此缓存充当应用的单一可信来源,使用户能够在应用中查看关键信息的一致副本,无论用户是否具有互联网连接。

2 引用

在主 module 的 build.gradle 文件添加依赖

dependencies {
  def room_version = "2.2.5"

  implementation "androidx.room:room-runtime:$room_version"
  kapt "androidx.room:room-compiler:$room_version"

  // optional - Kotlin Extensions and Coroutines support for Room
  implementation "androidx.room:room-ktx:$room_version"

  // optional - Test helpers
  testImplementation "androidx.room:room-testing:$room_version"
}

3 概念

Room 设计到的概念有以下几个:

  • Database:包含数据库持有者,并作为应用已保留的持久关系型数据的底层连接的主要接入点。

    • 使用 @Database 注释的类应满足以下条件:
    • 是扩展 RoomDatabase 的抽象类。
    • 在注释中添加与数据库关联的实体列表。
    • 包含具有 0 个参数且返回使用 @Dao 注释的类的抽象方法,通过该类方法获取具体的 Dao,并进行数据库操作。
    • 在运行时,您可以通过调用 Room.databaseBuilder() 或 Room.inMemoryDatabaseBuilder() 获取 Database 的实例。
  • Entity:表示数据库中的表,属性会与数据库表 column 进行映射。

  • DAO:数据库访问对象,实现具体的增删改查。

Room 不同组件之间的关系如下图所示:

Room 架构图
Room 架构图

了解上面的具体概念,我们就可以进行开发了,下面是具体细节处理。

4 简单使用

1.定义实体 Entity

@Entity
data class User(
    @PrimaryKey val uid: Int,
    @ColumnInfo(name = "first_name") val firstName: String?,
    @ColumnInfo(name = "last_name") val lastName: String?
)
  • 使用主键

    每个实体必须将至少 1 个字段定义为主键。即使只有 1 个字段,也仍然需要为该字段添加 @PrimaryKey 注释。

    如果实体具有复合主键,可以使用 @Entity 注释的 primaryKeys 属性,如以下代码段所示:

@Entity(primaryKeys = arrayOf("firstName", "lastName"))
data class User(
    var firstName: String?,
    var lastName: String?
)
  • 设置表名与列名

    利用 @Entity 注释的 tableName 属性设置表名

    利用字段的 @ColumnInfo 注释的 name 属性设置列名

@Entity(tableName = "users")
data class User (
    @PrimaryKey val id: Int,
    @ColumnInfo(name = "first_name") var firstName: String?,
    @ColumnInfo(name = "last_name") var lastName: String?
)
  • 忽略字段

    默认情况下,Room 会为实体中定义的每个字段创建一个列。如果某个实体中有您不想保留的字段,则可以使用 @Ignore 为这些字段添加注释,如以下代码段所示:
@Entity
data class User(
    @PrimaryKey val id: Int,
    var firstName: String?,
    var lastName: String?,
    @Ignore var picture: Bitmap?
)

如果实体继承了父实体的字段,则使用 @Entity 属性的 ignoredColumns 属性通常会更容易:

open class User {
    var picture: Bitmap? = null
}

@Entity(ignoredColumns = arrayOf("picture"))
data class RemoteUser(
    @PrimaryKey val id: Int,
    var hasVpn: Boolean
) : User()

2. 定义具体的 Dao

@Dao
interface UserDao {
    @Query("SELECT * FROM user")
    fun getAll(): List<User>

    @Query("SELECT * FROM user WHERE uid IN (:userIds)")
    fun loadAllByIds(userIds: IntArray): List<User>

    @Query("SELECT * FROM user WHERE first_name LIKE :first AND " +
           "last_name LIKE :last LIMIT 1")
    fun findByName(first: String, last: String): User

    @Insert
    fun insertAll(vararg users: User)

    @Delete
    fun delete(user: User)

    @Update
    fun updateUsers(vararg users: User)
}
  • Insert

    如果 @Insert 方法只接收 1 个参数,则它可以返回 long,这是插入项的新 rowId。如果参数是数组或集合,则应返回 long[] 或 List<Long>。

  • Update

    @Update 便捷方法会修改数据库中以参数形式给出的一组实体。它使用与每个实体的主键匹配的查询。

    通常没有必要,但是可以让此方法返回一个 int 值,以指示数据库中更新的行数。

  • Delete

    @Delete 便捷方法会从数据库中删除一组以参数形式给出的实体。它使用主键查找要删除的实体。

    与 @Update 相同,可以让此方法返回一个 int 值,以指示从数据库中删除的行数。

  • Query

    @Query 是 DAO 类中使用的主要注释。它允许对数据库执行读/写操作。每个 @Query 方法都会在编译时进行验证,因此如果查询出现问题,则会发生编译错误,而不是运行时失败。

3. 定义类继承自 RoomDataBase

@Database(entities = arrayOf(User::class), version = 1)
abstract class AppDatabase : RoomDatabase() {
    abstract fun userDao(): UserDao
}

4. 获取数据库实例,进行数据库操作

val db = Room.databaseBuilder(
            applicationContext,
            AppDatabase::class.java, "database-name"
        ).build()

5 与 Kotlin Coroutines 结合使用

Room 支持各种查询方法的返回类型,包括与特定框架或 API 进行互操作的特殊返回类型。下表根据查询类型和框架展示了适用的返回类型:

查询类型 协程 RxJava 生命周期
可观察读取 Flow<T> Flowable<T>、Publisher<T>、Observable<T> LiveData<T>
单次读取 suspend fun Single<T>、Maybe<T>
单次写入 suspend fun Single<T>、Maybe<T>、Completable<T>

1. 使用流进行响应式查询

在 Room 2.2 及更高版本中,可以使用 Kotlin 的 Flow 功能(需要添加 room-ktx 依赖)确保应用的界面保持最新状态。只要表中的任何数据发生变化,界面都会进行自动更新,写法如下:

@Query("SELECT * FROM User")
fun getAllUsers(): Flow<List<User>>

可以通过将 distinctUntilChanged() 运算符应用于返回的 Flow 对象,确保仅在实际查询结果发生更改时通知界面,用法如下:

@Dao
abstract class UsersDao {
  @Query("SELECT * FROM User WHERE username = :username")
  abstract fun getUser(username: String): Flow<User>

  fun getUserDistinctUntilChanged(username:String) =
    getUser(username).distinctUntilChanged()
}

2. 使用 Kotlin 协程进行异步查询

可以将 suspend Kotlin 关键字添加到 DAO 方法中,以使用 Kotlin 协程功能使这些方法成为异步方法。这样可确保不会在主线程上执行这些方法。

@Dao
interface MyDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertUsers(vararg users: User)

    @Update
    suspend fun updateUsers(vararg users: User)

    @Delete
    suspend fun deleteUsers(vararg users: User)

    @Query("SELECT * FROM user")
    suspend fun loadAllUsers(): Array<User>
}

suspend 关键字同时适用于带有 @Transaction 注释的 DAO 方法,这些方法会在单个数据库事务中运行。

@Dao
abstract class UsersDao {
    @Transaction
    open suspend fun setLoggedInUser(loggedInUser: User) {
        deleteUser(loggedInUser)
        insertUser(loggedInUser)
    }

    @Query("DELETE FROM users")
    abstract fun deleteUser(user: User)

    @Insert
    abstract suspend fun insertUser(user: User)
}

3. 使用 LiveData 进行可观察查询

执行查询时,可以通过使用 LiveData 类型的返回值,确保应用的界面在数据发生变化时自动更新,当数据库更新时,Room 会生成更新 LiveData 所必需的所有代码。

@Dao
interface MyDao {
    @Query("SELECT first_name, last_name FROM user WHERE region IN (:regions)")
    fun loadUsersFromRegionsSync(regions: List<String>): LiveData<List<User>>
}

4. 使用 RxJava 进行响应式查询

由于最近在转型使用 Kotlin 协程,所以未对RxJava进行研究,欲知如何使用,可自行参考 Android 开发者官网。

6 踩坑

1. @PrimaryKey@Ignore

当使用 Kotlin data class 声明数据库表,且在一个 Entity 实体中同时出现这两个注解时,会有产生一些奇怪的错误。

  • 建议使用 var 声明属性,虽然官方文档中的例子是使用 val 关键字对属性进行声明,但是实际 build 的时候会报以下错误:
  Cannot find setter for field.
  • 需要给 data class 中的全部属性设置默认值,否则 build 时会报以下错误:
  Entities and POJOs must have a usable public constructor.
  You can have an empty constructor or a constructor whose parameters match the fields (by name and type).
  • 踩过前面两个坑之后,如果 Entity 主键是自增的 IntLong 型时,此时,假设设置的主键默认值为 0,在 Insert 数据时,也未对主键进行赋值,那么数据的主键会一直是默认值 0,后续的产生的问题参照 onConflict 配置(数据库中只有一条数据、插入失败等)。

解决方案:

将 @Ignore 注解的属性,放入 data class 的类体中,这样即可避免问题 2 与问题 3

@Entity
data class User(
    @PrimaryKey(autoGenerate = true) var id: Int,

    var firstName: String = "",

    var lastName: String = "",

) {
    @Ignore
    var pictureUrl: String = ""

}

此外,可能大家都知道 Kotlin data classGson 配合使用时,如果 data class 中没有给所有的属性设定默认值时,会有产生 NPE 的情况。考虑到问题 3,当无法给自增主键设置默认值时,可以采用以下方案进行解决。

@Entity
data class User(
    var firstName: String = "",

    var lastName: String = "",
) {
    @PrimaryKey(autoGenerate = true) var id: Int? = null,

    @Ignore
    var pictureUrl: String = ""
}

2. 不能声明 isXxx 形式的字段名

POJO 类中布尔类型的变量,都不要加 is,否则部分框架解析会引起序列化错误。

反例:isSuccess:Boolean 属性,他的方法也是isSuccess(),RPC 框架在反向解析的时候,“以为”对应的属性是 success,导致属性获取不到,进而抛出异常。

3. suspend 关键字与 Flow 和 LiveData

需要注意的是,使用 suspend 关键字进行声明的 Dao 函数中,不可以使用 Flow 或者 LiveData 作为返回值,否则会报出编译时异常(异常信息如下),因此该类 Dao 函数,建议在 Coroutines 中的非主线程中调用。

Not sure how to convert a Cursor to this method's return type (kotlinx.coroutines.flow.Flow<T>).
Not sure how to convert a Cursor to this method's return type (androidx.lifecycle.LiveData<T>).

本文使用 mdnice 排版