[简译]Google 官方 App 架构 Guide(6)
Contents
本文将介绍Architecture Components中的Room组件
Room Persistence Library
Room 在 SQLite 的基础上提供了一个抽象层(可以理解为官方的ORM)。
尽管Android系统内建支持了功能强大的裸SQL查询语句,但是这些API太底层了,使用起来总是需要各种封装。
- 裸SQL语句没有编译期检查,重构数据库底层后,更新数据查询语句既耗时又很容易出错。
- 在SQL查询结果和Java Object之间转化需要写一堆胶水代码。
Room 分为三大部分:
- @Database: database holder,定义了数据库的DAO,同时也是数据库连接的主入口。被
Database
标注的类需要为继承RoomDatabase
的抽象类。运行时通过Room.databaseBuilder()
orRoom.inMemoryDatabaseBuilder()
获取实例 - Entity: 数据库表行数据结构。每个entity对应一个表。通过
Database
的entities()
来获取entity引用。 - DAO: Data Access Object (DAO). DAO定义了获取数据的方法。
Database
中必须包含返回@Dao
类的无参抽象方法。在编译期间Room会自动生成相应实现代码。
Figure 1. Room architecture diagram
App通过Room database来获取DAO,然后通过DAO获取数据库中的entities,对entities的修改会同步到数据库。
show me the code
User.java
|
UserDao.java
|
AppDatabase.java
1) (entities = {User.class}, version = |
在APP中获取database引用:
AppDatabase db = Room.databaseBuilder(getApplicationContext(), |
注意: 这里尽可能使用单例设计模式,RoomDatabase
实例开销很大.
Entities
所有在注解@Database
的entities
属性中引用到的@Entity
类,Room都会创建一个对应的数据库表。
默认,@Entity
类的所有field
对应一个数据库的column,除非被用@Ignore
修饰。
@Entity |
无论是public可见,还是提供setter、getter,被持久化的field必须是可以被accessed的。
@PrimaryKey
-
每个Entity必须至少有一个primary key(即使Entity只有一个field)。
-
自增ID:使用
@PrimaryKey
的autoGenerate
属性 -
组合主键:使用
@Entity
的primaryKeys
属性
"firstName", "lastName"}) (primaryKeys = { |
- 数据库表名:
Room默认使用类名作为数据库表名,自定义表名可以使用@Entity
的tableName
属性
+ @Entity(tableName = "users") |
注意: SQLite表名是大小写敏感的
- 数据库字段名:
Room 默认使用 field 名作为字段名,自定义字段名可以使用@ColumnInfo
注解
@Entity(tableName = "users") |
索引 和 唯一性约束
- 索引:
@Entity
标注的indices
属性。
+ @Entity(indices = {@Index("name"), @Index("last_name", "address")}) |
- 唯一性约束:设置
@Index
注解的unique
属性为true
。
@Entity(indices = {@Index(value = {"first_name", "last_name"}, |
关系
大多数ORM允许Entity之间互相引用,Room却明确的禁止。
Room使用外链约束@ForeignKey
来定义Entity之间的关系。
举个例子,Book
和 User
之间的关系。
(foreignKeys = (entity = User.class, |
外链虽然提供了强大的关系约束,但是使用不当也容易造成冲突。为了避免冲突,往往需要添加额外的信息,比如@ForeignKey
的onDelete=CASCADE
可以级联删除。要注意的是@Insert(OnConflict=REPLACE)
做了remove、update一系列操作,而不是简单的update,可能会破坏外链约束。
嵌套的objects
有时,我们会想用一个 entity 或者来 pojo object来表示数据库的逻辑结构,即使这个object中往往有多个field。
举例 :User
包含了Address
field,而Address
是一个复合类,又包含了street
,city
, state
, 和 postCode
。
这时,可以用
@Embedded
修饰一个subfields,来表明在数据库的同一行中分解这个subfields。
这里要想把Address
字段在数据库表中分开, 只需要在User
类中包含@Embedded
修饰的 Address
field 。
class Address { |
生成的table长这个样
id | firstName | street | state | city | post_code |
---|---|---|---|---|---|
** | ** | ** | ** | ** | ** |
注意: 嵌套可以包含其他嵌套类,为了避免嵌套类的命名冲突,可以设置@Embedded
的 prefix
属性。
Data Access Objects (DAOs)
Room 的主要组件是Dao
,DAOs抽象了一系列访问数据库的方法。
注意: Room 默认不允许在main thread访问数据库,除非调用了builder.allowMainThreadQueries()
。但是异步查询(返回LiveData
或者 RxJava Flowable
的查询)例外。
一些基于约定的查询
有些查询仅仅用DAO类注解就能实现。
Insert
@Insert
Room 自动生成实现,所有的参数在一个transition内被提交并插入到数据库.
|
@Insert
方法根据参数个数返回对应的long、long[]、List<Long>
类型的rowId
Update
Update
使用参数中的 primary key 更新数据
|
可选让方法返回int
表示 rows updated。
Delete
Delete
使用参数中的 primary key删除数据
|
可选让方法返回int
表示 rows removed。
Methods using @Query
@Query
可以读写数据库, @Query
方法会在编译期验证查询语法,同时,Room也会验证返回值的字段名是否和查询字段名一致。
- 只有部分字段名匹配,验证抛出warning.
- 没有任何字段名匹配,验证抛出error
简单的 queries
|
编译期,Room 就已经知道查询user 数据表的所有字段了。如果查询语句有语法错误、或者user表不存在,Room 都会在编译期显示错误信息。
带参数的 query
|
编译期, Room会依据参数名字进行绑定,比如 :minAge
会和 minAge
参数绑定。绑定失败,编译期就会报错。
多个参数
|
返回结构集映射
Room 通过定义所需的pojo来支持select
映射。
比如
public class NameTuple { |
Now, you can use this POJO in your query method:
|
**注意:**这些 POJOs 同样可以使用@Embedded
annotation。
Passing a collection of arguments
某些查询需要传入个数可变的参数,比如具体数量直到运行期才确定的。 当参数为一个集合时,Room会在运行期自动将其展开为个数确定的参数集。
|
Observable queries
使用LiveData作为查询函数的返回值可以在查询数据更新时,自动通知UI。
|
RxJava
Room也可以和RxJava2配合使用,使用Publisher
或者 Flowable
作为查询函数的返回值即可。
build.gradle
compile 'android.arch.persistence.room:rxjava2:1.0.0-alpha1' |
|
直接返回 cursor
Room也可以直接返回 Cursor
|
注意: 原则上不鼓励使用Cursor API,因为它既不保证row查询结果存在,也不保证row保存的数据是否和查询数据一致。
跨表查询
可以直接在Query中使用join,如果查询方法的返回数据类型是Flowable
或者LiveData
,Room 会检查所有的涉及到的表。
下面的代码表示查询某个用户借的书籍,user
表 join loan
表 join book
表
|
也可以直接返回POJOs,比如下面返回user的宠物的名字。
|
关于类型转换
Room 提供了内建的基础数据类型和封装类型的自动转换。有时,为了在数据库的某个字段中存储自定义的数据类型,你需要提供一个从自定义类到已知类型的TypeConverter
转化器。
比如,将Date
数据类型转化为Unix timestamp
public class Converters { |
接下来将@TypeConverters
添加到AppDatabase
类,让entity和DAO都可以使用这个converter
AppDatabase.java
1) (entities = {User.java}, version = |
使用converters后,在查询时也可以直接使用自定义类型。
User.java
|
UserDao.java
|
除了在AppDatabase
上应用 @TypeConverters
,还可以在entities、 DAOs、以及 DAO 方法上面应用。
Database migration
数据库结构升级时,需要提供一个从低版本到高版本的迁移方法。在Room中,通过实现Migration
类来完成这个过程。每个Migration
类都定义了 startVersion
和 endVersion
。在运行期, Room 会调用每个Migration
的 migrate()
方法 ,并用正确的顺序迁移数据库到最新的版本。
注意: 如果没有提供必须的migration, Room 会重建每个数据, 低版本的数据会丢失。
Room.databaseBuilder(getApplicationContext(), MyDb.class, "database-name") |
注意: 为了维持migration的正常运作, 总是使用 full queries 而不是引用常量的queries.
迁移结束后,Room 会验证表的元数据正确。如果验证失败就会抛出异常。
测试 migrations
Room 提供了一个测试 migration 是否正常工作的库
首先,需要导出数据库元数据
Exporting schemas
在build.gradle
设置room.schemaLocation
annotation processor 属性即可导出数据库元信息,导出的数据库信息存储在一个json文件中。
build.gradle
android { |
你应该把这个json文件添加到vcs中,Room会根据这个文件创建低版本的数据库方便测试。
接下来继续修改build.gradle
添加测试依赖
testCompile 'android.arch.persistence.room:testing:***' |
添加 schema 的 asset 文件位置
android { |
测试库提供了一个MigrationTestHelper
类, 它可以读取 schema 文件. 同时这个Helper还是一个Junit4 TestRule
类, 它会自动管理创建的数据库。
(AndroidJUnit4.class) |
测试你的 database
测试应用时,我们没必要创建整个数据库,因为我们并不是要测试数据库底层。 Room 允许你在测试时mock一个数据获取层。 这套机制是基于DAOs和数据库实现细节清晰的划分实现的。当测试时,你应该 mock DAO 实例.
2种测试思路:
- 开发机上测试。
- 真机测试。
开发机上测试
Room 使用的 SQLite Support 库, 这个库是基于Android Framework实现的接口。为了测试,也可以使用一个实现了接口的自定义 support 库。
这种做法优点是:测试运行速度快,缺点是:设备端的SQLite的版本可能和host机器上的不一致。
Android 真机测试
这也是推荐的测试方式,在真机上跑Junit测试用例。由于节省了创建activity的开销,运行速度比那些UI测试快一点。
创建测试时,需要生成一个基于内存的database,以免影响到外部数据。
(AndroidJUnit4.class) |
附录: 为什么不支持 entities 之间引用
将数据库之间的关系映射到entities上,并延迟加载的做法,在服务端开发已经有很成熟的应用了。
但是在客户端,延迟加载并不可行,因为延迟加载往往发生在UI线程,从而导致严格的性能问题,activity只有16ms来绘制UI界面,即使数据库查询只花了5ms,任然有很大可能绘制超时,导致界面卡顿。另外,如果并行的在数据库上执行多个查询,或者设备同时在执行一个重IO的任务,那么延迟加载会花费更多时间。而如果不使用延迟加载策略,应用往往会load比预期更多的数据,从而加重了内存负担。
ORMs 经常将这种锅抛给程序员,让他们自己去选择。 不幸的是,在程序员往往在app和UI界面间共享 model。当UI发生变化,你很难去预测和调试遇到的种种问题。
举例说明,有一个界面需要加载 Book
列表,每个Book
都有Author
字段,你决定采用延迟加载策略,Book
通过调用getAuthor()
时返回作者信息。第一次调用getAuthor()
时才会去查询数据库。这时要在在UI上显示作者姓名,你可能会写下面的代码:
authorNameTextView.setText(user.getAuthor().getName()); |
这时,getAuthor()
查询数据库的动作在main线程上运行。
如果采用主动查询策略,当UI不再需要作者信息时,Book
任然会加载对应的 Author
信息。而且,如果Author
又查询了另外一张表,比如getBooks()
,情况变得更糟。
基于以上原因,Room 不允许entity之间的互相引用,而且,你必须显式的申明请求所需的数据。
Author: deskid
Link: https://deskid.github.io/2017/06/20/Guide-to-App-Architecture-6/
License: 知识共享署名-非商业性使用 4.0 国际许可协议