简介
我们虽然学习SQLite数据库的使用方法,不过当时仅仅是使用了一些原生的API来进行数据的增删改查操作。这些原生API虽然简单易用,但是如果放到大型项目当中的话,会非常容易让项目的代码变得混乱,除非你进行了很好的封装。为此市面上出现了诸多专门为Android数据库设计的ORM框架
ORM (Object Relational Mapping)也叫对象关系映射。简单来讲,我们使用的编程语言是面向对象语言,而使用的数据库则是关系型数据库,将面向对象的语言和面向关系的数据库之间建立一种映射关系,这就是ORM了。
那么使用ORM框架有什么好处呢 ? 它赋予了我们一个强大的功能,就是可以用面向对象的思维来和数据库进行交互,绝大多数情况下不用再和SQL语句打交道了,同时也不用担心操作数据库的逻辑会让项目的整体代码变得混乱。
由于许多大型项目中会用到数据库的功能,为了帮助我们编写出更好的代码,Android官方推出了一个ORM框架,并将它加入了Jetpack当中,就是我们这节即将学习的Room。
使用Room进行增删改查
那么现在就开始吧,先来看一下Room的整体结构。"它主要由Entity、Dao和Database这3部分组成,每个部分都有明确的职责,详细说明如下
Entity。用于定义封装实际数据的实体类,每个实体类都会在数据库中有一张对应的表,并且表中的列是根据实体类中的字段自动生成的。
Dao。Dao是数据访问对象的意思,通常会在这里对数据库的各项操作进行封装,在实际编程的时候,逻辑层就不需要和底层数据库打交道了,直接和Dao层进行交互即可。Database。用于定义数据库中的关键信息,包括数据库的版本号、包含哪些实体类以及提供Dao层的访问实例。
不过只看这些概念可能还是不太容易理解,下面我们结合实践来学习一下Room的具体用法
继续在JetpackTest项目上进行改造。首先要使用Room,需要在app/build.gradle文件中添加如下的依赖:
apply plugin:com.android.application'
apply plugin:'kotlin-android'
apply plugin:'kotlin-android-extensions'
apply plugin:kotlin- kapt'
dependencies {
...
implementation "androidx.room:room-runtime:2.1.0
kapt "androidx.room:room -compiler:2.1.0"
}
这里新增了一个kotlin-kapt插件,同时在dependencies闭包中添加了两个Room的依赖库由于Room会根据我们在项目中声明的注解来动态生成代码,因此这里一定要使用kapt引入Room的编译时注解库,而启用编译时注解功能则一定要先添加kotlin-kapt插件。注意,kapt只能在Kotlin项目中使用,如果是]ava项目的话,使用annotationProcessor即可。
下面我们就按照刚才介绍的Room的3个组成部分一一来进行实现,首先是定义Entity,也就是实体类。
好消息是etpackTest项目中已经存在一个实体类了,就是我们在学习LiveData时创建的User类。然而User类目前只包含firstName、lastName和age这3个字段,但是一个良好的数据库编程建议是,给每个实体类都添加一个id字段,并将这个字段设为主键。于是我们对User类进行如下改造,并完成实体类的声明
@Entitydata
class User(var firstName: String, var lastName: String, var age: Int) {
@PrimaryKey(autoGenerate = true)
var id: Long = 0
}
可以看到,这里我们在User的类名上使用@Entity注解,将它声明成了一个实体类,然后在User类中添加了一个id字段,并使用@PrimaryKey注解将它设为了主键,再把autoGenerate参数指定成t rue,使得主键的值是自动生成的。
这样实体类部分就定义好了,不过这里简单起见,只定义了一个实体类,在实际项目当中,你可能需要根据具体的业务逻辑定义很多个实体类。当然,每个实体类定义的方式都是差不多的,最多添加一些实体类之间的关联。
接下来开始定义Dao,这部分也是Room用法中最关键的地方,因为所有访问数据库的操作都是在这里封装的。
通过第7章的学习我们已经了解到,访问数据库的操作无非就是增删改查这4种,但是业务需求却是千变万化的。而Dao要做的事情就是覆盖所有的业务需求,使得业务方永远只需要与Dao层进行交互,而不必和底层的数据库打交道。
那么下面我们就来看一下一个Dao具体是如何实现的。新建一个UserDao接口,注意必须使用接口,这点和Retrofit是类似的,然后在接口中编写如下代码:
@Dao
interface UserDao {
aInsertfun insertUser(user: User): Long
@Updatefun updateUser(newUser: User)
@Query("select * from User")fun loadATlUsers(): 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语句。
但是如果想要从数据库中查询数据,或者使用非实体类参数来增删改数据,那么就必须编写SOL语句了。比如说我们在UserDao接口中定义了一个loadAllUsers()方法,用于从数据库中查询所有的用户,如果只使用一个@Query注解,Room将无法知道我们想要查询哪些数据因此必须在@Query注解中编写具体的SQL语句才行。我们还可以将方法中传入的参数指定到SOL语句当中,比如loadUsersolderThan()方法就可以查询所有年龄大于指定参数的用户。另外,如果是使用非实体类参数来增删改数据,那么也要编写SQL语句才行,而且这个时候不能使用@Insert、@Delete或Update注解,而是都要使用@Query注解才行,参考deleteUserByLastName()方法的写法
这样我们就大体定义了添加用户、修改用户数据、查询用户、删除用户这几种数据库操作接口,在实际项目中你根据真实的业务需求来进行定义即可。
虽然使用Room需要经常编写SQL语句这一点不太友好,但是SQL语句确实可以实现更加多样化的逻辑,而且Room是支持在编译时动态检查SOL语句语法的。也就是说,如果我们编写的SOL语句有语法错误,编译的时候就会直接报错,而不会将错误隐藏到运行的时候才发现,也算是大大减少了很多安全隐患吧。
接下来我们进入最后一个环节:定义Database。这部分内容的写法是非常固定的,只需要定义好3个部分的内容: 数据库的版本号、包含哪些实体类,以及提供Dao层的访问实例。新建一个AppDatabase.kt文件,代码如下所示 :
@Database(version = l, entities = [User::class])
abstract class AppDatabase : RoomDatabase() {
abstract fun userDao( ): UserDao
companion object {
private var instance: AppDatabase? = null
@Synchronizedfun 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 obiect结构体中编写了一个单例模式,因为原则上全局应该只存在一份AppDatabase的实例。这里使用了instance变量来缓存AppDatabase的实例,然后在getDatabase()方法中判断:如果instance变量不为空就直接返回,否则就调用Room,databaseBuilder()方法来构建一个AppDatabase的实例。databaseBuilder()方法接收3个参数,注意第一个参数一定要使用applicationContext,而不能使用普通的context,否则容易出现内存泄漏的情况,关于applicationContext的详细内容我们将会在第14章中学习。第二个参数是AppDatabase的Class类型,第三个参数是数据库名,这些都比较简单。最后调用build()方法完成构建,并将创建出来的实例赋值给instance变量,然后返回当前实例即可。
然后修改MainActivity中的代码,分别在这4个按钮的点击事件中实现增删改查的逻辑,如下所示:
class MainActivity : AppCompatActivity() {
...
override fun onCreate( savedInstanceState: Bundle?) {
val userDao = AppDatabase.getDatabase(this).userDao()
val userl = 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 {
userl.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和@Delete注解去更新和删除数据时都是基于这个id值来操作的。
然后在“Update Data”按钮的点击事件中,我们将user1的年龄修改成了42岁,并调用UserDao的updateUser0)方法来更新数据库中的数据。在“Delete Data”按钮的点击事件中,我们调用了UserDao的deleteUserByLastName()方法,删除所有lastName是Hanks的用户。在“Query Data”按钮的点击事件中,我们调用了UserDao的loadAllUsers()方法,查询并打印数据库中所有的用户
另外,由于数据库操作属于耗时操作,Room默认是不允许在主线程中进行数据库操作的,因此上述代码中我们将增删改查的功能都放到了子线程中。不过为了方便测试,Room还提供了一个更加简单的方法,如下所示:
Room .databaseBuilder(context.applicationContext, AppDatabase::class.java,"app database")
.allowMainThreadQueries()
.build()
在构建AppDatabase实例的时候,加入一个allowMainThreadOueries()方法,这样Room就允许在主线程中进行数据库操作了,这个方法建议只在试环境下使用。
好了,现在可以运行一下程序了.
Room的数据库升级
当然了,我们的数据库结构不可能在设计好了之后就永远一成不变,随着需求和版本的变更数据库也是需要升级的。不过遗憾的是,Room在数据库升级方面设计得非常烦琐,基本上没有比使用原生的SQLiteDatabase简单到哪儿去,每一次升级都需要手动编写升级逻辑才行。相比之下,我个人编写的数据库框架LitePal则可以根据实体类的变化自动升级数据库,感兴趣的话,你可以通过搜索去了解一下。
不过,如果你目前还只是在开发测试阶段,不想编写那么烦琐的数据库升级逻辑,Room倒也提供了一个简单粗暴的方法,如下所示 :
Room .databaseBuilder(context.applicationContext, AppDatabase::class .java,"app database"
.falTbackToDestructiveMigration()
.build()
在构建AppDatabase实例的时候,加入一个fallbackToDestructiveMigration()方法。这样只要数据库进行了升级,Room就会将当前的数据库销毁,然后再重新创建,随之而来的副作用就是之前数据库中的所有数据就全部丢失了。
假如产品还在开发和测试阶段,这个方法是可以使用的,但是一旦产品对外发布之后,如果造成了用户数据丢失,那可是严重的事故。因此接下来我们还是老老实实学习一下在Room中升级数据库的正规写法。
随着业务逻辑的升级,现在我们打算在数据库中添加一张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 ToadAlTBooks(): 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 primarykey 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 obiect结构体中,我们实现了一个Migration的匿名类,并传入了1和 2这两个参数,表示当数据库版本从1升级到2的时候就执行这个匿名类中的升级逻辑。匿名类实例的变量命名也比较有讲究,这里命名成MIGRATION 1 2,可读性更高。由于我们要新增一张Bok表,所以需要在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: SupportSOLiteDatabase){
database.execSOL("alter table Book add column author text not nulldefault '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语句即可。
文章内容均来自郭霖的《Android第一行代码》第三版