一文理解Jetpack——Room

883 阅读11分钟

屏幕截图 2024-05-02 102507.png

在上一篇文章 一文理解Jetpack——SQLite 中,我们介绍了 SQLite 相关的知识。但是 Google 是不推荐我们直接使用 SQLite 的,而是推荐使用 Room 库来操作数据库。

要使用 Room 库我们需要先引入依赖,代码如下:

// 引入依赖
val room_version = "2.6.1"    
implementation("androidx.room:room-runtime:$room_version")  
annotationProcessor("androidx.room:room-compiler:$room_version")

Room 的组成

Room 主要包含三个组件,分别为 DataBaseEntityDAO。其中 DataBase 是数据库持有者;Entity 表示数据库中对应的表;DAO 则提供了访问数据库方法的对象。在 Android 开发中,我们一般通过 DataBase 来获取对应数据库的访问对象 DAO,然后使用 DAOEntity 进行各种操作。它们之间的关系如下图所示,图片来源官方文档

image.png

代码示例如下:

// 通过 @Entity 在数据库中定义了 user 表
// User 类的每个实例都代表应用数据库中 user 表中的一行
@Entity
data class User(
    @PrimaryKey val uid: Int, // @PrimaryKey 定义主键,必须要有一个
    // @ColumnInfo 设置对应表的字段名
    @ColumnInfo(name = "first_name") val firstName: String?, 
    @ColumnInfo(name = "last_name") val lastName: String?
)

// 通过 UserDao 对象来操作 user 表
@Dao
interface UserDao {
    @Query("SELECT * FROM user")
    fun getAll(): List<User>
}

// entities 数组表示与数据库关联的数据实体;version 表示数据库版本号
@Database(entities = [User::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
    abstract fun userDao(): UserDao
}
// 定义数据实体、DAO 和数据库对象后
// 我们就可以使用以下代码创建数据库实例并与数据库进行交互了
val db = Room.databaseBuilder(
            applicationContext,
            AppDatabase::class.java, "database-name"
        ).build()
val userDao = db.userDao()
val users: List<User> = userDao.getAll()        

使用 @Entity 定义表

从上面的代码示例可以看到,Room 通过 @Entity 注解来定义数据库中的表。@Entity 的定义如下所示,可以看到 @Entity 内部有很多字段,接下来我们一一介绍。

@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.BINARY)
public annotation class Entity(
    // 数据表名, 默认情况下以类名为表名
    val tableName: String = "",

    // 索引
    val indices: Array<Index> = [],

    // 是否继承父类索引
    val inheritSuperIndices: Boolean = false,

    // 复合主键
    val primaryKeys: Array<String> = [],

    // 外键约束
    val foreignKeys: Array<ForeignKey> = [],

    // 忽略字段数组
    val ignoredColumns: Array<String> = []
)

tableName

默认情况下,Room 会以类名为表名。我们也可以使用 tableName 来主动设置表的名字。代码示例如下:

@Entity(tableName = "user_info")
data class User(
    ...
)

indices

indices 用来定义索引。索引是一种特殊的查找表。通过索引,数据库可以加快数据的SELECT 查询过程。在 Room 中,索引的定义非常简单,只需要使用 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?,
)

声明索引之后,数据库内部会完成索引的处理,后面我们只需要正常的查询就可以了,不需要关心内部的实现。唯一需要注意的是,索引虽然会加快数据的SELECT 查询过程,但是会减慢使用 UPDATE 和 INSERT 的数据更新和插入过程。因此创建索引时,需要考虑以下几点:

  1. 较小的表上,不建议使用索引
  2. 声明索引的列,不应该频繁更新
  3. 需要频繁的更新或插入操作的表上,不建议使用索引
  4. 索引不应该使用在含有大量的 NULL 值的列上。

inheritSuperIndices

inheritSuperIndices 用来表示是否继承父类索引

primaryKeys

如下代码所示,一般情况下,我们使用 @PrimaryKey 来声明主键。但是如果我们需要通过多个列的组合对实体实例进行唯一标识。这时候我们就可以使用 primaryKeys 来定义一个复合主键。

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

primaryKeys 的使用如下:

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

foreignKeys

foreignKeys 用来做外键约束。代码示例如下:

// 学生表
 @Entity
 data class Student(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    @ColumnInfo(name = "first_name") val firstName: String,
    @ColumnInfo(name = "last_name") val lastName: String
 )
 
// 成绩单表 
@Entity(tableName = "grade_result")
data class GradeResult(
@PrimaryKey(autoGenerate = true) val gradeId: Long = 0,
@ColumnInfo(name = "student_id") val studentId: Long
)

