Room的使用

背景

按照Anko官方的建议开始逐步替换anko,
在替换anko-sql时准备使用提到的Room.

介绍

Room是谷歌提供的ORM,多数使用注解的方式.
兼具灵活性(@Query 注解可以自定义查询语句)与
便捷性(免去常规的插入语句,免去表创建语句等).
但好像和 active-record 风格不一样,还需要DAO,感觉奇怪.

目前感觉的亮点

  1. 不需要自己写创建数据库的sql语句
  2. 如果是 select * 可以将结果自动转成entity
  3. 提供的 @Insert, @Update, @Delete 注解可以自动生成sql模板代码
  4. 能够使用 LiveData 来监控数据库变化

依赖

1
2
3
4
5
6
apply plugin: 'kotlin-kapt'

dependices {
implementation "androidx.room:room-runtime:2.2.5"
kapt "androidx.room:room-compiler:2.2.5"
}

有的地方写

1
2
implementation "androidx.room:room-runtime:2.2.5"
annotationProcessor "androidx.room:room-compiler:2.2.5"

但不知道为什么会报 AppDatabase_Impl 没有生成的问题,
换成 kapt 之后就没问题了

必要组件

Entity

1
2
3
4
5
6
7
@Entity(tableName = "users")
data class User(
var name: String,
var salary: Int
) {
@PrimaryKey(autoGenerate = true) var id: Long? = null
}
  • 默认情况下表名等于类名,使用 tableName 来自定义
  • 默认情况下列名等于变量名,使用 @ColumnInfo(name = "first_name") 来定义
  • 默认情况下全部列名作为表的列,使用 @Ignore 表示该变量不需要作为列名.
  • 这里定义好id的自动增加后,设置默认null只是为了语法不报错,id还是自动从1增加的.
  • 数据库的notnull是根据entity的类型决定的, Int 就是notnull, Int? 就是null

高级一些的用法

  1. 多个主键 @Entity(primaryKeys = {"last_name", "first_name"})
  2. 单个索引 @Index("name")
  3. 多个索引 @Index(value = {"last_name", "first_name"})
  4. 默认值 @ColumnInfo(defaultValue = "0"), Room 2.2.0才加入的功能

DAO

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
@Dao
interface UserDao {

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

@Query("SELECT * FROM Users WHERE id = :id")
fun getUserById(id: String): User?

@Query("SELECT * FROM Users LIMIT 1")
fun getOneUser(): User?

/*当数据库中已经有此用户的时候,直接替换*/
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun addUser(user: User)

@Update
fun upDateUser(user: User)

@Delete
fun deleteUser(user: User)

@Query("DELETE FROM Users")
fun deleteAllUsers()
}

除了查询,其他的DAO注解还是很好用的,再也不用手写插入代码了

1
2
3
4
5
6
insert(
Invoice.TABLE_NAME,
Invoice.COLUMN_SENDER_ID to owner.senderId,
Invoice.COLUMN_SENDER_NM to senderNm.text.toString(),
Invoice.COLUMN_CREATED_USER_ID to owner.thisApp!!.loginUserId
)

Database

初始化DB的实例开销比较大,
因此常常使用单例模式.
kotlin的单例模式有许多中写法,
这里只是其中一种.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Database(entities = [User::class], version = 1)
abstract class AppDatabase : RoomDatabase() {

// DAO只需要声明一下
abstract fun userDao(): UserDao

companion object {
private var instance: AppDatabase? = null
fun getInstance(context: Context): AppDatabase {

// 单例模式
if (instance == null) {
instance = Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"sample.db" //数据库名称
).allowMainThreadQueries().build()
}

// 单例模式
return instance as AppDatabase
}
}
}

使用举例

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
class MainActivity : AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

val userDao = AppDatabase.getInstance(this).userDao()

//insert数据
for (i in (0 until 10)) {
val user = User("test$i", 1)
userDao.addUser(user)
}

//query所有数据
userDao.getAllUsers().forEach {
Log.d("room", "==query==${it.id},${it.name},${it.salary}")
}

//delete所有数据
userDao.deleteAllUsers()

Log.d("room", "1")
}
}

还是需要提供context给 getInstance,
原生的sql本身就是需要context的

1
2
val crSQLiteOpenHelper = CRSQLiteOpenHelper(applicationContext, "create_db", null, 1)
this.db = crSQLiteOpenHelper.writableDatabase

和livedata搭配的用法

Room可以在数据库发生改变时更新模型的值,
然后模型的值会驱动UI变化,
这样保证用户看到的一定被存储下来了,
不会因为断电等无法保存.

目前的一些实现方法是通过ViewModel实现的,而且也很简单.
首先在DAO中加入返回值为Livedata的方法

