Jetpack架构组件库:Room

Posted 川峰

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Jetpack架构组件库:Room相关的知识,希望对你有一定的参考价值。

Room

Room是一款轻量级orm数据库,本质上是一个基于SQLite之上的抽象层。它通过注解的方式提供相关功能,编译时自动生成实现Impl,相比纯 SQLite 的API使用方式更加简单。另外一个相比于SQLite API的优势是:它会在编译时检查 SQL 语句的合法性,而不是等到运行时应用崩溃才发现。

Room 的使用

添加依赖项

dependencies 
    def room_version = "2.5.0"

    implementation "androidx.room:room-runtime:$room_version"
    implementation "androidx.room:room-ktx:$room_version"
    
    // annotationProcessor "androidx.room:room-compiler:$room_version"
    
    // To use Kotlin annotation processing tool (kapt)
    // kapt "androidx.room:room-compiler:$room_version"
    
    // To use Kotlin Symbol Processing (KSP)
    ksp "androidx.room:room-compiler:$room_version"

注解处理器这里建议使用 KSP,编译速度更快。

定义Entity实体

@Entity
data class User(@PrimaryKey val id: Int, val name: String, val age: Int) 

默认表名与类名相同,如需显示指定表名,使用 @Entity(tableName = "user_table")

@Entity(tableName = "user_table")
data class User(@PrimaryKey val id: Int, val name: String, val age: Int) 

如需显示指定表中的列名,使用 @ColumnInfo(name = "xxx")

@Entity
data class User(
    @PrimaryKey 
    val id: Int,
    @ColumnInfo(name = "userName") 
    val name: String,
    val age: Int,
) 
定义主键

每个 Room 实体都必须定义一个主键,用于唯一标识相应数据库表中的每一行。执行此操作的最直接方式是使用 @PrimaryKey 为单个列添加注解:

@Entity
data class User(
    @PrimaryKey // 主键
    val id: Int, 
    val name: String,
    val age: Int
) 

如需设置主键自动生成,使用 @PrimaryKey(autoGenerate = true)

@Entity
data class User(
    @PrimaryKey(autoGenerate = true) // 设置主键自动生成
    val id: Int, 
    val name: String,
    val age: Int,
)

如需定义复合主键进行唯一标识,使用 @Entity(primaryKeys = ["name1", "name2"])

@Entity(primaryKeys = ["firstName", "lastName"])
data class User(
    val firstName: String?,
    val lastName: String?
)
忽略字段

默认情况下,Room 会为实体中定义的每个字段创建一个列。 如果某个实体中有不想保留的字段,则可以使用 @Ignore 为这些字段添加注解:

@Entity
data class User(
    @PrimaryKey val id: Int,
    val firstName: String?,
    val lastName: String?,
    @Ignore val picture: Bitmap?
)

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

open class User 
    var picture: Bitmap? = null


@Entity(ignoredColumns = ["picture"])
data class RemoteUser(
    @PrimaryKey val id: Int,
    val hasVpn: Boolean
) : User()
创建嵌套对象

例如,User 类可以包含一个 Address 类型的字段,它表示名为 street、city、state 和 postCode 的字段的组合。若要在表中单独存储组合列,请在 User 类中添加 Address 字段,并添加 @Embedded 注解,如以下代码段所示:

data class Address(
    val street: String?,
    val state: String?,
    val city: String?,
    @ColumnInfo(name = "post_code") val postCode: Int
)

@Entity
data class User(
    @PrimaryKey val id: Int,
    val firstName: String?,
    @Embedded val address: Address?
)

然后,表示 User 对象的表将包含具有以下名称的列:id、firstName、street、state、city 和 post_code。

注意:嵌套字段还可以包含其他嵌套字段。如果某个实体具有相同类型的多个嵌套字段,可以通过设置 prefix 属性确保每个列的唯一性。然后,Room 会将提供的值添加到嵌套对象中每个列名称的开头。

支持全文搜索

如果应用需要通过全文搜索 (FTS) 快速访问数据库信息,请使用虚拟表(使用 FTS3 或 FTS4 SQLite 扩展模块)为实体提供支持。如需使用 Room 2.1.0 及更高版本中提供的这项功能,请将 @Fts3@Fts4 注解添加到给定实体,如下代码所示:

// 只有当你的应用程序对磁盘空间有严格的要求,或者你需要与旧SQLite版本兼容时才使用“@Fts3”
@Fts4
@Entity(tableName = "users")
data class User(
    /* 为FTS表支持的实体指定主键是可选的,但是如果指定了,则必须使用Int类型和rowid列名 */
    @PrimaryKey @ColumnInfo(name = "rowid") val id: Int,
    @ColumnInfo(name = "first_name") val firstName: String?
)

注意:启用 FTS 的表始终使用 INTEGER 类型的主键且列名称为“rowid”。如果是由 FTS 表支持的实体定义主键,则必须使用相应的类型和列名称。

如果表支持以多种语言显示的内容,请使用 languageId 选项指定用于存储每一行语言信息的列:

@Fts4(languageId = "lid")
@Entity(tableName = "users")
data class User(
    // ...
    @ColumnInfo(name = "lid") val languageId: Int
)
为特定列添加索引

如果您的应用必须支持不允许使用由 FTS3 或 FTS4 表支持的实体的 SDK 版本,您仍可以将数据库中的某些列编入索引,以加快查询速度。如需为实体添加索引,请在 @Entity 注解中添加 indices 属性,列出要在索引或复合索引中包含的列的名称。

@Entity(indices = [Index(value = ["last_name", "address"])])
data class User(
    @PrimaryKey val id: Int,
    val firstName: String?,
    val address: String?,
    @ColumnInfo(name = "last_name") val lastName: String?,
    @Ignore val picture: Bitmap?
)
添加基于 AutoValue 的对象

Room 2.1.0 及更高版本中,您可以将基于 Java不可变值类(使用 @AutoValue 进行注解)用作应用数据库中的实体。此支持在实体的两个实例被视为相等(如果这两个实例的列包含相同的值)时尤为有用。

将带有 @AutoValue 注解的类用作实体时,您可以使用 @PrimaryKey、@ColumnInfo、@Embedded@Relation 为该类的抽象方法添加注解。但是,您必须在每次使用这些注解时添加 @CopyAnnotations 注解,以便 Room 可以正确解释这些方法的自动生成实现。

以下代码段展示了一个使用 @AutoValue 进行注解的类(Room 将其标识为实体)的示例:

// User.java
@AutoValue
@Entity
public abstract class User 
    // Supported annotations must include `@CopyAnnotations`.
    @CopyAnnotations
    @PrimaryKey
    public abstract long getId();

    public abstract String getFirstName();
    public abstract String getLastName();

    // Room uses this factory method to create User objects.
    public static User create(long id, String firstName, String lastName) 
        return new AutoValue_User(id, firstName, lastName);
    

注意:此功能旨在用于基于 Java 的实体。如需在基于 Kotlin 的实体中实现相同的功能,最好改用数据类

创建 DAO

什么是 DAO?

在 DAO(Database Access Object 数据访问对象)中,您可以指定 SQL 查询并将其与方法调用相关联。编译器会检查 SQL 并根据常见查询的方便的注解(如 @Insert)生成查询。Room 会使用 DAO 为代码创建整洁的 API。

  • DAO 必须是一个接口或抽象类
  • 默认情况下,所有查询都必须在单独的线程上执行。
  • Room 支持 Kotlin 协程,您可使用 suspend 修饰符对查询进行注解,然后从协程或其他挂起函数对其进行调用。
@Dao 
interface UserDao 

    @Insert 
    suspend fun insert(vararg user: User) // 注意是挂起函数

    @Update
    suspend fun update(vararg user: User)

    @Delete
    suspend fun delete(vararg user: User)  

    @Query("DELETE FROM user") // 表名会自动转大写
    suspend fun deleteAll()  

    @Query("SELECT * FROM user")
    fun getAllUser(): List<User>

插入

@Insert 方法的每个参数必须是带有 @Entity 注解的 Room 数据实体类的实例或数据实体类实例的集合。调用 @Insert 方法时,Room 会将每个传递的实体实例插入到相应的数据库表中。

@Dao  
interface UserDao 
    @Insert 
    suspend fun insert(vararg user: User) 
    
    @Insert
    fun insertBothUsers(user1: User, user2: User)
    
    @Insert
    fun insertUsersAndFriends(user: User, friends: List<User>)

如果 @Insert 方法接收单个参数,则会返回 long 值,这是插入项的新 rowId。如果参数是数组或集合,则该方法应改为返回由 long 值组成的数组或集合,并且每个值都作为其中一个插入项的 rowId