可以看到, 成绩单表通过 student_id 关联到学生表。因此 student_id成绩单表的外键。但是这里有一个问题,就是我们可以随便给成绩单表添加任何数据,即使 student_id学生表 中找不到。

这时候,就可以使用外键约束。有了外键约束后,当我们插入数据时,如果没有对应的student_id 则会报错。创建外键约束的代码如下:

@Entity(tableName = "grade_result",
   foreignKeys = [ForeignKey(
       entity = Student::class,
       childColumns = ["student_id"],
       parentColumns = ["id"]
)])
data class GradeResult(
   @PrimaryKey(autoGenerate = true) val gradeId: Long = 0,
   @ColumnInfo(name = "student_id") val studentId: Long
)

ignoredColumns

ignoredColumns 表示忽略字段的数组。一般情况下,我们使用 @Ignore 来忽略该字段。代码示例如下:

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

但是如果需要忽略父类的字段,这时就需要ignoredColumns了,代码示例如下:

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

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

复杂数据类型转换

在 Room 中,不允许实体类之间的引用。具体原因可以看了解 Room 为何不允许对象引用。因此我们需要使用类型转化器来支持自定义类型。代码示例如下:

// 比如说,我们需要存储 Date 信息,就可以定义 Date 的转化器
class Converters {
  @TypeConverter
  fun fromTimestamp(value: Long?): Date? {
    return value?.let { Date(it) }
  }

  @TypeConverter
  fun dateToTimestamp(date: Date?): Long? {
    return date?.time?.toLong()
  }
}

// 使用 @TypeConverters 声明转化器
@TypeConverters(Converters::class)
@Entity
data class User(private val birthday: Date?)

声明嵌套关系

有的时候,我们需要需要声明嵌套关系。比如说用户的信息包含名字、地址等,而地址信息又包含城市、街道等。代码示例如下,我们可以使用 @Embedded 注解来声明嵌套关系。

data class Address(
    val street: String?,
    val city: String?,
)

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

User 表中,address会以 citystreet 字段的形式存储在表中。如果你想让 Address 存储在不同的表中,可以看后面的表之间的关系。

如何操作数据

Room 中,我们通过 DAO 来操作数据。代码示例如下:

@Dao
interface UserDao {

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun insertUsers(vararg users: User)

    @Delete
    fun deleteUsers(vararg users: User)

    @Update
    fun updateUsers(vararg users: User)

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

@Insert

DAO 中,使用 @Insert 来标注插入数据的方法(可以插入一条数据或者多条)。其中有一个onConflict参数,是用于定义当插入数据冲突时执行的操作。它有六种处理方式,其中2种已经过时了。其中未过时的4种处理方式如下所示:

策略作用
OnConflictStrategy.ABORT 终止插入,并且抛出异常
OnConflictStrategy.NONE默认的策略,它和ABORT作用是一样的
OnConflictStrategy.REPLACE 覆盖原数据
OnConflictStrategy.IGNORE 忽略这条数据,若是有返回值的话则返回-1

@Delete

DAO 中,使用 @Delete 来标注删除数据的方法(可以删除一条数据或者多条)。代码示例如下:

    @Delete
    fun deleteUsers(vararg users: User)

@Update

DAO 中,使用 @Update 来标注更新数据的方法(可以更新一条数据或者多条)。代码示例如下:

    @Update
    fun updateUsers(vararg users: User)

@Query

DAO 中,使用 @Query 来标注查询数据的方法。代码示例如下:

    // 查询表内所有的数据
    @Query("SELECT * FROM user")
    fun getAll(): List<User>

    // 查询表内的部分字段  
    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>

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

@Transaction

DAO 中,如果我们想要多个操作在一个事务中执行,我们可以使用 @Transaction 注解。代码示例如下:

// 先后插入学生和成绩信息,以事务的方式执行
@Transaction
fun insertStudentAndGrade(student: Student, grade: Grade) {
    insertStudent(student)
    insertGrade(grade)
}

复杂的SQL操作

如果你的 SQL 非常复杂。只使用上面 Room 提供的注解无法完成对应的操作。这时你可以通过 RoomDatabase 获取 SQLiteOpenHelper 对象来直接执行对应的 SQL 方法。代码示例如下:

val db = Room.databaseBuilder(
    applicationContext,
    AppDatabase::class.java, "database-name"
).build()
db.openHelper.writableDatabase.execSQL("你的sql")

异步操作

需要注意,上面的操作都是同步的。如果需要异步操作,我们只需要给对应的 DAO 方法加上 suspend 就可以了。代码示例如下:

@Dao
interface UserDao {

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

