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,用于获取对应用其余部分的数据访问权限。

Jetpack架构组件库:Room_第1张图片

为什么使用数据仓库?

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

实现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 onCreate() {
        super.onCreate()
    }
}

注意,AppDataBase在设计时本身就是单例模式,所以即便不在Application中创建,也只会有一个全局实例。

创建ViewModel来向界面提供数据

为什么使用 ViewModel?

Jetpack架构组件库:Room_第2张图片

ViewModel 的作用是向界面提供数据,它以一种可以感知生命周期的方式保存应用的界面数据,不受配置变化的影响。它会将应用的界面数据与 Activity 和 Fragment 类区分开,让您更好地遵循OO设计原则中的单一职责:activity 和 fragment 负责将数据绘制到屏幕上,ViewModel 则负责保存并处理界面所需的所有数据。

ViewModel 中获取数据的具体方式是通过持有的 Repository 对象来进行操作的。因此从这个角度来看,ViewModel 实际上充当了数据仓库和界面之间的通信中心。

ViewModel 结合 LiveData 在传统 View 中使用

在传统 View 体系中,只有 LiveData 是具有生命周期感知能力的数据结构类型,它是一种可观察的数据存储器,每当数据发生变化时,观察者接口都会收到通知。LiveData 遵循其他应用组件(如 activity 或 fragment)的生命周期。LiveData 会根据负责监听变化的组件的生命周期自动停止或恢复观察。因此,LiveData 适用于界面使用或显示的可变数据。

因此,需要将 ViewModel 中从UserRepository查询出的数据从 Flow 转换为 LiveData, 这样可以确保每次数据库中的数据发生变化时,界面都会自动更新。

class UserViewModel(private val repository: UserRepository): ViewModel() {

    val allUser: LiveData<List<User>> = repository.allUser.asLiveData()

    fun insert(vararg user: User) = viewModelScope.launch {
        repository.insert(*user)
    }

    fun update(vararg user: User) = viewModelScope.launch {
        repository.update(*user)
    }

    fun delete(vararg user: User) = viewModelScope.launch {
        repository.delete(*user)
    }

    fun deleteAll() = viewModelScope.launch {
        repository.deleteAll()
    }
}

class UserViewModelFactory(private val repository: UserRepository) : ViewModelProvider.Factory {
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        if (modelClass.isAssignableFrom(UserViewModel::class.java)) {
            @Suppress("UNCHECKED_CAST")
            return UserViewModel(repository) as T
        }
        throw IllegalArgumentException("Unknown ViewModel class")
    }
}

ViewModel 中除了查询以外的其他操作方法,都是通过 UserRepository 间接的调用 UserDao 中的对应方法,因为 UserDao 中的操作方法是挂起函数,因此在 ViewModel 中是放在 viewModelScope.launch{...} 协程作用域中执行的。

警告:请勿保留对生命周期短于 ViewModel 的 Context 的引用!例如:activity、fragment、view
保留引用可能会导致内存泄漏,例如 ViewModel 对已销毁的 activity 的引用。操作系统可能会在配置发生变化时销毁所有对象并重新创建,且这种情况会在 ViewModel 的生命周期内出现多次。

接下来就可以在Activity中使用该 ViewModel 来进行数据库操作了,例如:

class UserListActivity: ComponentActivity() {

    private val userViewModel: UserViewModel by viewModels {
        UserViewModelFactory((application as MyApp).repository)
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.room_test)
        
        val recyclerView = findViewById<RecyclerView>(R.id.recyclerview)
        val adapter = UserListAdapter(emptyList())
        recyclerView.adapter = adapter
        recyclerView.layoutManager = LinearLayoutManager(this)

        userViewModel.allUser.observe(this) { userList ->
            println("aaaaaaaaaaa ${userList.size}")
            adapter.update(userList)
        }
        findViewById<Button>(R.id.btn_add).setOnClickListener {
            val user1 = User(111, "张三", 22)
            val user2 = User(222, "李四", 33)
            val user3 = User(333, "Jetpack", 45)
            val user4 = User(234, "Room", 63)
            userViewModel.insert(user1, user2, user3, user4)
        }
        findViewById<Button>(R.id.btn_update).setOnClickListener {
            val user = User(222, "小明", 28)
            userViewModel.update(user)
        }
        findViewById<Button>(R.id.btn_delete).setOnClickListener {
            val user = User(111, "", 0)
            userViewModel.delete(user)
        }
        findViewById<Button>(R.id.btn_delete_all).setOnClickListener {
            userViewModel.deleteAll()
        }
    }
}