更新

@Insert 方法类似,@Update 方法接受数据实体实例作为参数。

@Dao
interface UserDao 
    @Update
    fun updateUsers(vararg users: User)

Room 使用主键将传递的实体实例与数据库中的行进行匹配。如果没有具有相同主键的行,Room 不会进行任何更改。

@Update 方法可以选择性地返回 int 值,该值指示成功更新的行数。

删除

@Insert 方法类似,@Delete 方法接受数据实体实例作为参数。

@Dao
interface UserDao 
    @Delete
    fun deleteUsers(vararg users: User)

Room 使用主键将传递的实体实例与数据库中的行进行匹配。如果没有具有相同主键的行,Room 不会进行任何更改。

@Delete 方法可以选择性地返回 int 值,该值指示成功删除的行数。

查询方法

使用 @Query 注解,您可以编写 SQL 语句并将其作为 DAO 方法公开。使用这些查询方法从应用的数据库查询数据,或者需要执行更复杂的插入、更新和删除操作。

Room 会在编译时验证 SQL 查询。这意味着,如果查询出现问题,则会出现编译错误,而不是运行时失败。

简单查询

以下代码定义了一个方法,该方法使用简单的 SELECT 查询返回数据库中的所有 User 对象:

@Query("SELECT * FROM user")
fun loadAllUsers(): Array<User>
查询指定的列

在大多数情况下,您只需要返回要查询的表中的列的子集。为节省资源并简化查询的执行,您应只查询所需的字段。

借助 Room,您可以从任何查询返回简单对象,前提是您可以将一组结果列映射到返回的对象。例如,您可以定义以下对象来保存用户的名字和姓氏:

data class NameTuple(
    @ColumnInfo(name = "first_name") val firstName: String?,
    @ColumnInfo(name = "last_name") val lastName: String?
)

然后,您可以从查询方法返回该简单对象:

@Query("SELECT first_name, last_name FROM user")
fun loadFullName(): List<NameTuple>

Room 知道该查询会返回 first_namelast_name 列的值,并且这些值会映射到 NameTuple 类的字段中。如果查询返回的列未映射到返回的对象中的字段,则 Room 会显示一条警告。

指定查询参数

大多数情况下,您的 DAO 方法需要接受参数,以便它们可以执行过滤操作。Room 支持在查询中将方法参数用作绑定参数。

例如,以下代码定义了一个返回特定年龄以上的所有用户的方法:

@Query("SELECT * FROM user WHERE age > :minAge")
fun loadAllUsersOlderThan(minAge: Int): Array<User>

您还可以在查询中传递多个参数或多次引用同一参数,如以下代码所示:

@Query("SELECT * FROM user WHERE age BETWEEN :minAge AND :maxAge")
fun loadAllUsersBetweenAges(minAge: Int, maxAge: Int): Array<User>

@Query("SELECT * FROM user WHERE first_name LIKE :search OR last_name LIKE :search")
fun findUserWithName(search: String): List<User>
为查询指定一组参数

某些 DAO 方法可能要求您传入数量不定的参数,参数的数量要到运行时才知道。Room 知道参数何时表示集合,并根据提供的参数数量在运行时自动将其展开。

例如,以下代码定义了一个方法,该方法返回了部分地区的所有用户的相关信息:

@Query("SELECT * FROM user WHERE region IN (:regions)")
fun loadUsersFromRegions(regions: List<String>): List<User>
联表查询

部分查询有可能需要访问多个表格才能计算出结果。可以在 SQL 查询中使用 JOIN 子句来引用多个表。

以下代码定义了一种方法将三个表联接在一起,以便将当前已出借的图书返回给特定用户:

@Query(
    "SELECT * FROM book " +
    "INNER JOIN loan ON loan.book_id = book.id " +
    "INNER JOIN user ON user.id = loan.user_id " +
    "WHERE user.name LIKE :userName"
)
fun findBooksBorrowedByNameSync(userName: String): List<Book>

此外,还可以定义简单对象以从多个联接表返回列的子集,如前面 [查询指定的列] 中所述。以下代码定义了一个 DAO,其中包含一个返回用户姓名和借阅图书名称的方法:

