13.5--Room

在第7章的时候我们学习了SQLite 数据库的使用方法,不过当时仅仅是使用了一些原生的API来进行数据的增删改查操作。这些原生API虽然简单易用,但是如果放到大型项目当中的话,会非常容易让项目的代码变得混乱,除非你进行了很好的封装。为此失眠上出现了诸多专门为Android 数据库设计的ORM 框架。

ORM (Object Relational Mapping)也叫对应关系映射。.简单来讲,我们使用的编程语言是面向对象语言,而使用的数据库则是关系型数据库,将面向对象的语言和面向关系的数据库之间建立一种影射关系。这就是ORM了。

那么使用ORM框架有什么好处呢?他赋予了我们一个强大的功能,就是可以用面向对象的思维来和数据库进行交互,绝大多数情况下不用再和SQL语句打交道了,同时也不用担心操作数据库的逻辑会让项目的整体代码变得混乱。

由于许多大型项目中会用到数据库的功能。为了帮助我们编写出更好的代码,Anroid官方推出了一个ORM框架,并将它加入了Jetpack当中。这就是我们这些即将学习的Room。

 

13.5.1 使用Room进行增删改查

那么现在就开始吧,先来看一下Room的整体结构。它主要由Entity、Dao 和 Database 这3个部分组成,每个部分都有明确的职责,详细说明如下。

Entity。用于定义封装实际数据的实体类,每个实体类都会在数据库中有一张对应的表,并且表中的列式根据实体类中的字段自动生成的。

Dao。Dao 是数据库访问对象的意思,通常会在这里对数据库的各项操作进行封装,在实际变成的时候,逻辑层就不需要和底层数据库打交道的。直接和Dao 层进行交互即可。

Database。用于定义数据库中的关系信息,包括数据库的版本号、包含哪些实体类以及提供的Dao层的访问实例。

我只看这些概念可能还是不太容易理解,下面我们结合时间来学习一下Room的具体用法。

继续在JetpackTest项目上进行改造。首先要使用Room,需要在app/build.grable 文件中添加如下的依赖:

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 项目中使用。如果是Java 项目的话,使用annotationProcessor 即可。

下面我们就按照刚才介绍的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 用法中最关键的地方,因为所有访问数据库的操作都是在这里封装的。

通过第7 章的学习我们已经了解到,访问数据库的操作无非就是增删改查这4种,但是业务需求却是千变万化的。而Dao 要做的事情就是覆盖所有的业务需求,使得业务方永远只需要与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 注解才行,参考deleteUserByLastName() 方法的写法

这样我们就大体定义了添加用户、修改用户数据、查询用户、删除用户这几种数据库操作接口,在实际项目中你根据真实的业务需求来进行定义即可。

虽然使用Room 需要经常编写SQL 语句这一点不太友好,但是SQL 语句确实可以实现更加多样化的逻辑,而且Room 是支持在编译时动态检查SQL 语句语法的。也就是说,如果我们编译写的SQL语句语法错误,编译的时候就会直接报错,而不会将错误隐藏到运行的时候才发现,也算是打打减少了很多安全隐患吧。

接下来我们进入最后一个环节:定义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 类,并且一定要使用了abstrack 关键字将它声明成抽象方法,然后提供相应的抽象方法,用于获取之前编写的Dao 的实例,比如这里提供的userDao() 方法。不过我们只需要进行方法声明就可以了,具体的方法。不过我们只需要进行方法声明就可以了,具体的方法实现是由Room 在底层自动完成的

紧接着,我们在companion object 结构体中编写了一个单例模式,因为原则上全局应该只存在一份AppDatabase 的实例。这里使用了instance 变量来缓存 AppDatabase 的实例,然后在getDatabase() 方法中判断:

如果Instance 变量不为空就直接返回,否则就调用Room.databaseBuilder() 方法来构建一个AppDatabase 的实例。databaseBuilder() 方法接收3个参数,注意第一个参数一定要使用applicationContext ,而不能使用普通的context ,否则容易出现内存泄漏的情况,关于applicationContext 的详细内容我们将会在第14 章中学习。第二个参数是AppDatabase 的Class 类型,第三个参数是数据库名,这些都比较简单。最后启动build() 方法完成构建,并将创建出来的实例赋值给instance 变量,然后返回当前实例即可。

这样我们就把Room 所需要的一切都定义好了,接下来要做的事情就是对它进行测试。修改activity_main.xml 中的代码,在这里加入用于增删改查的4个按钮:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    tools:context=".MainActivity">

    ...
    <Button
        android:id="@+id/addDataBtn"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:text="Add Data"/>

    <Button
        android:id="@+id/updateDataBtn"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:text="Update Data"/>

    <Button
        android:id="@+id/deleteDataBtn"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:text="Delete Data"/>

    <Button
        android:id="@+id/queryDataBtn"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:text="Query Data"/>

    <Button
        android:id="@+id/doWorkBtn"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:text="Do Work"/>