Jetpack架构组件库:Room_第3张图片

ViewModel 结合 StateFlow 在 Compose 中使用

要在 Compose 中使用首先需要将 Flow 转成 StateFlow ,这可以通过 Flow.stateIn() 方法来实现:

class UserViewModel2(private val repository: UserRepository): ViewModel() {

    val allUser: StateFlow<List<User>> = repository.allUser.stateIn(
        initialValue = emptyList(),
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5000)
    )

    fun insert(vararg user: User) = viewModelScope.launch {
        repository.insert(*user)
    }

    fun update(vararg user: User) = viewModelScope.launch {
        repository.update(*user)
    }

    fun delete(vararg user: User) = viewModelScope.launch {
        repository.delete(*user)
    }

    fun deleteAll() = viewModelScope.launch {
        repository.deleteAll()
    }
}

class UserViewModelFactory2(private val repository: UserRepository) : ViewModelProvider.Factory {
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        if (modelClass.isAssignableFrom(UserViewModel2::class.java)) {
            @Suppress("UNCHECKED_CAST")
            return UserViewModel2(repository) as T
        }
        throw IllegalArgumentException("Unknown ViewModel class")
    }
}

然后在 Composable 中借助StateFlow的扩展函数 collectAsStateWithLifecycle() (需要单独添加lifecycle-runtime-compose依赖),可以将一个StateFlow转化为 Composable 组件可以观察的 State 状态类型:

class UserListActivity2: ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MyComposeApplicationTheme {
                Surface(Modifier.fillMaxSize()) {
                    ContentView()
                }
            }
        }
    }

    @OptIn(ExperimentalLifecycleComposeApi::class)
    @Composable
    fun ContentView() {
        val userViewModel = viewModel<UserViewModel2>(
            factory = UserViewModelFactory2((application as MyApp).repository)
        )
        Column(horizontalAlignment = Alignment.CenterHorizontally) {
            val userList by userViewModel.allUser.collectAsStateWithLifecycle()
            UserList(Modifier.weight(1f), userList)
            Row {
               Button(onClick = {
                   val user1 = User(111, "张三", 22)
                   val user2 = User(222, "李四", 33)
                   val user3 = User(333, "Jetpack", 45)
                   val user4 = User(444, "Room", 63)
                   userViewModel.insert(user1, user2, user3, user4)
               }) {
                   Text(text = "新增")
               }
               Button(onClick = { userViewModel.update(User(444, "Compose", 88)) }) {
                   Text(text = "更新")
               }
                Button(onClick = { userViewModel.delete(User(333, "", 0)) }) {
                    Text(text = "删除")
                }
                Button(onClick = { userViewModel.deleteAll() }) {
                    Text(text = "删除所有")
                }
            }
        }
    }

    @Composable
    fun UserList(modifier: Modifier = Modifier, userList: List<User>) {
        LazyColumn(
            modifier.fillMaxWidth().background(Color.Gray),
            contentPadding = PaddingValues(15.dp),
            verticalArrangement = Arrangement.spacedBy(8.dp)
        ) {
            items(userList,  key={ it.id }) { user ->
                CardContent(user.toString())
            }
        }
    }
}

Jetpack架构组件库:Room_第4张图片

使用总结

Jetpack架构组件库:Room_第5张图片

Room数据库升级

如果应用是从之前的纯 SQLite API 使用方式更改为使用 Room 组件库的方式,则必须为RoomDatabase提供一个比之前更高的版本号(比如之前是1,则现在至少是2),否则 Room 数据库会在运行时崩溃。

在 Room 版本号发生变化时,必须为 Room 提供升级/迁移策略,否则 Room 数据库也会在运行时崩溃。

破坏性迁移

一种简单暴力的做法是,升级时丢弃/清除以前的所有旧数据,可以调用Room数据库构建器的 fallbackToDestructiveMigration() 方法来实现:

val dataBase = Room.databaseBuilder(context.applicationContext,
		 AppDataBase::class.java, "app_database"
     )
     .fallbackToDestructiveMigration() // 破坏性迁移:清除以前旧数据
     .build()

如果需要这么做,请确保应用的数据不是非常重要的用户数据,而只是用于缓存类型的数据,否则这将会是一场灾难。

提供空的迁移策略