interface UserBookDao 
    @Query(
        "SELECT user.name AS userName, book.name AS bookName " +
        "FROM user, book " +
        "WHERE user.id = book.user_id"
    )
    fun loadUserAndBookNames(): LiveData<List<UserBook>>

    // 也可以将该类定义在独立文件中
    data class UserBook(val userName: String?, val bookName: String?)

查询返回Map映射

Room 2.4 及更高版本中,您还可以通过编写返回多重映射的查询方法来查询多个表中的列,而无需定义其他数据类。

以下代码直接从查询方法返回 User 和 Book 的 Map 映射,而不是返回保存有 User 和 Book 实例配对的自定义数据类的实例列表

@Query(
    "SELECT * FROM user" +
    "JOIN book ON user.id = book.user_id"
)
fun loadUserAndBookNames(): Map<User, List<Book>>

查询方法返回 Map 映射时,可以编写使用 GROUP BY 子句的查询,以便利用 SQL 的功能进行高级计算和过滤。例如以下代码仅返回已借阅图书数量超过三本的用户:

@Query(
    "SELECT * FROM user" +
    "JOIN book ON user.id = book.user_id" +
    "GROUP BY user.name WHERE COUNT(book.id) >= 3"
)
fun loadUserAndBookNames(): Map<User, List<Book>>

如果你不需要映射整个对象,还可以通过在查询方法的 @MapInfo 注解中设置 keyColumnvalueColumn 属性,返回查询中特定列之间的映射:

@MapInfo(keyColumn = "userName", valueColumn = "bookName")
@Query(
    "SELECT user.name AS username, book.name AS bookname FROM user" +
    "JOIN book ON user.id = book.user_id"
)
fun loadUserAndBookNames(): Map<String, List<String>>
查询返回Cursor对象

如果应用的逻辑要求直接访问返回行,您可以编写 DAO 方法以返回 Cursor 对象,如以下示例所示:

@Dao
interface UserDao 
    @Query("SELECT * FROM user WHERE age > :minAge LIMIT 5")
    fun loadRawUsersOlderThan(minAge: Int): Cursor

注意:强烈建议不要使用 Cursor API,因为它无法保证行是否存在或行包含哪些值。只有当您已具有需要光标且无法轻松重构的代码时,才使用此功能。

冲突策略

@Insert@Update 注解可以通过 onConflict 参数指定一个冲突策略:

@Dao  
interface UserDao 

    @Insert(onConflict = OnConflictStrategy.REPLACE)  // 冲突策略:替换旧数据
    suspend fun insert(vararg user: User) 

OnConflictStrategy中定义的冲突策略有:

  • REPLACE: 替换旧数据并继续事务。对于 Insert 方法永远都不会返回-1,因为即使存在冲突,该策略也将始终插入一行。
  • ABORT: 终止事务,事务会被回滚。
  • IGNORE: Insert 方法成功时会返回已插入的行id,但是如果存在冲突,该策略将忽略该行,并对未插入成功的行返回-1
  • NONE: 使用它可以防止Room生成ON冲突子句。当需要在触发器中使用ON冲突子句时,它可能很有用。运行时行为与应用ABORT策略时相同。事务会被回滚。

注意:@Insert@Update 注解类中 onConflict 的默认值都是 ABORT

观察数据库的变化

当数据发生变化时,您通常需要执行某些操作,例如在界面中显示更新后的数据。这意味着您必须观察数据,以便在数据发生变化后作出回应。

为了观察数据变化情况,推荐使用 kotlin 协程中的 Flow。只需将查询方法的返回值类型改成使用 Flow 类型;当数据库更新时,Room 会自动生成更新 Flow 所需的所有代码。例如:

    @Query("SELECT * FROM user ORDER BY name ASC")
    fun getAllUser(): Flow<List<User>>

创建 Room 数据库

要创建一个 Room 数据库类需要继承 RoomDatabase 类实现一个抽象类,并使用@Database注解标注该类:

@Database(entities = [User::class], version = 1, exportSchema = false)
abstract class AppDataBase: RoomDatabase() 

    abstract fun getUserDao(): UserDao

    // 创建一个单例对象,避免同时打开多个数据库实例
    companion object 
        @Volatile
        private var INSTANCE: AppDataBase? = null

        fun getDatabase(context: Context): AppDataBase 
            return INSTANCE ?: synchronized(this) 
                    val dataBase = Room.databaseBuilder(context.applicationContext, 
                    	AppDataBase::class.java, "app_database").build()
                    INSTANCE = dataBase
                    dataBase
            
        
    

