本文将介绍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() or Room.inMemoryDatabaseBuilder()获取实例
  • Entity: 数据库表行数据结构。每个entity对应一个表。通过Databaseentities()来获取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

@Entity
public class User {
@PrimaryKey
private int uid;

@ColumnInfo(name = "first_name")
private String firstName;

@ColumnInfo(name = "last_name")
private String lastName;

// Getters and setters are ignored for brevity,
// but they're required for Room to work.
}

UserDao.java

@Dao
public interface UserDao {
@Query("SELECT * FROM user")
List<User> getAll();

@Query("SELECT * FROM user WHERE uid IN (:userIds)")
List<User> loadAllByIds(int[] userIds);

@Query("SELECT * FROM user WHERE first_name LIKE :first AND "
+ "last_name LIKE :last LIMIT 1")
User findByName(String first, String last);

@Insert
void insertAll(User... users);

@Delete
void delete(User user);
}

AppDatabase.java

@Database(entities = {User.class}, version = 1)
public abstract class AppDatabase extends RoomDatabase {
public abstract UserDao userDao();
}

在APP中获取database引用:

AppDatabase db = Room.databaseBuilder(getApplicationContext(),
AppDatabase.class, "database-name").build();

注意: 这里尽可能使用单例设计模式,RoomDatabase实例开销很大.

Entities

所有在注解@Databaseentities属性中引用到的@Entity类,Room都会创建一个对应的数据库表。
默认,@Entity类的所有field对应一个数据库的column,除非被用@Ignore修饰。

@Entity
class User {
@PrimaryKey
public int id;

public String firstName;
public String lastName;

+ @Ignore
+ Bitmap picture;
}

无论是public可见,还是提供setter、getter,被持久化的field必须是可以被accessed的。

@PrimaryKey

  • 每个Entity必须至少有一个primary key(即使Entity只有一个field)。

  • 自增ID:使用@PrimaryKeyautoGenerate 属性

  • 组合主键:使用@EntityprimaryKeys 属性

@Entity(primaryKeys = {"firstName", "lastName"})
class User {
public String firstName;
public String lastName;

@Ignore
Bitmap picture;
}
  • 数据库表名:
    Room默认使用类名作为数据库表名,自定义表名可以使用@EntitytableName属性
+ @Entity(tableName = "users")
class User {
...
}

注意: SQLite表名是大小写敏感的

  • 数据库字段名:
    Room 默认使用 field 名作为字段名,自定义字段名可以使用@ColumnInfo 注解
@Entity(tableName = "users")
class User {
@PrimaryKey
public int id;

+ @ColumnInfo(name = "first_name")
public String firstName;

@ColumnInfo(name = "last_name")
public String lastName;

@Ignore
Bitmap picture;
}

索引 和 唯一性约束

  • 索引: @Entity标注的indices属性。
+ @Entity(indices = {@Index("name"), @Index("last_name", "address")})
class User {
@PrimaryKey
public int id;

public String firstName;
public String address;

@ColumnInfo(name = "last_name")
public String lastName;

@Ignore
Bitmap picture;
}
  • 唯一性约束:设置@Index注解的unique属性为true
@Entity(indices = {@Index(value = {"first_name", "last_name"},
+ unique = true)})
class User {
@PrimaryKey
public int id;

@ColumnInfo(name = "first_name")
public String firstName;

@ColumnInfo(name = "last_name")
public String lastName;

@Ignore
Bitmap picture;
}

关系

大多数ORM允许Entity之间互相引用,Room却明确的禁止。

Room使用外链约束@ForeignKey来定义Entity之间的关系。

举个例子,BookUser 之间的关系。

@Entity(foreignKeys = @ForeignKey(entity = User.class,
parentColumns = "id",
childColumns = "user_id"))
class Book {
@PrimaryKey
public int bookId;

public String title;

@ColumnInfo(name = "user_id")
public int userId;
}

外链虽然提供了强大的关系约束,但是使用不当也容易造成冲突。为了避免冲突,往往需要添加额外的信息,比如@ForeignKeyonDelete=CASCADE可以级联删除。要注意的是@Insert(OnConflict=REPLACE)做了remove、update一系列操作,而不是简单的update,可能会破坏外链约束。