如果从SQLite 转成 Room 时,想保留之前的数据,而不是清除数据,可以提供一个空的迁移策略:

 // 空的迁移策略
val migration_1_2 = Migration(1, 2) {
    // 因为我们没有改变表,所以这里没有别的事可做。
}

val dataBase = Room.databaseBuilder(context.applicationContext,
		 AppDataBase::class.java, "app_database"
     )
     .addMigrations(migration_1_2) // 空的迁移策略:保留旧数据
     .build()

Room 构建器的 addMigrations() 可以添加多个Migration迁移策略对象,每个Migration对象中可以指定从哪个版本升级到哪个版本,并指定具体的迁移操作,这里是空操作。

提供具体的迁移策略

比如为User实体增加一个字段 email

@Entity
data class User(
    @PrimaryKey(autoGenerate = true)  
    val id: Int, 
    val name: String,
    val age: Int,
    val email: String? = null // 新增的字段
)

这时,RoomDatabase可以这样修改:

@Database(entities = [User::class], version = 2, 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"
                        ) 
                        .addMigrations(migration_1_2) // 指定具体迁移策略
                        .build()
                    INSTANCE = dataBase
                    dataBase
            }
        } 
		// 从版本1升级到版本2的具体操作
        val migration_1_2 = Migration(1, 2) { database ->
            database.execSQL("ALTER TABLE user ADD COLUMN email TEXT")
        }
    }
}

使用自动迁移策略

如果只是简单的增加列字段,可以通过 autoMigrations 来完成,例如再为User新增一个字段 created

@Entity
data class User(
    @PrimaryKey(autoGenerate = true)  
    val id: Int, 
    val name: String,
    val age: Int,
    val email: String? = null 
    @ColumnInfo(name = "createdTime", defaultValue = "0")
    val created : Long = System.currentTimeMillis()
)

此时,RoomDatabase 可以这样做:

@Database(
    entities = [User::class],
    version = 3,
    exportSchema = true,
    autoMigrations = [
        AutoMigration(from = 2, to = 3)
    ]
)
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
            }
        }   
    }
}

注意,要使用 autoMigrations ,这里 @Database 注解中的 exportSchema 必须设置为true,并且在gradle中为注解处理器设置schema的输出位置:

ksp {
    arg("room.schemaLocation", "$projectDir/schemas") // 设置Room数据库的schema导出位置
}

指定目录下会生成对应每次Room版本升级时的json配置文件,里面详细描述了每次版本中提供了哪些字段已经执行的具体SQL。

此外,还可以通过 AutoMigrationspec 参数指定一个实现AutoMigrationSpec接口的带有注解的类,比如要将user表的 createdTime 字段名改为 createdAt,可以这么做:

@RenameColumn(tableName = "user", fromColumnName = "createdTime", toColumnName = "createdAt")
class Migration3To4 : AutoMigrationSpec

@Database(
    entities = [User::class],
    version = 4,
    exportSchema = true,
    autoMigrations = [
        AutoMigration(from = 2, to = 3),
        AutoMigration(from = 3, to = 4, spec = Migration3To4::class),
    ]
)
abstract class AppDataBase: RoomDatabase() {
	......
}

除了@RenameColumn,同样支持的功能还有以下注解:

@DeleteColumn(tableName = "user", columnName = "createdTime") 
class Migration3To4 : AutoMigrationSpec
@RenameTable(fromTableName = "user", toTableName = "user_table")
class Migration3To4 : AutoMigrationSpec
@DeleteTable(tableName = "user") 
class Migration3To4 : AutoMigrationSpec

更加复杂的迁移

例如,如果决定将表中的数据拆分为两个表,则 Room 无法确定应如何执行此拆分。在这类情况下,必须通过实现 Migration 类来手动定义迁移路径。

每个 Migration 类都会通过替换 Migration.migrate() 方法明确定义 startVersionendVersion 之间的迁移路径。使用 addMigrations() 方法将定义的 Migration 类添加到数据库构建器:

val MIGRATION_1_2 = object : Migration(1, 2) {
  override fun migrate(database: SupportSQLiteDatabase) {
    database.execSQL("CREATE TABLE `Fruit` (`id` INTEGER, `name` TEXT, " +
      "PRIMARY KEY(`id`))")
  }
}

val MIGRATION_2_3 = object : Migration(2, 3) {
  override fun migrate(database: SupportSQLiteDatabase) {
    database.execSQL("ALTER TABLE Book ADD COLUMN pub_year INTEGER")
  }
}

