Jetpack--Room
jetpack Room 数据库
资料来源:
https://developer.android.com/training/data-storage/room
更新
1
2
3
4
5
620.03.01 初始化
20.03.03 补完
20.03.12 补充测试
20.04.28 补充内容
20.06.24 补充数据库迁移
21.02.02 补充存入 object
导语
- 初始仅仅是速描官方文档,随时添加内容.
概览
Room 是一个基于 SQLite 的 ORM 数据库,架构组件中可以直接返回 LiveData.
包含有3个部分
- Entity: 数据库中的表.@Entity 修饰.
- DAO: 包含访问数据库的方法.是从数据库面向关系到面向对象的转换. @DAO 修饰.
- RoomDatabase: 数据库的持有者, @Database 修饰.
- 必须继承自 RoomDatabase.
- 在注释中添加与数据库关联的实体列表.
- 包含具有 0 个参数且返回使用 @Dao 注释的类的抽象方法.
导入 Room,在应用的 build.gradle 声明.(特别注意使用kotlin开发需要用 kapt 而不是 annotationProcessor)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15dependencies {
def room_version = "2.2.3"
implementation "androidx.room:room-runtime:$room_version"
annotationProcessor "androidx.room:room-compiler:$room_version" // For java
kapt "androidx.room:room-compiler:$room_version" // For kotlin
// optional - Kotlin Extensions and Coroutines support for Room
implementation "androidx.room:room-ktx:$room_version"
// optional - RxJava support for Room
implementation "androidx.room:room-rxjava2:$room_version"
// optional - Guava support for Room, including Optional and ListenableFuture
implementation "androidx.room:room-guava:$room_version"
// Test helpers
testImplementation "androidx.room:room-testing:$room_version"
}总而言之 Room 基本使用流程
- 定义 @Entity 修饰的类.(数据库的表)
- 定义 @Dao 修饰的数据库访问方法.
- 实现一个继承自 RoomDatabase 的数据库持有者.
- 还有类型转换等其他操作.
Room 最吸引我的是其与架构组件的配合,直接返回 LiveData 非常方便.
Room 会在编译时检查定义的映射,不通过直接报错.
简单示例
Entity: 定义一个 User 的表,uid 主键 name 字段.kt 中常常与数据类配合.
1
2
3
4
5
data class User(
val uid: Int,
val Name: String?
)Dao: 一般是一个接口或抽象类.具体方法编译时 Room 会自动生成.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
interface UserDao {
fun getAll(): List<User>
fun loadAllByIds(userIds: IntArray): List<User>
Database: 数据库的持有者,同时持有 Dao 实例.
1
2
3
41) , version =
abstract class AppDatabase : RoomDatabase() {
abstract fun userDao(): UserDao
}创建数据库实例.一个数据库实例的代价相当高,单进程一般采用单例模式.
1
2
3
4val db = Room.databaseBuilder(
applicationContext,
AppDatabase::class.java, "database-name"
).build()
定义实体
定义一个实体,@Entity 实际上是定义数据库中的表.
@Entity 修饰(kotlin 中多用数据类)
@PrimaryKey 至少 1 个字段定义为主键.
例:
1
2
3
4
5
data class User(
var id: Int,
var lastName: String?
)
主键 @PrimaryKey
至少 1 个字段定义为主键.
可以设置 autoGenerate 属性使 id 自增.
复合主键需要在 @Entity 使用 primaryKeys 声明.
例:
1
2
3
4
5
6//复合主键
)
data class User(
val firstName: String?,
val lastName: String?
)
命名
默认情况下表名是类名.可以修改 @Entity 的 tableName 属性改变默认的表名.
字段名默认也是属性的名称,修改字段名需要添加 @ColumnInfo 到具体的属性.
例:
1
2
3
4
5
data class User (
val id: Int,
val firstName: String?
)
忽略字段 @Ignore.
表搜索(暂时忽略)
实体关系
与其他的 ORM 数据库不同,Room 不允许实体对象之间互相引用.官方解释
一对多
利用外键实现.
例: foreignKeys 声明外键,关联到了 User 类.还可以在 @ForeignKey 注释中添加 onDelete = CASCADE,在 User 的对应实例删除后告知 SQLite 删除该用户的所有图书.
1
2
3
4
5
6
7
8
9
10
11
,
childColumns = arrayOf("user_id"))
)
)
data class Book(
val bookId: Int,
val title: String?,
val userId: Int
)
嵌套对象
效果仅仅是在声明时分离,实际生成时依旧是在一个表中.
例: 查询时 street state city 都在 User 表中.
1
2
3
4
5
6
7
8
9
10
11
12
13data class Address(
val street: String?,
val state: String?,
val city: String?,
val postCode: Int
)
data class User(
val id: Int,
val firstName: String?,
val address: Address?
)
多对多
因为 Room 不允许实体对象之间互相引用,多对多需要使用中间类配合外键实现.
Playlist 可以存放无限多的 Song 信息,同一首 Song 可以存在在不同的 Playlist 中.
实体类声明
1
2
3
4
5
6
7
8
9
10
11
12
13
data class Playlist(
var id: Int,
val name: String?,
val description: String?
)
data class Song(
var id: Int,
val songName: String?,
val artistName: String?
)中间类:包含对 Song 和 Playlist 的外键引用.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
,
foreignKeys = arrayOf(
ForeignKey(entity = Playlist::class,
parentColumns = arrayOf("id"),
childColumns = arrayOf("playlistId")),
ForeignKey(entity = Song::class,
parentColumns = arrayOf("id"),
childColumns = arrayOf("songId"))
)
)
data class PlaylistSongJoin(
val playlistId: Int,
val songId: Int
)DAO:根据歌曲搜索播放列表,根据播放列表搜素歌曲.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
interface PlaylistSongJoinDao {
fun insert(playlistSongJoin: PlaylistSongJoin)
fun getPlaylistsForSong(songId: Int): Array<Playlist>
fun getSongsForPlaylist(playlistId: Int): Array<Song>
}
SQLite 视图
- 暂且不表.
存入任意类型
实际上 Room 直接支持存储的类型相当有限.经常使用的 list set 等类型都要曲线救国.
Room 支持通过 @TypeConverter 注解,将不支持类型转换为基本类型.
以存入 Set<String>
类型为例.通过 Gson 将任意对象转换为 json -> string 存储,读取时再反序列化.
1 | class Converters { |
转换类需要在 AppDatabase 声明
1 |
|
ps: 参考 Android避坑指南,Gson与Kotlin碰撞出一个不安全的操作 有一个坑.
Gson 生产新对象是通过反射调用 Type 的默认构造函数,如果此时恰好 Type 是 kotlin 的 dataclass,即使你在 dataclass 中声明了参数是非空,但是没有对其初始化,通过 Gson 生成的新对象的参数就是 null 的.
当然这里调用 Gson 的都是 Room 实际数据都是有效的,可能不太会遇到这个问题.
DAO
DAO 定义了 SQL 语言到对象的映射.方便了单元测试.
一般是接口类型(也可也是抽象类,需要有 RoomDatabase 为唯一参数的构造函数).编译时 Room 会生成对应的方法.
ps: 除非调用了 allowMainThreadQueries() ,否则 Room 不允许在主线程上运行.
@Insert
插入数据库,
例:
1
2
3
4
5
interface MyDao {
fun insertUsers(vararg users: User)
}
@Update
更新表.
例:
1
2
3
4
5
6
interface MyDao {
fun updateUsers(vararg users: User)
}
@Delete
删除
例子:
1
2
3
4
5
interface MyDao {
fun deleteUsers(vararg users: User)
}
@Query
查询
简单查询:返回所有表.
1
2
3
4
5
interface MyDao {
fun loadAllUsers(): Array<User>
}带参数: 在 SQL 中以 :变量 引用.传入多个多次引用也没问题.
1
2
3
4
5
interface MyDao {
fun loadAllUsersOlderThan(minAge: Int): Array<User>
}返回子集: 有时并不需要查询全表,只需要查询几个字段返回对象即可.需要先定义一个 POJO 对象(@ColumnInfo 修饰字段),再在 DAO 中声明.(@Embedded 亦可)
1
2
3
4
5
6
7
8
9
10data class NameTuple(
val firstName: String?,
val lastName: String?
)
interface MyDao {
fun loadFullName(): List<NameTuple>
}传入参数集合: 有时并不知道传入参数的具体数量,这时可以传入一个集合.
1
2
3
4
5
interface MyDao {
fun loadUsersFromRegions(regions: List<String>): List<NameTuple>
}与 LiveData 和 Rxjava 配合.(暂时省略)
与 kotlin 协程
- 可以将 suspend Kotlin 关键字添加到 DAO 方法,这样 DAO 的方法会在协程上执行,并且确保不会在主线程上运行.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
interface MyDao {
suspend fun insertUsers(vararg users: User)
suspend fun updateUsers(vararg users: User)
suspend fun deleteUsers(vararg users: User)
suspend fun loadAllUsers(): Array<User>
}- ps: 注意一点,协程与 LiveDate 都是异步回调,并不兼容.
Kotlin协程 Flow 与 LiveData
在协程和 Flow 使用 LiveData, 这是 Google 2019 年技术峰会的一个演讲主题,解决了我的一个问题.
问题:存在几乎相同的 3 个 Fragment,他们对应的 ViewModel / Room 等等都几乎相同,需要合并样板代码.
- Room 直接返回 livedata 的类型是唯一的,且不支持泛型或者子类覆盖父类这样的操作,3 种类型无法在 Room 层面合并.
- UI 层使用了 Databinding 必须传入 livedata 类型,无法使用其他类型,数据到 ViewModel 必须是 Livedata.
解决:Flow 流 解决了这个问题.
- Room 包装返回的 Flow 流(异步流),可以简单理解为一直向下游扔数据的生产者,而且最棒的是 Room 返回的 Flow 具有持续的通知能力,从数据更新角度看和直接使用 livedata 完全相同.
- Flow 流支持子类覆盖父类的操作.即 Room 返回一个子类的流,ViewModel 里是使用的父类的流.这样就能抽象父类了.
- Flow 流到 livedata 非常方便,只需要调用 .asLiveData() 即可.google 做了很多工作.
简单的实例: 父类是 Item ,子类是 Alarm / Service /Wakelock.
- Room 可以直接返回 Flow 流,和使用 livedata 一样简单.
1 | //DAO 返回子类的流 |
数据库迁移
版本升级过程中,难免存在数据库的更改,Room 对数据库的迁移也必须提供支持.
具体过程并不复杂,在声明数据库持有者时同时要声明数据库的版本号.
- 每个版本 Room 数据库的升级都需要实现一个 Migration 类.
- 每个指定一个 Migration 类都有 startVersion 和 endVersion.声明自己对应 Room 哪两个版本之间的升级.
- 运行时 Room 会依次调用 Migration 类的 migrate() 完成升级.
例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16val 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()
数据库测试
room 可以在 Android 设备或者主机上进行测试,不过还是推荐采用 Android 设备测试,以确保数据库正常工作.
一些基本的注解等不再重复(后面针对 Android 测试有专门一篇总结).
其过程与我们使用数据库几乎无二.只是生成数据库要选择
Room.inMemoryDatabaseBuilder
.例(删减):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38class AppDatabaseTest {
var instantTaskExecutorRule = InstantTaskExecutorRule()
private lateinit var appInfoDao: AppInfoDao
private lateinit var wakeLockDao: WakeLockDao
private lateinit var db: AppDatabase
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
db = Room.inMemoryDatabaseBuilder(
context, AppDatabase::class.java
).build()
appInfoDao = db.appInfoDao()
wakeLockDao = db.wakeLockDao()
}
fun tearDown() {
db.close()
}
fun loadWithoutInserted() {
val appinfos = runBlocking {
appInfoDao.loadAllAppInfos()
}
val wakeLocks = runBlocking {
wakeLockDao.loadAllWakeLocks(TestData.packageName)
}
assertTrue(appinfos.isEmpty())
assertTrue(wakeLocks.isEmpty())
}
}
测试数据库迁移
测试迁移需要用到 Maven.(需要在依赖中加入 Maven)
Maven 是通过 Room 导出的架构文件构建不同版本的数据库进行测试.
设置导出 build.gradle,重写编译后会在 ‘/app/schemas/‘ 下生成 json 文件,是 room 到 sqlite 的具体映射.
1
2
3
4
5
6
7
8
9
10
11
12android {
...
defaultConfig {
...
javaCompileOptions {
annotationProcessorOptions {
arguments = ["room.schemaLocation":
"$projectDir/schemas".toString()]
}
}
}
}设置资源文件到 Maven
1
2
3
4
5
6android {
...
sourceSets {
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
}
}测试单个迁移(有坑),Maven 会测试数据库本身是否能正常升级,但是还需要我们自行验证升级时,数据是否正常.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class MigrationTest {
private val TEST_DB = "migration-test"
val helper: MigrationTestHelper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
MigrationDb::class.java.canonicalName,
FrameworkSQLiteOpenHelperFactory()
)
fun migrate1To2() {
var db = helper.createDatabase(TEST_DB, 1).apply {
// 插入一些数据,这里不能用 dao 定义的方法.
execSQL(...)
// Prepare for the next version.
close()
}
// 开始迁移到 version 2
db = helper.runMigrationsAndValidate(TEST_DB, 2, true, MIGRATION_1_2)
// 接下来需要检查 version 1 时,插入的数据是否正常.
...
}
}
坑: 按照上面的运行测试会提示 AndroidJUnit4 找不到..解决办法: @Rule 改为 @get:Rule
测试全部
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21private val ALL_MIGRATIONS = arrayOf(
MIGRATION_1_2, MIGRATION_2_3
)
fun migrateAll() {
// 初始版本 1
helper.createDatabase(TEST_DB, 1).apply {
close()
}
// Open latest version of the database. Room will validate the schema
// once all migrations execute.
Room.databaseBuilder<AppDatabase>(
InstrumentationRegistry.getInstrumentation().targetContext,
AppDatabase::class.java, TEST_DB
).addMigrations(*ALL_MIGRATIONS).build().apply {
openHelper.writableDatabase
close()
}
}坑: 官网的示例是 kotlin,但是写的是
AppDatabase.class
..kotlin 中应该是AppDatabase::class.java
..官方也不细心系列..
几个 Room 升级实例
增加了新表
1 |
|
对应的升级也很简单,执行建表 SQL 即可.
1 | private val MIGRATION_2_3 = object : Migration(2, 3) { |
迁移已有表
这里可以有多种情况了
- 更换表名
- 表需要添加新的字段.
- 根据已有单张表,创新新的表.
- 根据已有多张表,创建新的表.
- 还有一种比较特殊是,Room 2.2.0 版本以后支持字段的默认值,但 2.2 之前不支持.跨 2.2 版本升级,需要重建表以解决默认值的问题.
以上的过程可以以下面的示例覆盖.
- A 表要升级,需要新增字段,但是这个字段的值在 B 表中,表A 和 表B 的主键是一一对应的.(外键约束)
1 | //增加了 packageName 字段 |
升级过程,Room 推荐是先创建一张临时表,再删除旧表,最后更改表名.
1 | private val MIGRATION_4_5 = object : Migration(4, 5) { |
尾巴
- 最近几篇是真的水,都是官方文档的总结,接下来会进入项目,应该会好一点.
- GA 显示改版后 Blog 阅读量直接降到了三位数..旧链接失效影响还是蛮大的.
- 慢慢来,一点点走结实了.