    @Delete
    suspend fun deleteUsers(vararg users: User)

    @Update
    suspend fun updateUsers(vararg users: User)

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

如果你需要监听操作执行的结果,可以使用 Flow 或者 LiveData。代码示例如下:

// 使用 Flow
@Query("SELECT * FROM user")
suspend fun getAll(): Flow<List<User>>

// 使用 LiveData
@Query("SELECT * FROM user")
fun getAll(): LiveData<List<User>>

注意,使用 Flow 或者 LiveData 监听数据时,需要使用 distinctUntilChanged去重。这是因为,表执行了任何更新,都会通知过来,无论这个更新的字段你是否关心。

表之间的关系

上面的查询操作中,我们都是对一个表进行操作。虽然多数情况下,使用一个表存储数据就够了。但是有时候,可能需要创建多个表。这时候就需要定义表之间的关系了。

表之间的关系一般有四种,分别是一对一、一对多、多对多、嵌套关系。下面分别介绍如何定义这些关系。

一对一关系

在 Room 中使用 @Relation 注解来建立两个实例之间的关系。代码示例如下:

@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
)

data class UserAndLibrary(
    @Embedded val user: User, // 使用 @Embedded 映射到 User 表对应的字段
    @Relation(
         parentColumn = "userId", // User 主键
         entityColumn = "userOwnerId" // 外键
    )
    val library: Library
)

之后我们我们就可以在 DAO 中来查询了,代码示例如下:

// 由于内部实现会涉及到 Room 两次查询,因此需要使用 @Transaction 。下面的关系也是一样的
@Transaction
@Query("SELECT * FROM User")
fun getUsersAndLibraries(): List<UserAndLibrary>

一对多关系

一对多关系和一对一关系的声明是一样的,不同的是对应的结果是集合。代码示例如下:

data class UserAndLibrary(
    @Embedded val user: User, 
    @Relation(
         parentColumn = "userId", 
         entityColumn = "userOwnerId"
    )
    val librarys: List<Library> // 不同点
)

多对多关系

多个歌单和多个歌曲形成了多对多的关系。我们需要一个额外的表 PlaylistSongCrossRef 来表明它们的引用关系。代码示例如下:

// 播放列表
@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
)
// 歌单及歌单中对应的歌曲
data class PlaylistWithSongs(
    @Embedded val playlist: Playlist,
    @Relation(
         parentColumn = "playlistId",
         entityColumn = "songId",
         associateBy = Junction(PlaylistSongCrossRef::class) // 不同点:通过 associateBy 来设置引用表
    )
    val songs: List<Song>
)

嵌套关系

假设一个音乐应用,我们需要获取一个用户的所有歌单、以及对应的歌曲信息。这时就需要嵌套查询,代码示例如下:

data class UserWithPlaylistsAndSongs(
    @Embedded val user: User // 用户信息
    @Relation(
        entity = Playlist::class, // 不同点:User 与 Playlist 对应,因此这里需要设置为 Playlist::class
        parentColumn = "userId",
        entityColumn = "userCreatorId"
    )
    val playlists: List<PlaylistWithSongs>
)

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

@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
)

嵌套关系如下图所示:

image.png

数据库升级

当我们需要删除或者重命名表和字段时,需要升级数据库的版本。在 Room 中,我们可以通过 @DatabaseautoMigrations 属性来实现。代码示例如下:

@Database(
  version = 2,
  entities = [User::class],
  autoMigrations = [
    AutoMigration (
      from = 1,
      to = 2,
      spec = AppDatabase.MyAutoMigration::class
    )
  ]
)
abstract class AppDatabase : RoomDatabase() {
    // 重命名表名
    @RenameTable(fromTableName = "User", toTableName = "AppUser")
    class RenameTableAutoMigration : AutoMigrationSpec
    // 删除表
    @DeleteTable(tableName = "User")
    class DeleteTableAutoMigration : AutoMigrationSpec
    // 重命名字段
    @RenameColumn(tableName = "User", fromColumnName = "id", toColumnName = "user_id")
    class RenameColumnAutoMigrationSpec: AutoMigrationSpec
    // 删除字段
    @DeleteColumn(tableName = "User", columnName = "nick_name")
    class DeleteColumnAutoMigrationSpec: AutoMigrationSpec
}

如果你想完成更复杂的更新逻辑,也可以手动升级。代码示例如下:

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()

参考