Room.databaseBuilder(applicationContext, MyDb::class.java, "database-name")
  .addMigrations(MIGRATION_1_2, MIGRATION_2_3).build()

再比如,如果要将 user 表的 id 字段从 Int 更改为 String 类型,需要以下几个步骤:

  • 使用新模式创建一个新的临时表 user_new ,
  • 将旧表user中的数据复制到临时表 user_new ,
  • 删除旧表user
  • 将临时表user_new 重命名为 user

那么 Migration 的实现如下所示:

val MIGRATION_3_4 = Migration(3, 4) { database ->
         // 创建新表
         database.execSQL("CREATE TABLE users_new (userid TEXT, username TEXT, last_update INTEGER, PRIMARY KEY(userid))")
         // 复制数据
         database.execSQL("INSERT INTO users_new (userid, username, last_update) SELECT userid, username, last_update FROM users")
         // 删除旧表
         database.execSQL("DROP TABLE users")
         // 将表名更改为正确的
         database.execSQL("ALTER TABLE users_new RENAME TO users")
 }

跨版本迁移

如果用户使用旧版本的应用,运行数据库版本 1,并且想要升级到版本 4,该怎么办呢?

通常情况下,我们的代码中已经定义了以下迁移策略:版本 1 到 2、版本 2 到 3、版本 3 到 4,因此 Room 将一个接一个的触发所有迁移策略。

但 Room 可以处理多个版本的增量:即我们可以定义一个一次性从版本 1 到版本 4 的迁移策略,从而使迁移过程更快。

val MIGRATION_1_4 = Migration(1, 4) { database ->
         // 创建新表
         database.execSQL("CREATE TABLE users_new (userid TEXT, username TEXT, last_update INTEGER, PRIMARY KEY(userid))")
         // 复制数据
         database.execSQL("INSERT INTO users_new (userid, username, last_update) SELECT userid, username, last_update FROM users")
         // 删除旧表
         database.execSQL("DROP TABLE users")
         // 将表名更改为正确的
         database.execSQL("ALTER TABLE users_new RENAME TO users")
 }

接下来,我们将它添加到迁移列表中即可:

val dataBase = Room.databaseBuilder(context.applicationContext, 
        AppDataBase::class.java, "app_database"
    ) 
    .addMigrations(MIGRATION_1_2 , MIGRATION_2_3 , MIGRATION_3_4 , MIGRATION_1_4) 
    .build()

定义对象之间的关系

定义一对一的关系

两个实体之间的一对一关系是指这样一种关系:父实体的每个实例都恰好对应于子实体的一个实例,反之亦然。

例如,假设有一个音乐在线播放应用,用户在该应用中具有一个属于自己的歌曲库。每个用户只有一个库,而且每个库恰好对应于一个用户。因此,User 实体和 Library 实体之间就应存在一种一对一的关系。

首先,为两个实体分别创建一个类。其中一个实体必须包含一个变量,且该变量是对另一个实体的主键的引用。

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

@Entity
data class Library(
    @PrimaryKey 
    val libraryId: Long,
    val userOwnerId: Long
)

如需查询用户列表和对应的库,必须先在两个实体之间建立一对一关系。为此,请创建一个新的数据类,其中每个实例都包含父实体的一个实例和与之对应的子实体实例。添加 @Relation 注解到子实体的实例,同时将 parentColumn 设置为父实体主键列的名称,并将 entityColumn 设置为引用父实体主键的子实体列的名称。

data class UserAndLibrary(
    @Embedded 
    val user: User,
    @Relation(parentColumn = "userId", entityColumn = "userOwnerId")
    val library: Library
)

最后,向 DAO 类添加一个方法,用于返回将父实体与子实体配对的数据类的所有实例。该方法需要 Room 运行两次查询,因此应向该方法添加 @Transaction 注解,以确保整个操作以原子方式执行。

@Transaction
@Query("SELECT * FROM User")
fun getUsersAndLibraries(): List<UserAndLibrary>

定义一对多关系

两个实体之间的一对多关系是指这样一种关系:父实体的每个实例对应于子实体的零个或多个实例,但子实体的每个实例只能恰好对应于父实体的一个实例。

在音乐在线播放应用示例中,假设用户可以将其歌曲整理到播放列表中。每个用户可以创建任意数量的播放列表,但每个播放列表只能由一个用户创建。因此,User 实体和 Playlist 实体之间应存在一种一对多关系。

