架构库版本:1.0.0 Alpha 2 - June 2, 2017
Room提供了一个SQLite之上的抽象层,使得在充分利用SQLite功能的前提下顺畅的访问数据库。
对于需要处理大量结构化数据的App来说,把这些数据做本地持久化会带来很大的好处。常见的用例是缓存重要数据块。这样当设备无法连网的时候,用户仍然可以浏览内容。而用户对内容做出的任何改动都在网络恢复的时候同步到服务端。
核心framework内置了对SQL的支持。虽然这些API很强大,但是都很低级,使用起来很花时间和精力:
没有编译时的SQL查询检查机制。当数据表发生改变的时候,需要手动更新受影响的SQL查询。这个过程既耗时又容易出错。
需要写很多公式化的代码在SQL查询与Java对象之间转换。
Room处理了这些相关的事情,同时提供了SQLite之上的抽象层。
Room中有三个主要的组件:
- Database:你可以用这个组件来创建一个database holder。注解定义实体的列表,类的内容定义从数据库中获取数据的对象(DAO)。它也是底层连接的主要入口。
这个被注解的类是一个继承RoomDatabase的抽象类。在运行时,可以通过调用Room.databaseBuilder()
或者 Room.inMemoryDatabaseBuilder()
来得到它的实例。
- Entity:这个组件代表一个持有数据库的一个表的类。对每一个entity,都会创建一个表来持有这些item。你必须在Database类中的entities数组中引用这些entity类。entity中的每一个field都将被持久化到数据库,除非使用了
@Ignore
注解。
注:实体可以有一个空构造函数(如果DAO类可以访问每个持久化字段),或者一个构造函数的参数包含与实体中的字段匹配的类型和名称。Romm还可以使用全部或部分构造函数,例如只接收一些字段的构造函数。
- DAO:这个组件代表一个作为Data Access Objec的类或者接口。DAO是Room的主要组件,负责定义查询(添加或者删除等)数据库的方法。使用
@Database
注解的类必须包含一个0参数的,返回类型为@Dao
注解过的类的抽象方法。Room会在编译时生成这个类的实现。
注:通过DAO而不是query builders或者直接的query语句来处理数据库,可以把数据库的各个部分分离开来。而且DAO还可以让你轻松的使用假的database来测试app。
这些组件以及它们与app其余部分之间的关系如图1:
下面是一个只有一个entity和一个DAO的数据库配置的简单例子:
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();
}
创建了上面的文件之后,可以使用下面的代码来得到database的实例了:
AppDatabase db = Room.databaseBuilder(getApplicationContext(),
AppDatabase.class, "database-name").build();
在实例化AppDatabase对象的时候应该遵循单例模式,因为每个Database实例都是相当耗费的,而且也很少需要多个实例。
Entities
当一个类用@Entity注解并且被@Database注解中的entities属性所引用,Room就会在数据库中为那个entity创建一张表。
默认Room会为entity中定义的每一个field都创建一个column。如果一个entity中有你不想持久化的field,那么你可以使用@Ignore来注释它们,如下面的代码所示:
@Entity
class User {
@PrimaryKey
public int id;
public String firstName;
public String lastName;
@Ignore
Bitmap picture;
}
要持久化一个field,Room必须有获取它的渠道。你可以把field写成public,也可以为它提供一个setter和getter。如果你使用setter和getter的方式,记住它们要基于Room的Java Bean规范。
Primary key
每个entity必须至少定义一个field作为主键(primary key)。即使只有一个field,你也必须用@PrimaryKey注释这个field。如果你想让Room为entity设置自增ID,你可以设置@PrimaryKey的autoGenerate属性。如果你的entity有一个组合主键,你可以使用@Entity注解的primaryKeys属性,具体用法如下:
@Entity(primaryKeys = {"firstName", "lastName"})
class User {
public String firstName;
public String lastName;
@Ignore
Bitmap picture;
}
Room默认把类名作为数据库的表名。如果你想用其它的名称,使用@Entity注解的tableName属性,如下:
@Entity(tableName = "users")
class User {
...
}
注:SQLite中的表名是大小写敏感的。
和tableName属性类似,Room默认把field名称作为数据库表的column名。如果你想让column有不一样的名称,为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;
}
Indices 和 uniqueness
为了提高查询的效率,你可能想为特定的字段建立索引。要为一个entity添加索引,在@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来实现唯一性。下面的代码防止了一个表中的两行数据出现firstName和lastName字段的值相同的情况:
@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;
}
关系
因为SQLite是关系数据库,你可以指定对象之间的关联。虽然大多数ORM库允许entity对象相互引用,但是Room明确禁止了这种行为。详细情况见:Addendum: No object references between entities。
虽然不可以使用直接的关联,Room仍然允许你定义entity之间的外键(Foreign Key)约束。
比如,假设有另外一个entity叫做calledBook,你可以使用@ForeignKey注解定义它和User entity之间的关联,如下:
@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;
}
外键非常强大,因为它允许你指定当被关联的entity更新时做什么操作。例如,通过在@ForeignKey注解中包含Delete = CASCADE, 你可以告诉SQLite,如果相应的User实例被删除,那么删除这个User下的所有book。
嵌套对象
有时你可能想把一个entity或者一个POJOs作为一个整体看待,即使这个对象包含几个field。这种情况下,你可以使用@Embedded注解,表示你想把一个对象分解为表的子字段。然后你就可以像其它独立字段那样查询这些嵌入的字段。
比如,我们的User类可以包含一个类型为Address的field,Address代表street,city,state, 和postCode字段的组合。为了让这些组合的字段单独存放在这个表中,对User类中的Address字段使用@Embedded注解,如下面的代码所示:
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;
}
那么现在代表一个User对象的表就有了如下的字段:id,firstName,street,state,city,以及post_code。
注:嵌套字段也可以包含其它的嵌套字段。
如果一个entity有多个嵌套字段是相同类型,你可以设置prefix属性保持每个字段的唯一性。Room就会在嵌套对象中的每个字段名的前面添加上这个值。
Data Access Objects (DAOs)
Room中的主要组件是Dao类。DAO抽象出了一种操作数据库的简便方法。
注:Room不允许通过主线程上访问数据库,除非您在构建器上调用allowMainThreadQueries(),因为它可能会长时间地锁定用户界面。异步查询(返回LiveData或RxJava Flowable的查询)将免除此规则,因为它们在需要时异步地在后台线程上运行查询。
便利的方法
DAO提供了多种简便的查询方式,本文档列出几种常见的例子。
insert
当你创建了一个DAO方法并且添加了@Insert注解,Room生成一个实现,将所有的参数在一次事务中插入数据库。
下面的代码片段演示了几种查询的例子:
@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,代表新插入元素的rowId,如果参数是一个数组或者集合,那么应该返回long[]或者List。
update
Update是一个更新一系列entity的简便方法。它根据每个entity的主键作为更新的依据。下面的代码演示了如何定义这个方法:
@Dao
public interface MyDao {
@Update
public void updateUsers(User... users);
}
你可以让这个方法返回一个int类型的值,表示更新影响的行数,虽然通常并没有这个必要。
delete
这个API用于删除一系列entity。它使用主键找到要删除的entity。下面的代码演示了如何定义这个方法:
@Dao
public interface MyDao {
@Delete
public void deleteUsers(User... users);
}
你可以让这个方法返回一个int类型的值,表示从数据库中被删除的行数,虽然通常并没有这个必要。
使用@Query的方法
@Query是DAO类中主要被使用的注解。它允许你在数据库中执行读写操作。每个@Query方法都是在编译时检查,因此如果查询存在问题,将出现编译错误,而不是在运行时引起崩溃。
Room还会检查查询的返回值,如果返回的对象的字段名和查询结果的相应字段名不匹配,Room将以下面两种方式提醒你:
- 如果某些字段名不匹配给出警告。
- 如果没有匹配的字段名给出错误提示。
简单的查询
@Dao
public interface MyDao {
@Query(“SELECT * FROM user”)
public User[] loadAllUsers();
}
这是一个非常简单的查询,加载所有的user。在编译时,Room知道它是查询user表中的所有字段。如果query有语法错误,或者user表不存在,Room将在app编译时显示恰当的错误信息。
向query传递参数
大多数时候,你需要向查询传递参数来执行过滤操作,比如只显示大于某个年龄的user。为此,在Room注解中使用方法参数,如下:
@Dao
public interface MyDao {
@Query("SELECT * FROM user WHERE age > :minAge")
public User[] loadAllUsersOlderThan(int minAge);
}
当编译时处理到这个查询的时候,Room把:minAge用方法中的minAge匹配。Room使用参数的名称来匹配。如果有不匹配的情况,app编译的时候就会出现错误。
你也可以传递多个参数或者在查询中多次引用它们,如下面的代码所示:
@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);
}
返回字段的子集
大多数时候,我们只需要一个entity的部分字段。比如,你的界面也许只需显示user的first name 和 last name,而不是用户的每个详细信息。只获取UI需要的字段可以节省可观的资源,查询也更快。
只要结果的字段可以和返回的对象匹配,Room允许返回任何的Java对象。比如,你可以创建如下的POJO获取user的first name 和 last name:
public class NameTuple {
@ColumnInfo(name="first_name")
public String firstName;
@ColumnInfo(name="last_name")
public String lastName;
}
现在你可以在query方法中使用这个POJO:
@Dao
public interface MyDao {
@Query("SELECT first_name, last_name FROM user")
public List<NameTuple> loadFullName();
}
Room知道这个查询返回first_name和last_name字段的值,并且这些值可以被映射到NameTuple类的field中。因此Room可以生成正确的代码。如果查询返回了太多的字段,或者某个字段不在NameTuple类中,Room将显示一个警告。
注:这些POJO也可以使用@Embedded注解。
传入参数集合
一些查询可能需要你传入个数是一个变量的参数,只有在运行时才知道具体的参数个数。比如,你可能想获取一个区间的用户信息。当一个参数代表一个集合的时候Room是知道的,它在运行时自动根据提供的参数个数扩展。
@Dao
public interface MyDao {
@Query("SELECT first_name, last_name FROM user WHERE region IN (:regions)")
public List<NameTuple> loadUsersFromRegions(List<String> regions);
}
可观察的查询
当执行查询的时候,你通常希望app的UI能自动在数据更新的时候更新。为此,在query方法中使用LiveData类型的返回值。当数据库变化的时候,Room会生成所有的必要代码来更新LiveData。
@Dao
public interface MyDao {
@Query("SELECT first_name, last_name FROM user WHERE region IN (:regions)")
public LiveData<List<User>> loadUsersFromRegionsSync(List<String> regions);
}
注:对于version 1.0,Room使用query中的table列表来决定是否更新LiveData对象。
RxJava
Room还可以让你定义的查询返回RxJava2的Publisher和Flowable对象。要使用这个功能,在Gradle dependencies中添加android.arch.persistence.room:rxjava2
。然后你就可以返回RxJava2中定义的对象类型了,如下面的代码所示:
@Dao
public interface MyDao {
@Query("SELECT * from user where id = :id LIMIT 1")
public Flowable<User> loadUserById(int id);
}
Direct cursor access
如果你的app需要获得直接返回的行,你可以在查询中返回Cursor对象,如下面的代码所示:
@Dao
public interface MyDao {
@Query("SELECT * FROM user WHERE age > :minAge LIMIT 5")
public Cursor loadRawUsersOlderThan(int minAge);
}
注:不推荐使用Cursor API,因为它无法保证行是否存在或者行中有哪些值。只有在当前的代码需要一个cursor,而且你又不好重构的时候才使用这个功能。
多表查询
某些查询可能需要根据多个表查询出结果。Room允许你书写任何查询,因此表连接(join)也是可以的。而且如果响应是一个可观察的数据类型,比如Flowable或者LiveData,Room将观察查询中涉及到的所有表。
下面的代码演示了如何执行一个表连接查询来查出借阅图书的user与被借出图书之间的信息。
@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);
}
你也可以返回POJO对象。比如你可以写一个如下的查询加载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,它将一个自定义类转换为Room保留的已知类型。
比如,如果我们要保留Date的实例,我们可以编写以下TypeConverter来存储数据库中的等效的Unix时间戳记:
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();
}
}
上述示例定义了两个函数,一个将Date对象转换为Long对象,另一个将从Long到Date转换为执行逆转换。 由于Room已经知道如何持久化Long对象,因此可以使用此转换器来持久保存Date类型的值。
接下来,将@TypeConverters注释添加到AppDatabase类,以便Room可以使用你为该AppDatabase中的每个实体和DAO定义的转换器:
AppDatabase.java
@Database(entities = {User.java}, version = 1)
@TypeConverters({Converter.class})
public abstract class AppDatabase extends RoomDatabase {
public abstract UserDao userDao();
}
使用这些转换器,可以在其他查询中使用自定义类型,就像使用原始类型一样,如以下代码片段所示:
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);
}
数据库迁移
随着app功能的添加和修改,你需要修改entity类来反应这些变化。当一个用户更新了app的最新版本之后,你并不希望它们丢失所有的现有数据,尤其是当你无法通过远程服务器恢复这些数据的时候。
Room让你可以让你写Migration类来保存用户数据。每个Migration类指定from和to版本。运行时Room运行每个Migration类的 migrate() 方法,使用正确的顺序把数据库迁移到新版本。
注意:如果你没有提供必要的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");
}
};
注意:为了让迁移的逻辑是可预知的,请使用完整的查询而不是用引用代表查询的constant。
当迁移过程完成之后,Room会检查schema以确保迁移正确进行。如果Room发现了问题,会抛出异常。
测试迁移
写迁移不是一件简单的事情,如果写法不恰当可能导致app的进入崩溃的恶性循环。为了保证app的稳定性,你应该先测试迁移。Room提供了一个testing Maven artifact来帮助你完成这个测试过程。但是要让这个artifact工作,需要导出数据库的schema。
导出 schemas
在编译的时候,Room将database的schema信息导出到一个JSON文件中。为此,要在build.gradle 文件中设置room.schemaLocation注解处理器属性,如下面的代码所示:
build.gradle
android {
...
defaultConfig {
...
javaCompileOptions {
annotationProcessorOptions {
arguments = ["room.schemaLocation":
"$projectDir/schemas".toString()]
}
}
}
}
你应该把导出的在这个JSON文件-它代表了你的数据库的schema历史-保存到你的版本管理系统中,这样就可以让Room创建旧版本的数据库来测试。
测试迁移需要把Room的Maven artifact android.arch.persistence.room:testing
添加到你的test dependencies,并且把schema的位置作为 asset folder添加进去,代码如下:
build.gradle
android {
...
sourceSets {
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
}
}
testing package提供了一个MigrationTestHelper类,它可以读出这些schema文件。它同时也是一个 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.getContext(),
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.
}
}