先来看一下Room的整体结构。它主要由Entity、Dao和Database这3部分组成,每个部分都有明确的职责,详细说明如下
继续在JetpackTest项目上进行改造。首先要使用Room,需要在app/build.gradle文件中添加如下的依赖:
plugins {
···
id("org.jetbrains.kotlin.kapt")
}
···
dependencies {
val room_version = "2.5.0"
implementation("androidx.room:room-runtime:$room_version")
implementation("androidx.room:room-ktx:$room_version")
kapt("androidx.room:room-compiler:$room_version")
···
}
下面就按照刚才介绍的Room的3个组成部分一一来进行实现,首先是定义Entity,也就是实体类
好消息是JetpackTest项目中已经存在一个实体类了,就是在了解LiveData时创建的User类。然而User类目前只包含firstName、lastName和age这3个字段,但是一个良好的数据库编程建议是,给每个实体类都添加一个id字段,并将这个字段设为主键
于是对User类进行如下改造,并完成实体类的声明:
@Entity
data class User(var firstName: String, var lastName: String, var age: Int) {
@PrimaryKey(autoGenerate = true)
var id: Long = 0
}
可以看到,这里在User的类名上使用@Entity注解,将它声明成了一个实体类,然后在User类中添加了一个id字段,并使用@PrimaryKey注解将它设为了主键,再把autoGenerate参数指定成true,使得主键的值是自动生成的,这样实体类部分就定义好了
接下来开始定义Dao,这部分也是Room用法中最关键的地方,因为所有访问数据库的操作都是在这里封装的
访问数据库的操作无非就是增删改查这4种,但是业务需求却是千变万化的。而Dao要做的事情就是覆盖所有的业务需求,使得业务方永远只需要与Dao层进行交互,而不必和底层的数据库打交道
新建一个UserDao接口,注意必须使用接口,这点和Retrofit是类似的,然后在接口中编写如下代码:
@Dao
interface UserDao {
@Insert
fun insertUser(user: User): Long
@Update
fun updateUser(newUser: User)
@Query("select * from User")
fun loadAllUsers(): List<User>
@Query("select * from User where age > :age")
fun loadUsersOlderThan(age: Int): List<User>
@Delete
fun deleteUser(user: User)
@Query("delete from User where lastName = :lastName")
fun deleteUserByLastName(lastName: String): Int
}
UserDao接口的上面使用了一个@Dao注解,这样Room才能将它识别成一个Dao。UserDao的内部就是根据业务需求对各种数据库操作进行的封装。数据库操作通常有增删改查这4种,因此Room也提供了@Insert、@Delete、@Update和@Query这4种相应的注解
insertUser()方法上面使用了@Insert注解,表示会将参数中传入的User对象插入数据库中,插入完成后还会将自动生成的主键id值返回
updateUser()方法上面使用了@Update注解,表示会将参数中传入的User对象更新到数据库当中
deleteUser()方法上面使用了@Delete注解,表示会将参数传入的User对象从数据库中删除
以上几种数据库操作都是直接使用注解标识即可,不用编写SQL语句
但是如果想要从数据库中查询数据,或者使用非实体类参数来增删改数据,那么就必须编写SQL语句了。比如在UserDao接口中定义了一个loadAllUsers()方法,用于从数据库中查询所有的用户,如果只使用一个@Query注解,Room将无法知道我们想要查询哪些数据,因此必须在@Query注解中编写具体的SQL语句才行
还可以将方法中传入的参数指定到SQL语句当中,比如loadUsersOlderThan()方法就可以查询所有年龄大于指定参数的用户
另外,如果是使用非实体类参数来增删改数据,那么也要编写SQL语句才行,而且这个时候不能使用@Insert、@Delete或@Update注解,而是都要使用@Query注解才行
接下来进入最后一个部分:定义Database。这部分内容的写法是非常固定的,只需要定义好3个部分的内容:数据库的版本号、包含哪些实体类,以及提供Dao层的访问实例
新建一个AppDatabase.kt文件,代码如下所示:
@Database(version = 1, entities = [User::class])
abstract class AppDatabase : RoomDatabase() {
abstract fun userDao(): UserDao
companion object {
private var instance: AppDatabase? = null
@Synchronized
fun getDatabase(context: Context): AppDatabase {
instance?.let {
return it
}
return Room.databaseBuilder(context.applicationContext,AppDatabase::class.java, "app_database")
.build().apply {
instance = this
}
}
}
}
这里在AppDatabase类的头部使用了@Database注解,并在注解中声明了数据库的版本号以及包含哪些实体类,多个实体类之间用逗号隔开即可
AppDatabase类必须继承自RoomDatabase类,并且一定要使用abstract关键字将它声明成抽象类,然后提供相应的抽象方法,用于获取之前编写的Dao的实例,比如这里提供的userDao()方法。不过只需要进行方法声明就可以了,具体的方法实现是由Room在底层自动完成的
在companion object结构体中编写了一个单例模式,因为原则上全局应该只存在一份AppDatabase的实例。这里使用了instance变量来缓存AppDatabase的实例,然后在getDatabase()方法中判断:
- 如果instance变量不为空就直接返回
- 否则就调用Room.databaseBuilder()方法来构建一个AppDatabase的实例
databaseBuilder()方法接收3个参数,注意第一个参数一定要使用applicationContext,而不能使用普通的context,否则容易出现内存泄漏的情况。第二个参数是AppDatabase的Class类型,第三个参数是数据库名
最后调用build()方法完成构建,并将创建出来的实例赋值给instance变量,然后返回当前实例即可
修改activity_main.xml中的代码,在里面加入用于增删改查的4个按钮
然后修改MainActivity中的代码,分别在这4个按钮的点击事件中实现增删改查的逻辑,如下所示:
class MainActivity : AppCompatActivity() {
...
override fun onCreate(savedInstanceState: Bundle?) {
...
val userDao = AppDatabase.getDatabase(this).userDao()
val user1 = User("Tom", "Brady", 40)
val user2 = User("Tom", "Hanks", 63)
addDataBtn.setOnClickListener {
thread {
user1.id = userDao.insertUser(user1)
user2.id = userDao.insertUser(user2)
}
}
updateDataBtn.setOnClickListener {
thread {
user1.age = 42
userDao.updateUser(user1)
}
}
deleteDataBtn.setOnClickListener {
thread {
userDao.deleteUserByLastName("Hanks")
}
}
queryDataBtn.setOnClickListener {
thread {
for (user in userDao.loadAllUsers()) {
Log.d("MainActivity", user.toString())
}
}
}
}
...
}
这段代码的逻辑还是很简单的。首先获取了UserDao的实例,并创建两个User对象
在“Add Data”按钮的点击事件中,调用了UserDao的insertUser()方法,将这两个User对象插入数据库中,并将insertUser()方法返回的主键id值赋值给原来的User对象
在“Update Data”按钮的点击事件中,将user1的年龄修改成了42岁,并调用UserDao的updateUser()方法来更新数据库中的数据
在“Delete Data”按钮的点击事件中,调用了UserDao的deleteUserByLastName()方法,删除所有lastName是Hanks的用户
在“Query Data”按钮的点击事件中,调用了UserDao的loadAllUsers()方法,查询并打印数据库中所有的用户
运行程序,点击各种按钮,可以在日志里看到对于的内容
数据库结构不可能在设计好了之后就永远一成不变,随着需求和版本的变更,数据库也是需要升级的。不过遗憾的是,Room在数据库升级方面设计得非常烦琐,基本上没有比使用原生的SQLiteDatabase简单到哪儿去,每一次升级都需要手动编写升级逻辑才行
假如随着业务逻辑的升级,现在打算在数据库中添加一张Book表,那么首先要做的就是创建一个Book的实体类,如下所示:
@Entity
data class Book(var name: String, var pages: Int) {
@PrimaryKey(autoGenerate = true)
var id: Long = 0
}
Book类中包含了主键id、书名、页数这几个字段,并且还使用@Entity注解将它声明成了一个实体类
然后创建一个BookDao接口,并在其中随意定义一些API:
@Dao
interface BookDao {
@Insert
fun insertBook(book: Book): Long
@Query("select * from Book")
fun loadAllBooks(): List<Book>
}
接下来修改AppDatabase中的代码,在里面编写数据库升级的逻辑,如下所示:
@Database(version = 2, entities = [User::class, Book::class])
abstract class AppDatabase : RoomDatabase() {
abstract fun userDao(): UserDao
abstract fun bookDao(): BookDao
companion object {
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("create table Book (id integer primary key autoincrement not null, name text not null, pages integer not null)")
}
}
private var instance: AppDatabase? = null
fun getDatabase(context: Context): AppDatabase {
instance?.let {
return it
}
return Room.databaseBuilder(context.applicationContext,AppDatabase::class.java, "app_database")
.addMigrations(MIGRATION_1_2)
.build().apply {
instance = this
}
}
}
}
首先在@Database注解中,将版本号升级成了2,并将Book类添加到了实体类声明中,然后又提供了一个bookDao()方法用于获取BookDao的实例
接下来就是关键的地方了,在companion object结构体中,实现了一个Migration的匿名类,并传入了1和 2这两个参数,表示当数据库版本从1升级到2的时候就执行这个匿名类中的升级逻辑。匿名类实例的变量命名也比较有讲究,这里命名成MIGRATION_1_2,可读性更高
由于要新增一张Book表,所以需要在migrate()方法中编写相应的建表语句。另外必须注意的是,Book表的建表语句必须和Book实体类中声明的结构完全一致,否则Room就会抛出异常
最后在构建AppDatabase实例的时候,加入一个addMigrations()方法,并把MIGRATION_1_2传入即可
现在进行任何数据库操作时,Room就会自动根据当前数据库的版本号执行这些升级逻辑,从而让数据库始终保证是最新的版本
每次数据库升级并不一定都要新增一张表,也有可能是向现有的表中添加新的列。这种情况只需要使用alter语句修改表结构就可以了
现在Book的实体类中只有id、书名、页数这几个字段,想要再添加一个作者字段,代码如下所示:
@Entity
data class Book(var name: String, var pages: Int, var author: String) {
@PrimaryKey(autoGenerate = true)
var id: Long = 0
}
修改AppDatabase中的代码,如下所示:
@Database(version = 3, entities = [User::class, Book::class])
abstract class AppDatabase : RoomDatabase() {
...
companion object {
...
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("alter table Book add column author text not null default 'unknown'")
}
}
private var instance: AppDatabase? = null
fun getDatabase(context: Context): AppDatabase {
...
return Room.databaseBuilder(context.applicationContext,AppDatabase::class.java, "app_database")
.addMigrations(MIGRATION_1_2, MIGRATION_2_3)
.build().apply {
instance = this
}
}
}
}
升级步骤和之前是差不多的,这里先将版本号升级成了3,然后编写一个MIGRATION_2_3的升级逻辑并添加到addMigrations()方法中即可
比较有难度的地方就是每次在migrate()方法中编写的SQL语句,不过即使写错了也没关系,因为程序运行之后在你首次操作数据库的时候就会直接触发崩溃,并且告诉你具体的错误原因,对照着错误原因来改正你的SQL语句即可