JetPack 之 Room

Room持久库提供了一个SQLite抽象层,让你访问数据库更加稳健,提升数据库性能。App把经常需要访问的数据存储在本地将会大大改善用户的体验。这样用户在网络不好时仍然可以浏览内容。当用户网络可用时,可以更新用户的数据。

Room包含以下三个重要组成部分:

详细的结构关系可以看下图:

实例

Room和传统写数据库创建访问的代码大概形式差不多的。以存储User信息为例,看一下下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// 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.
//Getters和setters为了简单起见就省略了,但是对Room来说是必须的
}
// 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();
}

在创建上面的文件之后,使用以下代码获得创建数据库的实例:

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

使用@Entity实体定义数据

Room持久化一个类的field必须要求这个field是可以访问的。可以把这个field设为public或者设置setter和getter。如果上面的User类中包含一个字段是不希望存放到数据库中的,那么可以用@Ignore注解这个字段:

1
2
3
4
5
6
7
8
9
10
11
12
@Entity
class User {
@PrimaryKey
public int id;
public String firstName;
public String lastName;
//不需要被存放到数据库中
@Ignore
Bitmap picture;
}

Primary Key 主键

每个Entity都必须定义一个field为主键,即使是这个Entity只有一个field。如果想要Room生成自动的primary key,可以使用@PrimaryKeyautoGenerate属性。如果Entity的primary key是多个Field的复合Key,可以向下面这样设置:

1
2
3
4
5
6
7
8
@Entity(primaryKeys = {"firstName", "lastName"})
class User {
public String firstName;
public String lastName;
@Ignore
Bitmap picture;
}

在默认情况下Room使用类名作为数据库表的名称。如果想要设置不同的名称,可以设置表名tableName为users,如果想要使用不同的名称,可以通过@ColumnInfo(name = "first_name")设置,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@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;
}

索引和唯一性

根据访问数据库的方式,你可能想对特定的field建立索引来加速你的访问。下面这段代码展示了如何在Entity中添加索引或者复合索引:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Entity(indices = {@Index("name"),
@Index(value = {"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;
}

对象之间的关系

SQLite是关系型数据库,那么就可以在两个对象之间建立联系。大多数ORM库允许Entity对象互相引用,但Room明确禁止了这样做。详细的原因,可以参考这里。既然不允许建立直接的关系,Room提供以外键的方式在两个Entity之间建立联系。

例如,有一个Pet类需要和User类建立关系,可以通过@ForeignKey来达到这个目的,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
@Entity(foreignKeys = @ForeignKey(entity = User.class,
parentColumns = "id",
childColumns = "user_id"))
class Pet {
@PrimaryKey
public int petId;
public String name;
@ColumnInfo(name = "user_id")
public int userId;
}

外键可以允许你定义被引用的Entity更新时发生的行为。例如,你可以定义当删除User时对应的Pet类也被删除。可以在@ForeignKey中添加onDelete = CASCADE)实现。

有时候需要在类里面把另一个类作为field,这时就需要使用@Embedded 。这样就可以像查询其他列一样查询这个field。例如,User类可以包含一个field Address,代表User的地址包括所在街道、城市、州和邮编。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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。Embedded 的field中也可以包含其他Embedded的field。如果多个Embedded的field是类型相同的,可以通过设置 prefix) 来保证列的唯一性。

使用DAOs访问数据

DAOs是数据库访问的抽象层。Dao可以是一个接口也可以是一个抽象类。如果是抽象类,那么它可以接受一个RoomDatabase作为构造器的唯一参数。

注意:除非在建造器上调用了allowMainThreadQueries(),否则Room不支持主线程上的数据库访问,因为访问数据库是耗时的,可能阻塞主线程,引起UI卡顿。返回LiveDataFlowable实例的异步查询可免除此规则,因为它们在需要时异步地在后台线程上运行查询。

Insert

使用@Insert注解的方法,Room将会生成插入的代码。

1
2
3
4
5
6
7
8
9
10
11
@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。有关详细信息,请参阅@Insert注解的参考文档,以及SQLite documentation for rowid tables

Update

Update方法在数据库中用于修改一组实体的字段。它使用每个实体的主键来匹配查询。下面的代码片段演示如何定义此方法:

1
2
3
4
5
@Dao
public interface MyDao {
@Update
public void updateUsers(User... users);
}

也可以让update方法返回一个int型的整数,代表被update的行号。

Delete

使用@Insert注解的方法,Room将会生成插入的代码。

1
2
3
4
5
@Dao
public interface MyDao {
@Delete
public void deleteUsers(User... users);
}

和update方法一样,也可以返回一个int型的整数,代表被delete的行号。

Query

@Query是DAO类中使用的主要注解。它允许您在数据库上执行读/写操作。每个@Query方法在编译时被验证,因此,如果存在查询问题,则会发生编译错误而不是运行时故障。

Room也会检查查询返回值的类型,如果返回类型的字段和数据路列名存在不一致,会收到警告。如果两者完全不一致,就会产生错误。下面代码示例了普通查询和带参数查询:

1
2
3
4
5
6
7
8
@Dao
public interface MyDao {
@Query("SELECT * FROM user")
public User[] loadAllUsers();
@Query("SELECT * FROM user WHERE age > :minAge")
public User[] loadAllUsersOlderThan(int minAge);
}

这里也会在编译时做类型检查,如果表中没有age这个列,那么就会抛出错误。
也可以穿入多个参数或一个参数作为多个约束条件查询用户:

1
2
3
4
5
6
7
8
9
@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的几个field,例如只需要获取User的姓名就行了。通过只获取这两列的数据不仅能够节省宝贵的资源,还能加快查询速度。
Room也提供了这样的功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class NameTuple {
@ColumnInfo(name="first_name")
public String firstName;
@ColumnInfo(name="last_name")
public String lastName;
}
@Dao
public interface MyDao {
@Query("SELECT first_name, last_name FROM user")
public List<NameTuple> loadFullName();
}

可被观察的查询

通过和LiveData的配合使用,就可以实现当数据库内容发生变化时自动收到变化后的数据的功能。

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

查询多个表

有时可能需要查询多个表来获取结果,Room也定义这样的功能。下面这段代码演示了如何从一个包含借阅用户信息的表和一个包含已经被借阅的书的表中获取信息:

1
2
3
4
5
6
7
8
@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类。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@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使用 Migration 来实现数据库的迁移。每个Migration 都指定了startVersionendVersion。在运行的时候Room运行每个Migrationmigrate() 方法,按正确的顺序来迁移数据库到下个版本。如果没有提供足够的迁移信息,Room会重新创建数据库,这意味着将会失去原来保存的信息。

迁移是很重要的,可能会导致应用程序崩溃。为了保持应用程序的稳定性,您应该事先测试迁移。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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");
}
};

注意:如果您不提供必要的迁移,则Room会重新构建数据库,这意味着您将丢失数据库中的所有数据。

参考文章