嵌套的objects

有时,我们会想用一个 entity 或者来 pojo object来表示数据库的逻辑结构,即使这个object中往往有多个field。

举例 :User 包含了Address field,而Address是一个复合类,又包含了streetcitystate, 和 postCode

这时,可以用
@Embedded修饰一个subfields,来表明在数据库的同一行中分解这个subfields。

这里要想把Address字段在数据库表中分开, 只需要在User类中包含@Embedded修饰的 Address field 。

class Address {
public String street;
public String state;
public String city;

@ColumnInfo(name = "post_code")
public int postCode;
}

@Entity
class User {
@PrimaryKey
public int id;

public String firstName;

@Embedded
public Address address;
}

生成的table长这个样

id firstName street state city post_code
** ** ** ** ** **

注意: 嵌套可以包含其他嵌套类,为了避免嵌套类的命名冲突,可以设置@Embeddedprefix属性。

Data Access Objects (DAOs)

Room 的主要组件是Dao,DAOs抽象了一系列访问数据库的方法。

注意: Room 默认不允许在main thread访问数据库,除非调用了builder.allowMainThreadQueries() 。但是异步查询(返回LiveData或者 RxJava Flowable的查询)例外。

一些基于约定的查询

有些查询仅仅用DAO类注解就能实现。

Insert
  • @Insert
    Room 自动生成实现,所有的参数在一个transition内被提交并插入到数据库.
@Dao
public interface MyDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
public void insertUsers(User... users);

@Insert
public void insertBothUsers(User user1, User user2);

@Insert
public void insertUsersAndFriends(User user, List<User> friends);
}

@Insert方法根据参数个数返回对应的long、long[]、List<Long>类型的rowId

Update
  • Update 使用参数中的 primary key 更新数据
@Dao
public interface MyDao {
@Update
public void updateUsers(User... users);
}

可选让方法返回int表示 rows updated。

Delete
  • Delete 使用参数中的 primary key删除数据
@Dao
public interface MyDao {
@Delete
public void deleteUsers(User... users);
}

可选让方法返回int表示 rows removed。

Methods using @Query

@Query 可以读写数据库, @Query方法会在编译期验证查询语法,同时,Room也会验证返回值的字段名是否和查询字段名一致。

  • 只有部分字段名匹配,验证抛出warning.
  • 没有任何字段名匹配,验证抛出error
简单的 queries
@Dao
public interface MyDao {
@Query("SELECT * FROM user")
public User[] loadAllUsers();
}

编译期,Room 就已经知道查询user 数据表的所有字段了。如果查询语句有语法错误、或者user表不存在,Room 都会在编译期显示错误信息。

带参数的 query
@Dao
public interface MyDao {
@Query("SELECT * FROM user WHERE age > :minAge")
public User[] loadAllUsersOlderThan(int minAge);
}

编译期, Room会依据参数名字进行绑定,比如 :minAge 会和 minAge参数绑定。绑定失败,编译期就会报错。

多个参数

@Dao
public interface MyDao {
@Query("SELECT * FROM user WHERE age BETWEEN :minAge AND :maxAge")
public User[] loadAllUsersBetweenAges(int minAge, int maxAge);

@Query("SELECT * FROM user WHERE first_name LIKE :search "
+ "OR last_name LIKE :search")
public List<User> findUserWithName(String search);
}
返回结构集映射

Room 通过定义所需的pojo来支持select
映射。

比如

public class NameTuple {
@ColumnInfo(name="first_name")
public String firstName;

@ColumnInfo(name="last_name")
public String lastName;
}

Now, you can use this POJO in your query method:

@Dao
public interface MyDao {
@Query("SELECT first_name, last_name FROM user")
public List<NameTuple> loadFullName();
}

**注意:**这些 POJOs 同样可以使用@Embedded annotation。

Passing a collection of arguments

某些查询需要传入个数可变的参数,比如具体数量直到运行期才确定的。 当参数为一个集合时,Room会在运行期自动将其展开为个数确定的参数集。

@Dao
public interface MyDao {
@Query("SELECT first_name, last_name FROM user WHERE region IN (:regions)")
public List<NameTuple> loadUsersFromRegions(List<String> regions);
}
Observable queries