1
2
3
4
5
6
7
8
@Dao
interface UserDao {

@Query("SELECT * FROM Users ORDER BY id DESC LIMIT 1")
fun getOneUser(): LiveData<User>

// ...
}

然后在AndroidViewModel中,
使用context初始化一个userDao,
并使用userDao的方法来获取一个类型为Livedata的 user 对象

1
2
3
4
5
6
7
8
9
10
11
12
13
class UserViewModel(application: Application) : AndroidViewModel(application) {
var userDao = AppDatabase.getInstance(application).userDao()

// 该语句的效果并非使得User的值被赋值一次
// 而是将Livedatae这个壳直接传递,即使里面的User并没有,也会传递
// 具体Livedata如何打开,还是在Activity中手写Observer来定义的.
var user = userDao.getOneUser()

// 因此不需要在ViewModel中也写类似activity中监控变化就做什么的逻辑
// userDao.getOneUser().observe(this, Observer {
// this.user = it
// })
}

最后在Activity中定义

1
2
3
viewModel.user.observe(this, Observer {
text1.text = it.toString()
})

流程是

  1. 数据库发生变化
  2. UsedDao的getOneUser()方法被调用
  3. UserViewModel的user对象被改变
    • 好在不需要设置userDao.getOneUser().observe()
    • 如果安卓的未来变成像tensorflow一样的图编程,可能会非常不好debug
  4. View被改变

采用repository架构

Repository主要用途是为应用程序提供整洁的,用于获取数据的API,
隐藏掉本地操作和远程操作的细节

  • 方便了测试(代码被解耦,可以手动写虚拟的repository来测试viewmodel)
  • 方便应用程序实现在断网时数据存在本地,联网后又重新把数据提交到云端
  • 方便应用程序把一些常用的网络查询缓存在内存中,减少流量消耗,提升响应速度等

实现起来也比较简单.
先准备一个Repository

1
2
3
4
5
6
7
8
9
10
class UserRepository(application : Application) {
// 这里得对了DAO的对象
var userDao = AppDatabase.getInstance(application).userDao()

// 为了假装出repository提供了统一API
// 特意把获取数据的函数改得和viewmodel直接调用userDao不同
fun getUser() : LiveData<User> {
return userDao.getOneUser()
}
}

然后将viewmodel中直接使用userDao的代码换成经过repository

1
2
3
4
5
6
7
class UserViewModel(application: Application) : AndroidViewModel(application) {
// var userDao = AppDatabase.getInstance(application).userDao()
// var user = userDao.getOneUser()

var userRepository = UserRepository(application)
var user = userRepository.getUser()
}

总结

activity保有AndroidViewModel对象.
activity和viewmodel各自保有绑定的数据用以显示变量.
viewmodel保有Repository对象,且数据由repository的方法或数据直接驱动.
repository保有DAO和网络请求的对象.

数据库发生变化时,DAO的方法自动执行.
由于返回的是Livedata<User?>,该变量发生变化,
经过层层传递,最终在activity中被打开并导致了UI发生变化.

希望改进

目前使用的是AndroidViewmodel,而官方的指南中通过依赖注入而实现了使用ViewModel,
虽然不知道是否依赖注入对ViewModel无法销毁导致的内存泄露有什么影响,
但还是希望使用ViewModel

升级数据库

首先需要修改entity

1
2
3
4
5
6
7
8
9
@Entity(tableName = "users")
data class User(
var name: String,
var salary: Int,
// 增加一个字段, NOT NULL, DEFAULT 0
@ColumnInfo(defaultValue = "0") var age: Int
) {
@PrimaryKey(autoGenerate = true) var id: Long = 0
}

和数据库本身的版本

1
2
3
4
5
// 增加版本到2
@Database(entities = [User::class], version = 2)
abstract class AppDatabase : RoomDatabase() {
// ...
}

然后才能使用语句来定义迁移的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
companion object {
private var instance: AppDatabase? = null
// 可先定义好迁移的规则
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
// INTEGER NOT NULL 和 Int类型对应
// DEFAULT 0 和 @ColumnInfo(defaultValue = "0") 对应
// 如果不同会报错
database.execSQL("ALTER TABLE users ADD age INTEGER NOT NULL DEFAULT 0")
}
}

fun getInstance(context: Context): AppDatabase {
if (instance == null) {
instance = Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"sample.db" //数据库名称
).addMigrations(MIGRATION_1_2) // 然后瀑布式升级,在发现数据库版本低的时候才会真正升级
.allowMainThreadQueries().build()
}
return instance as AppDatabase
}
}

TODO 数据库的降级是否需要写一些语句?还是不用管

参考

  1. 基于kotlin的基础用法
  2. 官方指南中的建议
  3. 比较低级但更详细的Repository用法
  4. 官方的数据库升级指南