Room 是 SQLite 数据库之上的一个数据库层。它负责处理平常使用 SQLiteOpenHelper 所处理的单调乏味的任务。通常,整个应用只需要一个 Room 数据库实例。Room 实例管理多个 Dao 对象,具体查询请求是通过 Dao 对象完成的。

为避免界面性能不佳,默认情况下,Room 不允许在主线程上发出查询请求。当 Room 查询返回 Flow 时,这些查询会在后台线程上自动异步运行。

创建 Room 数据库类的注意事项:

  • Room 数据库类必须是 abstract 且继承 RoomDatabase
  • 通过 @Database 将该类注解为 Room 数据库,并使用注解参数声明数据库中的实体以及设置版本号。每个实体都对应一个将在数据库中创建的表。
  • 数据库会通过每个 @Dao 的抽象“getter”方法公开 DAO。
  • 定义一个单例 AppDataBase,,以防出现同时打开数据库的多个实例的情况。
  • getDatabase 会返回该单例。首次使用时,它会创建数据库,具体方法是:使用 Room 的数据库构建器在 AppDataBase 类的应用上下文中创建 RoomDatabase 对象,并指定数据库的名称为 “app_database”。

创建数据仓库(Repository)

什么是数据仓库?

Repository 类会将多个数据源的访问权限抽象化。数据仓库并非架构组件库的一部分,但它是将代码和架构分离一种的最佳做法。Repository 类会提供一个整洁的 API,用于获取对应用其余部分的数据访问权限。

为什么使用数据仓库?

数据仓库可管理查询,且允许您使用多个后端。在最常见的示例中,存储库可实现对以下任务做出决定时所需的逻辑:是否从网络中提取数据;是否使用缓存在本地数据库中的结果。

实现Repository仓库

// 将DAO声明为构造函数中的私有属性。传入 DAO 而不是整个数据库对象,因为你只需要访问DAO。
class UserRepository(private val userDao: UserDao) 

    // Room 在单独的线程上执行所有查询。当数据发生变化时,作为被观察对象的 Flow 会通知观察者。
    val allUser: Flow<List<User>> = userDao.getAllUser()

    // 默认情况下,Room 会在非主线程执行挂起函数进行查询,
    // 因此,我们不需要实现其他任何东西来确保避免在主线程中执行过长时间的数据操作。
    @WorkerThread
    suspend fun insert(vararg user: User) 
        userDao.insert(*user)
    

    @WorkerThread
    suspend fun update(vararg user: User) 
        userDao.update(*user)
    

    @WorkerThread
    suspend fun delete(vararg user: User) 
        userDao.delete(*user)
    

    @WorkerThread
    suspend fun deleteAll() 
        userDao.deleteAll()
    

注意事项:

  • Repository只需持有 DAO 对象,而非整个数据库实例对象。因为 DAO 包含了数据库的所有读取/写入方法,因此它只需要访问 DAO。
  • 对于 allUser 返回的是Flow对象,这是因为 userDao.getAllUser() 返回的就是一个Flow。Room 将在单独的线程上执行所有查询。
  • suspend 修饰的操作方法意味着需要从协程或其他挂起函数进行调用。默认情况下,Room 会在非主线程执行挂起函数进行查询,
  • Room 在主线程之外执行挂起查询。

Repository的用途是在不同的数据源之间进行协调。在这个简单示例中,数据源只有一个,因此该数据仓库并未执行多少操作。

将数据仓库和数据库实例化

我们希望应用中的数据库和存储库只有一个实例。实现该目的的一种简单的方法是,将它们作为 Application 类的成员进行创建。然后,在需要时只需从应用检索,而不是每次都进行构建。

class MyApp: Application() 
    // 通过 lazy,数据库和存储库只在需要时创建,而不是在应用程序启动时创建
    private val database by lazy  AppDataBase.getDatabase(this) 
    val repository by lazy  UserRepository(database.getUserDao()) 

    override fun JetPack架构---ORM数据库Room的使用

Android Jetpack: Room | 中文教学视频

Android JetPack组件之Room数据库的集成与详解

Android Jetpack简介

有了Jetpack,为何还感觉框架还是被放养?

Jetpack — AAC架构组件之Lifecycle