使用LiveData作为查询函数的返回值可以在查询数据更新时,自动通知UI。

@Dao
public interface MyDao {
@Query("SELECT first_name, last_name FROM user WHERE region IN (:regions)")
public LiveData<List<User>> loadUsersFromRegionsSync(List<String> regions);
}
RxJava

Room也可以和RxJava2配合使用,使用Publisher或者 Flowable作为查询函数的返回值即可。

build.gradle

compile 'android.arch.persistence.room:rxjava2:1.0.0-alpha1'
@Dao
public interface MyDao {
@Query("SELECT * from user where id = :id LIMIT 1")
public Flowable<User> loadUserById(int id);
}
直接返回 cursor

Room也可以直接返回 Cursor

@Dao
public interface MyDao {
@Query("SELECT * FROM user WHERE age > :minAge LIMIT 5")
public Cursor loadRawUsersOlderThan(int minAge);
}

注意: 原则上不鼓励使用Cursor API,因为它既不保证row查询结果存在,也不保证row保存的数据是否和查询数据一致。

跨表查询

可以直接在Query中使用join,如果查询方法的返回数据类型是Flowable 或者LiveData,Room 会检查所有的涉及到的表。

下面的代码表示查询某个用户借的书籍,user 表 join loan表 join book

@Dao
public interface MyDao {
@Query("SELECT * FROM book "
+ "INNER JOIN loan ON loan.book_id = book.id "
+ "INNER JOIN user ON user.id = loan.user_id "
+ "WHERE user.name LIKE :userName")
public List<Book> findBooksBorrowedByNameSync(String userName);
}

也可以直接返回POJOs,比如下面返回user的宠物的名字。

@Dao
public interface MyDao {
@Query("SELECT user.name AS userName, pet.name AS petName "
+ "FROM user, pet "
+ "WHERE user.id = pet.user_id")
public LiveData<List<UserPet>> loadUserAndPetNames();

// You can also define this class in a separate file, as long as you add the
// "public" access modifier.
static class UserPet {
public String userName;
public String petName;
}
}

关于类型转换

Room 提供了内建的基础数据类型和封装类型的自动转换。有时,为了在数据库的某个字段中存储自定义的数据类型,你需要提供一个从自定义类到已知类型的TypeConverter转化器。

比如,将Date数据类型转化为Unix timestamp

public class Converters {
@TypeConverter
public static Date fromTimestamp(Long value) {
return value == null ? null : new Date(value);
}

@TypeConverter
public static Long dateToTimestamp(Date date) {
return date == null ? null : date.getTime();
}
}

接下来将@TypeConverters添加到AppDatabase类,让entity和DAO都可以使用这个converter

AppDatabase.java

@Database(entities = {User.java}, version = 1)
@TypeConverters({Converter.class})
public abstract class AppDatabase extends RoomDatabase {
public abstract UserDao userDao();
}

使用converters后,在查询时也可以直接使用自定义类型。

User.java

@Entity
public class User {
...
private Date birthday;
}

UserDao.java

@Dao
public interface UserDao {
...
@Query("SELECT * FROM user WHERE birthday BETWEEN :from AND :to")
List<User> findUsersBornBetweenDates(Date from, Date to);
}

除了在AppDatabase上应用 @TypeConverters ,还可以在entities、 DAOs、以及 DAO 方法上面应用。

Database migration

数据库结构升级时,需要提供一个从低版本到高版本的迁移方法。在Room中,通过实现Migration 类来完成这个过程。每个Migration类都定义了 startVersionendVersion。在运行期, Room 会调用每个Migrationmigrate()方法 ,并用正确的顺序迁移数据库到最新的版本。

注意: 如果没有提供必须的migration, Room 会重建每个数据, 低版本的数据会丢失。

Room.databaseBuilder(getApplicationContext(), MyDb.class, "database-name")
.addMigrations(MIGRATION_1_2, MIGRATION_2_3).build();

