将Room的使用方式塞到脑子里
Room简介
Room是一个数据库框架,但它不是自己去实现的数据库,而是操作sqlite数据库,所以也可以称它为数据库封装框架。
对于使用者而言,仅需几个注解几个文件就能实现对数据库的操作,还是很方便的。并且由于采用的是编译时处理注解生成文件的方式,所以基本上不会有什么性能的损失。并且Room与协程也是无缝连接的,使用起来极其方便。
依赖添加
Room需要使用注解处理器,在kotlin项目中需要加上kotlin-kapt插件。
1 | |
定义表结构
在Room中,我们不需要手动去创建表,而是定义一个实体类并且使用@Entity注解。这样Room就会根据类的字段去创建相应的数据库表,注意每个表必须都有一个主键。
1 | |
如上的一个Person类,就会在数据库中生成一个Person表,两个字段分别叫id和name,并且都是非空类型。也就是说,默认情况下,表的名字和对应的类名是一致的,列的名称也是与字段的名称是一致的。
并且,由于Kotlin有非空检查,所以创建的表的字段也会对应的是否可空。如上面的对象生成的Person表,其中name列就是not null 的。若是使用Java声明的类,则name默认就会是可空的,除非给name字段加上NotNull注解。而id因为是主键,所以一定是不为空的。
表属性
表的属性如表名,主键,外键等也是可以定制的,而不是一直固定死的。
表名称
默认情况下,表名与类名保持一致。如上面的Person类对应的表名也是Person。可以通过@Entity的tableName属性进行修改。如下面的代码,则Person对应的表名就是my_person
1 | |
列名
默认情况下,列名与字段名也是保持一致的。如上面的name字段对应的列名就是name。可以通过@ColumnInfo的name属性进行修改(ColumnInfo有很多属性,这里先只说name属性)。如下例,则是将列名改成person_name。
1 | |
忽略的属性
在实体类中,有些字段可能是不想要映射在数据库的表中的,此时可以使用@Ignore注解某个不需要的字段,这样实体类对应的数据库表中就不会有该字段对应的列了。如下,则Person表中是没有sex列的。
1 | |
注意上面的代码是错误的,只是用来演示的。在实体类中,要求每个字段都是能够访问的,并且要有不包含@Ignore的字段。如上例,构造方法中有了sex参数,所以会编译报错。并且,每个对应数据库列的字段都必须有getter/setter方法,其中getter是必须有的,而setter可以没有,但是没有setter的参数必须出现在构造方法中,也就是必须得提供一个注入的入口。
所以遇到@Ignore的参数,可以这样声明:
1 | |
从上面三种方式中,还是第一种方式比较好,首先比较简单,其次将二者分开了会显得更清晰。另外上面说的都是data class,而普通的类也是可以的:
1 | |
注意上面的普通类没有提供构造方法,也就是默认的构造方法,这时候id和name必须设置为var类型,因为这样才会自动生成getter/setter方法。
主键
每个表必须有一个主键,主键是唯一的,不能重复。一个表中的主键可以不只是一个字段,而可以由多个字段组成复合主键。有两种方式可以设置主键,一种是使用@PrimaryKey,一种是使用@Entity的primaryKeys属性设置。
1 | |
这两种的设置都能把id列设为主键,但是看起来还是第一种方式比较方便看着也清晰,所以一般使用第一种方式,直接将@PrimaryKey注解在对应的字段上即可。
但是第一种方式只能设置简单主键,也就是只有一个列是主键的情况。对于复合主键,则必须通过第二种方式去设置了。
主键还可以是自增的,将autoGenerate属性设为true即可,此时id可以设置也可以不设置,不设置则自动递增。但是这种情况下主键必须是Int或者Long类型,这样insert的时候,若是不带入主键,则自动递增设置值,注意这种情况下,主键要设置为可空的,然后在插入的时候赋值为null。递增是从1开始的,每次插入的时候会从最高的值开始递增。例如有两条数据,id分别是1和100,则下次插入数据的id则是101。
1 | |
索引和唯一列
索引是数据库表中的一列或者多个列构成的一个排序的结构,当查询的时候,可以通过索引查询出位置,然后得到结果而不需要遍历原来的表数据来匹配结果。所以使用索引可以加快查询的速度。
可以将索引当成一个数据库表,存储着对应的数据以及相应位置的引用。但是这个表是给数据库管理系统使用的,不是给我们使用的,用户就正常执行相应的SQL语句,然后由数据库去进行优化选择是否查询索引。
创建索引需要消耗一定的存储空间,并且会拖慢更新表的操作,因为当插入或者修改表的时候也会更新索引表,但是好处是查询的速度大大增加(数据量很大的时候)。
而我们在手机本地存的数据显然不会很多,所以基本用不到索引。在Room中可以通过两种方式去创建索引。
1 | |
方式一比较方便,直接在对应的字段上注解ColumnInfo并且设置index属性为true即可。但是这种方式只能设置单列的索引。若是多个注解,则会生成多个索引,而非多列的索引。
方式二比较强大,是通过Entity的indices属性去创建索引。indices是一个数组,可以设置多个索引,索引通过Index去配置。
1 | |
上述的代码创建了两个索引,一个是name的索引,一个数name和childId的索引,像这种需要多个列的索引,用ColumnInfo是无法完成的。
索引还可以设置为unique,也就是索引不能重复。若是单列的索引,则该列的数据不能重复,若是多列的索引,则组合不能重复。 可以让某个列像是主键一样。
1 | |
默认值
列中字段是可以设置默认值的,当insert的时候,若是没有插入该列,则会自动使用默认值去填充。
1 | |
实际上,通过Room的Dao进行正常插入的时候,是无法使用到默认值的。因为kotlin是有非空检测的,因此不允许在name和childId字段传值为null。而若是将这两个字段设置为可空的话,对应的表的列属性也是可为NULL的,这时候传入null的话,表中的对应列也会是NULL,而不会去应用默认值。
所以,想要使用默认值,要么使用Java语言操作(定义实体类时使用@NotNull注解字段,然后传入null),要么使用@Query直接执行插入的SQL语句。涉及到的DAO部分会在后面讲解。
1 | |
外键
外键只能通过Entity的foreignKeys属性去设置,类型为ForeignKey。
1 | |
在上面的代码中,Person表中的childId是一个外键,引用User表中的uid列。这些都是在ForeignKey中配置的。首先参数entity指向外键引用的表对应的实体类,parentColumns指的是引用的表中的列,而clildColumns指的是本表中引用的列名。
其中parentColumns和childColumns都是数组类型的,二者的数量必须是对应的。并且对于外键引用的列,一般引用另一个表的主键,并且最好设置为索引index。若是引用的不是主键,则必须将引用列设置为索引并且unique。
外键约束
具有外键的表操作具有级联属性,也就是当被引用的表修改时,引用的表也会同步修改。可以通过ForeignKey中的onDelete和onUpdate属性来设置约束操作。
1 | |
如上例,Person表中的user列是一个外键,引用了User表中的uid列,并且设置了onDelete的操作为级联CASCADE。所以当User表发生删除事件后,也会对Person表中引用的数据进行删除。
1 | |
如上,Person表中两条数据的外键都是引用的User中的那条数据,所以当User表中的数据删除后,Person表中的两条引用数据也会自动删除,这就是CASCADE的效果。一共五种约束操作:
NO_ACTION没有操作,也就是默认约束,当删除User中那条数据的时候,由于被Person中的数据引用着,所以直接抛出异常RESTRICT和NO_ACTION一样,不同是RETRICT在字段修改或删除的时候就去检查外键约束,而不是等语句执行完SET_NULL将外键引用的表的字段设为NULL。如上表,当删除User中的数据时,Person表中的两条数据的user列的值都会被设置为NULL。当然,上面我们定义实体类的时候属性user:Int是非空的,所以实际删除的时候会抛出异常,要实现这种操作,必须将对应的外键的类型设置为可空的user:Int?。SET_DEFAULT将外键的引用表的字段设置为默认值。需要注意的是,默认值必须也是存在于被引用的表中的字段,也就是User中的另一条数据。所以,这种情况下,被引用表User表至少要有一条数据,用于被Person表设置默认值,否则会因为找不到引用数据而抛出异常。CASCADE级联操作,也是最常用的约束关系,当User表删除数据的时候,引用这条数据的Person表中的两条数据都会删除。修改User表的这条数据的uid的时候,Person表中的user字段也会改成新修改的值。
表的数据类型
基本数据类型
Room只支持八大基本数据类型和String类型。
Byte,Short,Int,Long表中被当做INTEGER存储Boolean表中被当做INTEGER存储,0为false,1为trueChar表中被当做INTEGER存储,记录的值是其ACSII码Float,Double表中被当做REAL类型存储String表中被当做TEXT类型存储
嵌套对象
也就是说,在定义表对应的实体对象的时候,字段默认是只能使用这九种数据类型的,若是使用了其他的类型,则在编译期间就会报错。
Room还提供了嵌套对象的注解@Embedded,可以将嵌套对象展开,作为当前表的字段。
1 | |
如上面的代码,则生成的Person表中,一共有三列,分别是id,uid,sex可以看到直接将User类中的字段展开到了Person表中。这种展开是有限制的,就是名称不能重复,比如Person中有个字段叫做id,则User表中不能有id这个字段。
其中User这个类可以是普通的类,也可以是一个被@Entity的数据库表的实体类。
类型转换
类型转换就是添加相应的TypeConverter,然后在操作数据库表的时候,Room就会根据TypeConverter将对应的字段转换成目标类型,然后在进行数据库操作。
1 | |
如上例,就是将Date类型和Long类型互相转换的转换器,使用TypeConverter注解来注解方法,表示该方法用来转换的。转换器可以是单例对象object class 也可以是普通的对像。当添加该转换器后,就可以定义具有Date字段的数据库映射对象了。
1 | |
注意,类型转换器只有一个参数,而且必须有返回值。参数和返回值构成了一组转换,并且对于一种类型,必须提供两个方法用于互相转换。至于怎么添加类型转换器,则放在后面再说。
表的关系
表的关系通常有三种,一对一,一对多,多对多。
一对一
一对一是指两张表之间,一条数据只对应一条数据。这种关系比较简单,一般用于对表数据的拓展。两张表可以通过外键进行连接,外键不要单独使用一个列,这样容易行成多对多的关系。而是应该将一张表的主键设为另一张表的外键,从而构成一对一的关系。
1 | |
如上面就是设计了两张一对一关系的表,一个Person只能对应一个PersonAddress,同理一个PersonAddress也只能对应一个Person。二者通过外键链接,并且外键也被设置为了主键,同时设置为级联操作,当删除主表Person中的数据后,拓展表PersonAddress中对应的数据也会被删除。这是一个很标准的一对一关系的设计方式。
查询方式
这种有关系的表的查询比较麻烦,需要额外定义一个类,用来存放查询结果。这个类不需要使用@Entity注解,因为它不对应数据库表,只是一个查询结果的容器。
1 | |
其中,主要的字段使用@Embedded注解,其他字段使用@Relation来声明两个表之间的关系。其中Relation至少要写两个属性,parentColumn属性是@Embedded修饰类的字段,而 entityColumn属性则是当前类的字段。当查询到Person之后,会根据Person.id字段作为PersonAddress.pid字段去查询结果。所以会经历两个查询过程,因此还需要加上事务的注解@Transaction。
1 | |
若是想要查询以PersonAddress为主的话,则需要另外定义一个容器类:
1 | |
一对多
一对多关系也是用外键形成的,但是这种情况下,外键不能用主键了,而应该使用一个独立的列。
1 | |
同样的道理,这种多表之间的关系都是需要使用独立的类去进行存储的:
1 | |
注意一点的是,City与Person是多对一的关系,因此CityWithPersons对象的persons属性需要写成List集合。
多对多
对对多的关系需要使用第三个表来进行关联两个表。这里借用官网的例子,音乐表和播放列表表。关系是一首音乐可以存在多个播放列表中,一个播放列表中也可以有多首音乐。
首先定义两个表:
1 | |
然后声明第三张表来定义关系,第三张表将前两张表的主键集合在一起,作为一张表,然后设置两个外键分别对应原来的两张表,同时也将这两列作为组合主键,避免数据重复。注意一点,第三张表的两个列名必须和引用的两个表的引用列名相同。:
1 | |
这样就完成了两个表的多对多关系的建立,每当有歌被添加到播放列表的时候,就可以向SongPlayList中添加一条数据即可。
对于查询还是一样要借助一个新的对象进行存储:
1 | |
和前面基本上是一样的,唯一一点差别就是在@Relation的时候,额外加了一个属性associateBy,用来指示联系两张表的第三张表。
创建DAO
DAO(Data Access Object) 数据访问对象是一个面向对象的数据库接口
说白了DAO就是一个接口,里面定义了多个方法,以对象的方式实现数据库表的增删改查功能。在Room中定义一个Dao异常简单,只需要声明一个接口,然后使用@Dao注解即可,具体的实现都会由Room来帮我们实现。
1 | |
Insert
Dao中的增删改查都是耗时操作,是不允许在主线程调用的(可以在创建数据库对象的时候设置允许主线程调用,但是不推荐这样),在kotlin中可以设置为suspend方法,这样就可以避免手动去进行线程切换了。
@Insert用来注解一个方法,该方法用来实现对数据库表数据的插入。
1 | |
在Dao中的方法上使用@Insert注解,可以声明该方法为插入方法,参数是要插入的数据。如上例,就是向数据库Song表中插入数据。参数可以是一个对象,也可以是多个。
Insert方法可以没有返回值,有返回值的话则只能是Long类型的。若是主键是单一主键并且类型是整型的话,则返回插入的主键。若是非整型主键或者是组合主键的话,则返回插入的行数,从1开始。
@Insert注解还有一个onConflict参数,用于定义当插入数据冲突(要插入的数据的主键在数据库表中已存在)时执行的操作。,一共有五种操作(两种已过时):
OnConflictStrategy.ABORT终止插入,并且抛出异常OnConflictStrategy.REPLACE覆盖原数据OnConflictStrategy.IGNORE忽略这条数据,若是有返回值的话则返回-1
Delete、Update
使用@Delete来注解一个方法为删除语句,参数仍然是映射对象。注意,删除只关注参数对象对应的主键,其他参数会忽略,只要主键匹配,就会删除。
删除方法也可以不加返回值,加的话只能使用Int返回值,代表本次删除的个数。
1 | |
而将上述的@Delete改为@Update就成了一个更新语句,这时候会将主键对应的数据的全部列的值都更新成参数的值,并且返回值代表着更新的条数。
Query
Query的难度较高一些,因为这需要我们自己去编写查询的SQL语句。Query注解的查询语句返回值可以是对象,也可以是List集合。注意如果是对象的话记得声明成可空的对象,因此有可能会查不到数据,而查询的是集合的话,则不用担心这点,因为查不到的话会返回一个空集合,而不会返回null。
1 | |
@Query需要接收一个SQL语句,可以使用冒号加上方法的某个参数将其注入到SQL语句中,如上例的:songId。为了避免线程切换不仅仅可以定义为suspend方法,还可以修改返回值,使用LiveData或者Flow包裹返回值也行,这种情况下不能再使用suspend修饰了,只能是普通方法。并且,LiveData和Flow会持续的监听数据库的变化。比如上例查询所有的Song,当向数据库中插入一条新的Song的时候,返回值LiveData也会拿到新的一个List集合。
此外,@Query既然能执行SQL语句,那肯定就不只是有查询功能,插入删除修改都是可以的,当使用这些语句的时候,返回值跟前面使用注解的返回值要求是一样的。
1 | |
使用@Query能够完全实现数据库表的增删改查,并且可以更加灵活。比如增加数据的时候只插入某几个列,其他列使用默认值(在kotlin中这是使用@Insert无法实现的)。比如修改数据的时候,只修改某几列,而不是全部修改(使用@Update会全部修改,即使某些列数据没发生改变)。
使用
当定义完表结构以及Dao后,就需要去创建数据库以及获取Dao实例了。数据库实例需要继承RoomDatabase,并且声明成抽象类,以及获取Dao的几个抽象方法。
定义数据库
1 | |
数据库对象需要使用@Database注解,并且声明参数version和entities。version代表的是数据库的版本号,每次数据库表有改动的时候,都需要增加版本号并且添加对应的Migration。entities是一个数组,内容是每个表所对应的实体类。
@TypeConverter是可选的,参数是一个TypeConverter数组,只有添加了类型转换器的时候才需要添加该注解。类型转换器在上面定义表结构->表的数据类型->类型转换一节有说过。
抽象类中还需要声明一些抽象方法,这些方法不需要参数,只要声明返回值为对应的Dao就行。
创建数据库以及对应的Dao
1 | |
使用Builder模式创建Database实例,然后在获取到Dao实例进行数据库的操作即可。其中创建数据库的databaseBuilder方法接收三个参数,第一个参数是Context,最好传入ApplicationContext;第二个参数是数据库对象的具体实现类;第三个参数是数据库文件的名字。
然后便可以通过多种方法去定义数据库的行为,如设置允许主线程操作,如设置Transaction的线程池,如从某个文件直接读取数据库(数据库文件已存在)等等。
Migration
数据库一旦创建,就不能再修改表结构了,也就是说@Entity注解的对象的属性都是不能动的了,不管是删除修改还是添加一个属性,都是不允许的。
若是需要修改的话,则需要添加相应的Migration,然后操作数据库表,让其与修改后的@Entity实例对应。
1 | |
注意创建的Migration实例有两个参数,第一个是升级前的数据库版本,第二个参数是升级后的版本。可以在migrate方法中通过database执行sql语句去执行数据库的改变,这需要有一定的SQL语言基础。
如上例,添加了一个sex列,则在migrate中也使用SQL去添加了一个列来对应。注意若是新增的字段是非空的,则在SQL语句中也要声明为非空的,并且设置默认值(对应的字段上可以不用去声明默认值了)。
所以每次更改字段都必须升级数据库并且添加Migration,所以在开发阶段,每次修改字段后,直接卸载应用然后重新安装会更加方便。
总结
Room是一个数据库框架,使用它,可以让我们不用再将精力放在各种基本操作中,而是只专注于数据库表以及Dao。
并且Room的使用非常简单,仅需一些注解就能完成各种任务,与协程和LiveData和Flow紧密相连,使得使用更加方便。
Room使用的是编译时处理注解的技术,不会影响运行的效率。