</LinearLayout>

然后修改MainActivity 中的代码,分别在4个按钮的点击事件中实现增删改查的逻辑,如下所示:

class MainActivity : AppCompatActivity() {
    ...
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        ...
        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 和 @Delete 注解去更新和删除数据时都是基于这个id 值来操作的。

然后在“Update Data” 按钮的点击事件中,我们将user1 的年龄修改成了42 岁,并调用UserDao 的 updateUser() 方法来更新数据库中的数据。在“Delete Data”按钮的点击事件中,我们调用了UserDao 的deleteByLastName() 方法,查询并打印数据库中所有的用户。

另外,由于数据库操作属于耗时操作,Room 默认是不允许在主线程中进行数据库操作的,上述代码中我们将增删改查的功能都放到了子线程中。不过为了方便测试,Room,还提供了一个更加简单的方法,如下所示:

Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "app_database")
                .allowMainThreadQueries()
                .build().

在构建AppDatabase实例的时候,加入一个allowMainThreadQueries() 方法,这样Room就允许在主线程中进行数据库操作了,这个方法建议只在测试环境下使用

好了,现在可以运行一下程序了,界面如图所示。

然后开始点击“Add Data”按钮,再点击"Query Data"按钮,查看Logcat中的日志打印,如图所示:

由此可以证明,两条用户数据都已经被成功插入数据库当中了。

接下来点击“Update Data” 按钮,再重新点击 “Query Data”按钮,Logcat中的日志打印,如图所示:

可以看到,第一条数据中用户的年龄成功被修改成了42岁。

最后点击"Delete Data"按钮,再次点击按“Query Data”钮,Logcat中的打印日志如图所示:

可以看到,现在只剩下一条用户数据了。

将Room 的用法体验一遍之后,不知道你有什么感觉呢?或许你觉得Room 使用起来太过于烦琐,要先定义Entity,再定义Dao,最后定义Database ,还不如直接使用原生的SQLiteDatabase 来的方便。但是你有没有察觉,一旦上述3部分内容都定义好了之后,你就只需要使用面向对象的思维去编写程序,而完全不用考虑数据库相关的逻辑和实现了。在大型项目当中,使用Room 成为现在Android 官方最为推荐使用的数据库框架。

 

13.5.2 Room 的数据库升级

当然了,我们的数据库结构不可能在设计好了之后就永远一成不变,随着需求和版本的变更,数据库也是需要升级的。不过遗憾的是,Room 在数据库升级方面设计得非常烦恼,基本上没有比原生的SQLiteDatabase 简单到哪去,每一次升级都需要手动编写升级逻辑才行。相比之下,我个人编写(郭霖)的数据库框架LitePal 则可以根据实体类的变化自动升级数据库,感兴趣的话,你可以通过搜索去了解一下。

不过,如果你目前还只是在开发测试阶段,不想编写那么烦琐的数据库升级逻辑,Room 倒也提供了一个简单粗暴的方法,如下所示:

Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "app_database")
                .fallbackToDestructiveMigration()
                .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 loadAllBooks():List<Book>
}

接下来修改AppDatabase中的代码,在里面编写数据库升级的逻辑,如下所示:

@Database(version = 1,entities = [User::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

        @Synchronized
        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的实例。

接下来就是关键的地方了,在commpanion object 结构体中,我们实现了一个Migration匿名类,用传入了1和2这两个参数,表示当数据库版本从1升级到2的时候就执行这个匿名类中的升级逻辑。匿名类实例的变量名也比较有讲究,这里命名成MIGRATION_1_2,可读性更高。所以我们要新增一张Book表,所以需要在migrate() 方法中编写相应的建表语句。另外,必须要注意的是,Book表的建表语句必须和Book 实体类中声明的结果完全一致,否则Room 就会抛出异常。

最后在构建AppDatabase 实例的时候,加入一个addMigations() 方法,并把MIRATION_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() {
    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)")
            }

        }
        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

        @Synchronized
        fun getDatabase(context: Context): AppDatabase {
            instance?.let {
                return it
            }
            return Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "app_database")
                .addMigrations(MIGRATION_1_2, MIGRATION_2_3)
                .build().apply {
                    instance = this
                }
        }
    }
}

升级步骤和之前是差不多的,这里先将版本号升级成了3,然后编写一个MIRATION_2_3 的升级逻辑并添加到addMirations() 方法中即可。比较有难度的地方就是每次在migrate() 方法中编写SQL 语句,不过即使写错了也没关系,因为程序运行之后你在首次操作数据库的时候就会直接触发崩溃,并且告诉你具体的错误原因,对照着错误原因来改正你的SQL 语句即可。

好了,关于Room 你已经了解足够多的内容了,接下来就让我们开始学习本章的最后一个Jetpack 组件 —— WorkManager。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值