SQLite
数据库是使用了一些原生的API
来进行数据的增删改查操作。这些原生API
虽然简单易用,但是如果放到大型项目当中的话,会容易让项目的代码变得混乱,除非进行了很好的封装。为此出现了诸多专门为Android
数据库设计的ORM
框架。
ORM(Object Relational Mapping)
也叫对象关系映射。简单来讲,我们使用的编程语言是面向对象语言,而使用的数据库则是关系型数据库,将面向对象的语言和面向关系的数据库之间建立一种映射关系,这就是ORM
了。
relational [rɪˈleɪʃənl] 相关的;亲属的 mapping [ˈmæpɪŋ] 映射,映现
那么使用ORM
框架有什么好处呢?它赋予了我们一个强大的功能,就是可以用面向对象的思维来和数据库进行交互,绝大多数情况下不用再和SQL
语句打交道了, 同时也不用担心操作数据库的逻辑会让项目的整体代码变得混乱。
由于许多大型项目中会用到数据库的功能,为了帮助我们编写出更好的代码,Android
官方推出 了一个ORM
框架,并将它加入了Jetpack
当中,就是Room
。
Room
的优点:
@Query
和@Entity
里面的SQL
语句等进行验证;SQL
语句的使用更加贴近,能够降低学习成本;RxJava 2
的支持(大部分都Android
数据库框架都支持),对LiveData
的支持;@Embedded
能够减少表的创建;简单来说:Room
是一个基于SQLite
的强大数据库框架。
Room
进行增删改查Room
主要由Entity
、Dao
和Database
这3
部分组成,每个部分都有明确的职责,详细说明如下:
@Entity
:用于定义封装实际数据的实体类,每个实体类都会在数据库中有一张对应的表,并且表中的列是根据实体类中的字段自动生成的;@Dao
:数据访问对象,通常会在这里对数据库的各项操作进行封装,在实际编程的时候,逻辑层就不需要和底层数据库打交道了,直接和Dao
层进行交互即可;@Database
:用于定义数据库中的关键信息,包括数据库的版本号、包含哪些实体类以及提供Dao
层的访问实例;entity [ˈentəti] 实体
要使用Room
,需要在app/build.gradle
文件中添加 如下的依赖:
plugins {
...
id 'kotlin-kapt'
}
dependencies {
...
implementation "androidx.room:room-runtime:2.2.6"
implementation "androidx.room:room-ktx:2.2.6"
kapt "androidx.room:room-compiler:2.2.6"
androidTestImplementation "androidx.room:room-testing:2.2.6"
}
这里新增了一个kotlin-kapt
插件,同时在dependencies
中添加了两个Room
的依赖库。 由于Room
会根据我们在项目中声明的注解来动态生成代码,因此这里一定要使用kapt
引入Room
的编译时注解库,而启用编译时注解功能则一定要先添加kotlin-kapt
插件。注意,kapt
只能在Kotlin
项目中使用,如果是Java
项目的话,使用annotationProcessor
即可。
定义@Entity
,也就是实体类。 一个良好的数据库编程建议是,给每个实体类都添加一个id
字段,并将这个字段设为主键。以下是实体类的声明:
@Entity
data class User(
var uname: String,
var sex: Int,
var age: Int,
var city: String
) {
@PrimaryKey(autoGenerate = true)
var id: Long = 0
}
在User
的类名上使用@Entity
注解,将它声明成了一个实体类,然后在User
类中添加了一个id
字段,并使用@PrimaryKey
注解将它设为了主键,再把autoGenerate
参数指定成true
,使得主键的值是自动生成的。
这样实体类部分就定义好了。在实际项目当中,可能需要根据具体的业务逻辑定义很多个实体类。当每个实体类定义的方式都是差不多的,最多添加一些实体类之间的关联。
Dao
这部分是Room
用法中最关键的地方,因为所有访问数据库的操作都是在这里封装的。 访问数据库的操作无非就是增删改查这4
种,但是业务需求却是千变万化的。而Dao
要做的事情就是覆盖所有的业务需求,使得业务方永远只需要与Dao
层进行交互,而不必和底层的数据库打交道。
下面是一个Dao
具体是如何实现的。新建一个UserDao
接口,注意必须使用接口,然后在接口中编写如下代码:
@Dao
interface UserDao {
@Insert
fun insertUser(user: User): Long
@Update
fun updateUser(newUser: User)
@Query("select * from User")
fun queryAllUsers(): List<User>
@Query("select * from User where age > :age")
fun queryOlderThan(age: Int): List<User>
@Delete
fun deleteUser(user: User)
@Query("delete from User where uname = :uname")
fun deleteUserByUName(uname: 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
接口中定义了一个queryAllUsers()
方法,用于从数据库中查询所有的用户,如果只使用一个@Query
注解,Room
将无法知道我们想要查询哪些数据, 因此必须在@Query
注解中编写具体的SQL
语句才行。我们还可以将方法中传入的参数指定到SQL
语句当中,比如queryOlderThan()
方法就可以查询所有年龄大于指定参数的用 户。另外,如果是使用非实体类参数来增删改数据,那么也要编写SQL
语句才行,而且这个时候不能使用@Inser
t、@Delete
或@Update
注解,而是都要使用@Query
注解才行,如deleteUserByUName()
方法的写法。
这样我们就大体定义了添加用户、修改用户数据、查询用户、删除用户这几种数据库操作接口,在实际项目中根据真实的业务需求来进行定义即可。
虽然使用Room
需要经常编写SQL
语句这一点不太友好,但是SQL
语句确实可以实现更加多样化的逻辑,而且Room
是支持在编译时动态检查SQL
语句语法的。 也就是说,如果我们编写的SQL
语句有语法错误,编译的时候就会直接报错,而不会将错误隐藏到运行的时候才发现,也算是大大减少了很多安全隐患吧。
接下来是定义Database
。这部分内容的写法是非常固定的,只需要定义好3
个部分的内容:数据库的版本号、包含哪些实体类,以及提供Dao
层的访问实例。 新建一个UserDatabase
文件,代码如下所示:
@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
变量,然后返回当前实例即可。
使用:
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_stack)
val userDao = AppDatabase.getDatabase(this).userDao()
val user1 = User("萧峰", 1, 31, "南京")
val user2 = User("段誉", 1, 24, "大理")
val user3 = User("慕容复", 1, 24, "大理")
addDataBtn.setOnClickListener {
thread {
user1.id = userDao.insertUser(user1)
user2.id = userDao.insertUser(user2)
user3.id = userDao.insertUser(user3)
}
}
updateDataBtn.setOnClickListener {
thread {
user1.age = 32
userDao.updateUser(user1)
}
}
deleteDataBtn.setOnClickListener {
thread {
userDao.deleteUserByUName("慕容复")
}
}
queryDataBtn.setOnClickListener {
thread {
for (user in userDao.queryAllUsers()) {
Log.e("CAH", "queryAllUsers: ${user.toString()}")
}
}
}
}
}
这段代码的逻辑很简单的。首先获取了UserDao
的实例,并创建三个User
对象。然后在Add Data
按钮的点击事件中,调用了UserDao
的insertUser()
方法,将这三个User
对象插入数据库中,并将insertUser()
方法返回的主键id
值赋值给原来的User
对象。
之所以要这么做,是因为使用@Update
和@Delete
注解去更新和删除数据时都是基于这个id
值来操作的。
然后在Update Data
按钮的点击事件中,将user1
的年龄修改成了32
岁,并调用UserDao
的updateUser()
方法来更新数据库中的数据。在Delete Data
按钮的点击事件中,调用了UserDao
的deleteUserByUName()
方法,删除uname
为慕容复
的用户。在Query Data
按钮的点击事件中,调用了UserDao
的queryAllUsers()
方法,查询并打印数据库中所有的用户。
另外,由于数据库操作属于耗时操作,Room
默认是不允许在主线程中进行数据库操作的,因此 上述代码中我们将增删改查的功能都放到了子线程中。不过为了方便测试,Room
还提供了一个 更加简单的方法,如下所示:
Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "app_database").allowMainThreadQueries().build()
在构建AppDatabase
实例的时候,加入一个allowMainThreadQueries()
方法,这样Roo
就允许在主线程中进行数据库操作了,这个方法建议只在测试环境下使用。
运行,发现出现以下问题:
Schema export directory is not provided to the annotation processor so we cannot export the schema. You can either provide room.schemaLocation annotation processor argument OR set exportSchema to false.
这是因为,Room
会将数据库的架构信息导出为JSON
文件(默认exportSchema = true
导出架构)。导出架构,需要在build.gradle
文件中设置room.schemaLocation
注释处理器属性(设置将JSON
存放的位置)。如果没有设置exportSchema = false
不导出架构或者没有设置架构导出的位置,所以构建错误。
解决方法:
build gradle
中添加(推荐)android {
...
defaultConfig {
...
javaCompileOptions {
annotationProcessorOptions {
arguments = ["room.schemaLocation":
"$projectDir/schemas".toString()]
}
}
}
}
exportSchema = false
(不推荐)@Database(entities = {entity.class}, version = 4, exportSchema = false)
运行程序:
然后点击Add Data
按钮,再点击Query Data
按钮,查看Logcat
中的打印日志,如图所示:
// CAH: queryAllUsers: User(uname=萧峰, sex=1, age=31, city=南京)
// CAH: queryAllUsers: User(uname=段誉, sex=1, age=24, city=大理)
// CAH: queryAllUsers: User(uname=慕容复, sex=1, age=24, city=大理)
由此可以证明,三条用户数据都已经被成功插入数据库当中了。
接下来点击Update Data
按钮,再重新点击Query Data
按钮,Logcat
中的打印日志如图所示:
// CAH: queryAllUsers: User(uname=萧峰, sex=1, age=32, city=南京)
// CAH: queryAllUsers: User(uname=段誉, sex=1, age=24, city=大理)
// CAH: queryAllUsers: User(uname=慕容复, sex=1, age=24, city=大理)
可以看到,第一条数据中用户的年龄被成功修改成了32
岁。
最后点击Delete Data
按钮,再次点击Query Data
按钮,Logcat
中的打印日志如图所示:
// CAH: queryAllUsers: User(uname=萧峰, sex=1, age=32, city=南京)
// CAH: queryAllUsers: User(uname=段誉, sex=1, age=24, city=大理)
可以看到,现在只剩下一条用户数据了。
以上就是Room
的用法。
Room
的数据库升级数据库结构不可能在设计好了之后就永远一成不变,随着需求和版本的变更,数据库也是需要升级的。不过遗憾的是,Room
在数据库升级方面设计得非常烦琐,基本上没有比使用原生的SQLiteDatabase
简单到哪儿去,每一次升级都需要手动编写升级逻辑才行。
不过,如果如果只是在开发测试阶段,不想编写那么烦琐的数据库升级逻辑,Room
有一个简单粗暴的方法,如下所示:
Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "app_database")
.fallbackToDestructiveMigration()
.build()
在构建AppDatabase
实例的时候,加入一个fallbackToDestructiveMigration()
方法。这样只要数据库进行了升级,Room
就会将当前的数据库销毁,然后再重新创建,随之而来的副作用就是之前数据库中的所有数据就全部丢失了。
假如产品还在开发和测试阶段,这个方法是可以使用的,但是一旦产品对外发布之后,如果造成了用户数据丢失,那可是严重的事故。因此接下来学习一下在Room
中升级数据库的正规写法。
随着业务逻辑的升级,现在打算在数据库中添加一张Course
表,那么首先要做的就是创建一 个Course
的实体类,如下所示:
@Entity
data class Course(var subject: String, var teacher: String, var time: Long) {
@PrimaryKey(autoGenerate = true)
var id: Long = 0
}
可以看到,Course
类中包含了主键id
、学科、老师,时间这几个字段,并且还使用@Entity
注解将它声明成了一个实体类。
然后创建一个CourseDao
接口,并在其中随意定义一些API
:
@Dao
interface CourseDao {
@Insert
fun insertCourse(course: Course): Long
@Query("select * from Course")
fun queryAllCourses(): List<Course>
}
接下来修改AppDatabase
中的代码,在里面编写数据库升级的逻辑,如下所示:
@Database(version = 2, entities = [User::class, Course::class])
abstract class AppDatabase : RoomDatabase() {
abstract fun userDao(): UserDao
abstract fun courseDao(): CourseDao
companion object {
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("create table Course (id integer primary key autoincrement not null, subject text not null, teacher text not null, time 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
,并将Course
类添加到了实体类声明中,然后又提供了一个courseDao()
方法用于获取CourseDao
的实例。
接下来就是关键的地方了,在companion object
结构体中,实现了一个Migration
的匿名类,并传入了1
和2
这两个参数,表示当数据库版本从1
升级到2
的时候就执行这个匿名类中的升级逻辑。匿名类实例的变量命名也比较有讲究,这里命名成MIGRATION_1_2
,可读性更高。由于我们要新增一张Course
表,所以需要在migrate()
方法中编写相应的建表语句。另外必须注意的是,Course
表的建表语句必须和Course
实体类中声明的结构完全一致,否则Room
就会抛出异常。
最后在构建AppDatabase
实例的时候,加入一个addMigrations()
方法,并把MIGRATION_1_2
传入即可。
现在当我们进行任何数据库操作时,Room
就会自动根据当前数据库的版本号执行这些升级逻辑,从而让数据库始终保证是最新的版本。
不过,每次数据库升级并不一定都要新增一张表,也有可能是向现有的表中添加新的列。这种情况只需要使用alter
语句修改表结构就可以了,下面是具体的操作过程。
现在Course
的实体类中只有id
、学科、老师、时间这几个字段,如果想要再添加一个班级字段,代码如下所示:
@Entity
data class Course(var subject: String, var teacher: String, var time: Long, var className: String) {
@PrimaryKey(autoGenerate = true)
var id: Long = 0
}
既然实体类的字段发生了变动,那么对应的数据库表也必须升级了,所以这里修改AppDatabase
中的代码,如下所示:
@Database(version = 3, entities = [User::class, Course::class])
abstract class AppDatabase : RoomDatabase() {
abstract fun userDao(): UserDao
abstract fun courseDao(): CourseDao
companion object {
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("create table Course (id integer primary key autoincrement not null, subject text not null, teacher text not null, time integer not null)")
}
}
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("alter table Course add column className 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
,然后编写一个MIGRATION_2_3
的升级逻辑并添加到addMigrations()
方法中即可。比较有难度的地方就是每次在migrate()
方法中编写的SQL
语句,不过即使写错了也没关系,因为程序运行之后在你首次操作数据库的时候就会直接触发崩溃,并且告诉你具体的错误原因,对照着错误原因来改正你的SQL
语句即可。