首先,为两个实体分别创建一个类。与上个示例中一样,子实体必须包含一个变量,且该变量是对父实体的主键的引用。

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

@Entity
data class Playlist(
    @PrimaryKey 
    val playlistId: Long,
    val userCreatorId: Long,
    val playlistName: String
)

为了查询用户列表和对应的播放列表,必须先在两个实体之间建立一对多关系。为此,请创建一个新的数据类,其中每个实例都包含父实体的一个实例和与之对应的所有子实体实例的列表。添加 @Relation 注解到子实体的实例,同时将 parentColumn 设置为父实体主键列的名称,并将 entityColumn 设置为引用父实体主键的子实体列的名称。

data class UserWithPlaylists(
    @Embedded 
    val user: User,
    @Relation(parentColumn = "userId", entityColumn = "userCreatorId")
    val playlists: List<Playlist>
)

最后,向 DAO 类添加一个方法,用于返回将父实体与子实体配对的数据类的所有实例。该方法需要 Room 运行两次查询,因此应向该方法添加 @Transaction 注解,以确保整个操作以原子方式执行。

@Transaction
@Query("SELECT * FROM User")
fun getUsersWithPlaylists(): List<UserWithPlaylists>

定义多对多关系

两个实体之间的多对多关系是指这样一种关系:父实体的每个实例对应于子实体的零个或多个实例,反之亦然。

在音乐在线播放应用示例中,再次考虑用户定义的播放列表。 每个播放列表都可以包含多首歌曲,每首歌曲都可以包含在多个不同的播放列表中。因此,Playlist 实体和 Song 实体之间应存在多对多的关系。

首先,为两个实体分别创建一个类。多对多关系与其他关系类型均不同的一点在于,子实体中通常不存在对父实体的引用。因此,需要创建第三个类来表示两个实体之间的关联实体(即交叉引用表)。交叉引用表中必须包含表中表示的多对多关系中每个实体的主键列。在本例中,交叉引用表中的每一行都对应于 Playlist 实例和 Song 实例的配对,其中引用的歌曲包含在引用的播放列表中。

@Entity
data class Playlist(
    @PrimaryKey 
    val playlistId: Long,
    val playlistName: String
)

@Entity
data class Song(
    @PrimaryKey 
    val songId: Long,
    val songName: String,
    val artist: String
)

@Entity(primaryKeys = ["playlistId", "songId"])
data class PlaylistSongCrossRef(
    val playlistId: Long,
    val songId: Long
)

下一步取决于你想如何查询这些相关实体。

  • 如果你想查询播放列表和每个播放列表所含歌曲的列表,则应创建一个新的数据类,其中包含单个 Playlist 对象,以及该播放列表所包含的所有 Song 对象的列表。

  • 如果你想查询歌曲和每首歌曲所在播放列表的列表,则应创建一个新的数据类,其中包含单个 Song 对象,以及包含该歌曲的所有 Playlist 对象的列表。

在这两种情况下,都可以通过以下方法在实体之间建立关系:在上述每个类中的 @Relation 注解中使用 associateBy 属性来确定提供 Playlist 实体与 Song 实体之间关系的交叉引用实体。

data class PlaylistWithSongs(
    @Embedded 
    val playlist: Playlist,
    @Relation(
    	 parentColumn = "playlistId", 
    	 entityColumn = "songId",
         associateBy = Junction(PlaylistSongCrossRef::class)
    )
    val songs: List<Song>
)

data class SongWithPlaylists(
    @Embedded 
    val song: Song,
    @Relation(
         parentColumn = "songId",
         entityColumn = "playlistId",
         associateBy = Junction(PlaylistSongCrossRef::class)
    )
    val playlists: List<Playlist>
)

最后,向 DAO 类添加一个方法,用于提供应用所需的查询功能。

  • getPlaylistsWithSongs:该方法会查询数据库并返回查询到的所有 PlaylistWithSongs 对象。
  • getSongsWithPlaylists:该方法会查询数据库并返回查询到的所有 SongWithPlaylists 对象。

这两个方法都需要 Room 运行两次查询,因此应为这两个方法添加 @Transaction 注解,以确保整个操作以原子方式执行。

@Transaction
@Query("SELECT * FROM Playlist")
fun getPlaylistsWithSongs(): List<PlaylistWithSongs>