static final Migration MIGRATION_1_2 = new Migration(1, 2) {
@Override
public void migrate(SupportSQLiteDatabase database) {
database.execSQL("CREATE TABLE `Fruit` (`id` INTEGER, "
+ "`name` TEXT, PRIMARY KEY(`id`))");
}
};

static final Migration MIGRATION_2_3 = new Migration(2, 3) {
@Override
public void migrate(SupportSQLiteDatabase database) {
database.execSQL("ALTER TABLE Book "
+ " ADD COLUMN pub_year INTEGER");
}
};

注意: 为了维持migration的正常运作, 总是使用 full queries 而不是引用常量的queries.

迁移结束后,Room 会验证表的元数据正确。如果验证失败就会抛出异常。

测试 migrations

Room 提供了一个测试 migration 是否正常工作的库

首先,需要导出数据库元数据

Exporting schemas

build.gradle设置room.schemaLocationannotation processor 属性即可导出数据库元信息,导出的数据库信息存储在一个json文件中。

build.gradle

android {
...
defaultConfig {
...
javaCompileOptions {
annotationProcessorOptions {
arguments = ["room.schemaLocation":
"$projectDir/schemas".toString()]
}
}
}
}

你应该把这个json文件添加到vcs中,Room会根据这个文件创建低版本的数据库方便测试。

接下来继续修改build.gradle

添加测试依赖

testCompile 'android.arch.persistence.room:testing:***'

添加 schema 的 asset 文件位置

android {
...
sourceSets {
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
}
}

测试库提供了一个MigrationTestHelper 类, 它可以读取 schema 文件. 同时这个Helper还是一个Junit4 TestRule 类, 它会自动管理创建的数据库。

@RunWith(AndroidJUnit4.class)
public class MigrationTest {
private static final String TEST_DB = "migration-test";

@Rule
public MigrationTestHelper helper;

public MigrationTest() {
helper = new MigrationTestHelper(InstrumentationRegistry.getInstrumentation(),
MigrationDb.class.getCanonicalName(),
new FrameworkSQLiteOpenHelperFactory());
}

@Test
public void migrate1To2() throws IOException {
SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 1);

// db has schema version 1. insert some data using SQL queries.
// You cannot use DAO classes because they expect the latest schema.
db.execSQL(...);

// Prepare for the next version.
db.close();

// Re-open the database with version 2 and provide
// MIGRATION_1_2 as the migration process.
db = helper.runMigrationsAndValidate(TEST_DB, 2, true, MIGRATION_1_2);

// MigrationTestHelper automatically verifies the schema changes,
// but you need to validate that the data was migrated properly.
}
}

测试你的 database

测试应用时,我们没必要创建整个数据库,因为我们并不是要测试数据库底层。 Room 允许你在测试时mock一个数据获取层。 这套机制是基于DAOs和数据库实现细节清晰的划分实现的。当测试时,你应该 mock DAO 实例.

2种测试思路:

  • 开发机上测试。
  • 真机测试。

开发机上测试

Room 使用的 SQLite Support 库, 这个库是基于Android Framework实现的接口。为了测试,也可以使用一个实现了接口的自定义 support 库。

这种做法优点是:测试运行速度快,缺点是:设备端的SQLite的版本可能和host机器上的不一致。

Android 真机测试

这也是推荐的测试方式,在真机上跑Junit测试用例。由于节省了创建activity的开销,运行速度比那些UI测试快一点。

创建测试时,需要生成一个基于内存的database,以免影响到外部数据。

@RunWith(AndroidJUnit4.class)
public class SimpleEntityReadWriteTest {
private UserDao mUserDao;
private TestDatabase mDb;

@Before
public void createDb() {
Context context = InstrumentationRegistry.getTargetContext();
mDb = Room.inMemoryDatabaseBuilder(context, TestDatabase.class).build();
mUserDao = mDb.getUserDao();
}

@After
public void closeDb() throws IOException {
mDb.close();
}

@Test
public void writeUserAndReadInList() throws Exception {
User user = TestUtil.createUser(3);
user.setName("george");
mUserDao.insert(user);
List<User> byName = mUserDao.findUsersByName("george");
assertThat(byName.get(0), equalTo(user));
}
}

附录: 为什么不支持 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之间的互相引用,而且,你必须显式的申明请求所需的数据。