@Transaction
@Query("SELECT * FROM Song")
fun getSongsWithPlaylists(): List<SongWithPlaylists>

注意:如果 @Relation 注解不适用于您的特定用例,您可能需要在 SQL 查询中使用 JOIN 关键字来手动定义适当的关系。

定义嵌套关系

有时,你可能需要查询包含三个或更多表格的集合,这些表格之间互相关联。在这种情况下,需要定义各个表之间的嵌套关系。

在音乐在线播放应用示例中,假设你想要查询所有用户、每个用户的所有播放列表以及每个用户的各个播放列表中包含的所有歌曲。用户与播放列表之间存在一对多关系,而播放列表与歌曲之间存在多对多关系。以下代码示例显示了代表这些实体以及播放列表与歌曲之间多对多关系的交叉引用表的类:

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

@Entity
data class Playlist(
    @PrimaryKey 
    val playlistId: Long,
    val userCreatorId: Long,
    val playlistName: String
)

@Entity
data class Song(
    @PrimaryKey val 
    songId: Long,
    val songName: String,
    val artist: String
)

@Entity(primaryKeys = ["playlistId", "songId"])
data class PlaylistSongCrossRef(
    val playlistId: Long,
    val songId: Long
)

首先,按照常规方法使用数据类和 @Relation 注解在集合中的两个表格之间建立关系。以下示例展示了一个 PlaylistWithSongs 类,该类可在 Playlist 实体类和 Song 实体类之间建立多对多关系:

data class PlaylistWithSongs(
    @Embedded 
    val playlist: Playlist,
    @Relation(
         parentColumn = "playlistId",
         entityColumn = "songId",
         associateBy = Junction(PlaylistSongCrossRef::class)
    )
    val songs: List<Song>
)

定义表示此关系的数据类后,请创建另一个数据类,用于在集合中的另一个表与第一个关系类之间建立关系,并将现有关系嵌套到新关系中。以下示例展示了一个 UserWithPlaylistsAndSongs 类,该类可在 User 实体类和 PlaylistWithSongs 关系类之间建立一对多关系:

data class UserWithPlaylistsAndSongs(
    @Embedded 
    val user: User
    @Relation(
        entity = Playlist::class,
        parentColumn = "userId",
        entityColumn = "userCreatorId"
    )
    val playlists: List<PlaylistWithSongs>
)

UserWithPlaylistsAndSongs 类间接地在以下三个实体类之间建立了关系:UserPlaylistSong
Jetpack架构组件库:Room_第6张图片
如果集合中还有其他表,则可以创建类在剩余的每个表和关系类(此类可在之前的所有表之间建立关系)之间建立关系。这样会在你要查询的所有表之间创建一系列嵌套关系。

最后,向 DAO 类添加一个方法,用于提供应用所需的查询功能。该方法需要 Room 运行多次查询,因此应添加 @Transaction 注解,以便确保整个操作以原子方式执行。

@Transaction
@Query("SELECT * FROM User")
fun getUsersWithPlaylistsAndSongs(): List<UserWithPlaylistsAndSongs>

注意:使用嵌套关系查询数据需要 Room 处理大量数据,可能会影响性能。因此,请在查询中尽量少用嵌套关系。


Room 设计原理分析

抽象层设计: 采用接口隔离原则,隔离sqlite的实现层,方便以后替换sqlite的实现层。


FrameworkSQLiteStatement中的所有操作都是通过SQLiteStatement来完成的。Room就是把原生的Api能力进行了封装和隔离而已,接口隔离原则。
Jetpack架构组件库:Room_第7张图片

数据库创建流程:

Room + LiveData 监听数据库数据变更刷新页面原理分析:

Jetpack架构组件库:Room_第8张图片

Room 与 LiveData 的巧妙结合:

  • 第一次向 LiveData 注册 Observer 时,触发 onActive() 方法,并向 InvalidationTracker 注册表数据变更监听
  • 增删改三种操作开始之前会向一张表中写入本次操作的表的名称,状态置为 1,操作完成后会触发 InvalidationTrackerendTranstions。进而调用 refreshRunnable 查询出所有数据变更了的表,然后回调给每一个RoomTrackingLiveData,再次执行 refreshRunnable 重新加载数据,并发送到UI层的observer刷新页面。

Jetpack架构组件库:Room_第9张图片

你可能感兴趣的:(Android,架构,架构,android,Jetpack架构组件库,Room,数据库)