这是一篇自己从Android开发文档中翻译来的关于
Room的文档。哪里不对,欢迎纠错
Room 持久化库
Room持久性库提供了SQLite的抽象层,以便在充分利用SQLite的同时允许流畅的数据库访问。
该库可帮助你在设备上创建应用程序的缓存数据,这样不管设备是否联网都能看到数据。
使用 Room 在本地保存数据
原文地址 https://developer.android.com/training/data-storage/room/index.html
对于不重要的数据可以存储在本地,最常见的就是缓存相关的数据。这样,在设备没有网络的时候就可以浏览离线数据。当设备联网后,将用户改动的数据同步至服务端。
Room 有三个重要组件
- Database
- Entity
- DAO
Database
包含数据库持有者,并作为与应用持久关联数据的底层连接的主要接入点。
使用@Database注解,并满足以下条件
- 是抽象类,并且继承自
RoomDatabase - 在注解中包含与数据库关联的实体列表。
- 包含一个具有0个参数的抽象方法,并返回用@Dao注解的类。
在运行时,可以通过调用Room.databaseBuilder()或Room.inMemoryDatabaseBuilder()来获取数据库实例。
Entity
表示数据库中的表格
DAO
包含用户访问数据库的方法
这些组件以及组件与APP其他部分的关系 如图所示
下面的代码片段是一个数据库实例配置包含了一个Entity和一个DAO:
User.java1
2
3
4
5
6
7
8
9
10
11
12
13
14@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.java1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18@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
1 | @Database(entities = {User.class}, version = 1) |
创建完完成后使用以下代码获取数据库实例:
1 | AppDatabase db = Room.databaseBuilder(getApplicationContext(), |
数据库实例最好是单例以节省内存开销
使用 Room 实体定义数据
原文地址 https://developer.android.com/training/data-storage/room/defining-data.html
我们定义的每一个实体,Room 都会对应的在数据库中创建一个表。
默认 Room 会为 每个字段在表中创建对应的字段;如果其中一些属性不想被创建在表中怎么办,那就是使用 @Ignore 注解此属性。完成实体的创建之后必须在 Database 引用。
1 |
|
类中的每个字段都必须让Room能够访问到。否则Room无法管理。
[^] 注意 :要遵循 JavaBean 规约;否则 管杀不管埋;[^]
定义主键
每个实体必须定义最少一个主键,就算类中只有一个字段,也要保证使用 @PrimaryKey;
如果想让Room自动分配ID,可以设置 autoGenerate 为true;
如果是联合主键,可以在@Entity中设置 primaryKeys 属性。
1 | (primaryKeys = {"firstName", "lastName"}) |
默认Room会使用类名当作数据库表名,如果你想设置其他名字,可以设置 tableName 属性
1 | (tableName = "users") |
[^]Sqlite中表名不区分大小写[^]
就像表名一样,字段的名字默认的也是类中属性的名字如果想设置其他名字,可使用 @ColumnInfo 的 name属性
1 | (tableName = "users") |
注解索引和唯一约束
使用 @Entity 的 indices 来创建索引,并列出索引或者组合索引包含的列;
1 | (indices = {("name"), |
使用 @Index 注解 和 unique 属性设置 唯一约束。
下面代码 firstName 和 lastName 两列组合唯一索引
1 | (indices = {(value = {"first_name", "last_name"}, |
定义对象间的关联关系
由于Sqlite 是关系型数据库,我们可以指定对象间的关系。大部分的ORM框架也都支持对象间相互引用。但是 Room 明确禁止这样做。至于为什么明确禁止,文章最后会说。原文链接:https://developer.android.com/training/data-storage/room/referencing-data.html#understand-no-object-references
虽然不能直接定义对象间引用,但是可以使用外键建立关系。
例如:有一个 Book 实体,可以使用 @ForeignKey 关联到 User 实体。下面代码演示使用1
2
3
4
5
6
7
8
9
10
11
12(foreignKeys = (entity = User.class,
parentColumns = "id",
childColumns = "user_id"))
class Book {
public int bookId;
public String title;
(name = "user_id")
public int userId;
}
@ForeignKey 是非常强大的,我们可以定义对象间的级联操作。例如可以在注解中设置 onDelete = CASCADE,当删除用户的的时候就会把用户所关联的书都删掉了。
[^]SQLite将@Insert(onConflict = REPLACE)作为一组REMOVE和REPLACE操作处理,而不是单个UPDATE操作。这种替换冲突值的方法可能会影响外键约束。有关更多详细信息,请参阅ON_CONFLICT子句的SQLite文档。[^]
创建嵌套对象
Room 支持在数据实体中嵌套其他对象来组合相关字段。例如 User 中嵌套一个 Address 这个地址对象中有三个字段:街道,城市,邮编。在数据表中这个三个字段是在用户表中的,就像其他字段一样。
通过在 User 使用 ` 注解 属性address` 即可。
1 | class Address { |
表示User对象的表格包含具有以下名称的列:id,firstName,street,state,city和post_code。
[^] 嵌套字段可以嵌套其他字段[^]
如果数据实体中有多个 嵌套字段,可以通过设置属性 prefix 加前缀的方式保证字段名不重复。
如果在 User 中使用下面的代码,那么嵌套字段就会是 address_street,address_state,address_city和address_post_code1
2(prefix = "address_")
public Address address;
使用 Room DAO 访问数据
原文地址:https://developer.android.com/training/data-storage/room/accessing-data.html
Room 使用数据对象和 DAO 访问数据库。
DAO 是 Room 的重要组件,他包含了操作数据的抽象方法;
DAO可以是一个接口或者抽象类,如果是抽象类的话,它可以有一个构造函数,它将RoomDatabase作为其唯一参数。Room会在编译时创建实现。
DAO不能在主线程的时候操作数据,可能会阻塞UI,除非在构建的时候调用 allowMainThreadQueries()。如果是返回 LiveData或者 Flowable 的异步查询例外。
定义操作方法
这里只列出几个常用方法
Insert
当创建一个DAO方法并使用它的时候,Room会生成它的实现并在单个事物中将所有参数插入。
1 |
|
如果 @Insert 只接受到一个参数,他会返回一个新插入行的 long类型的 rowid。如果参数是 一个数组和集合就会返回一个long类型的数组或集合。
关于 @Insert 的详细介绍查看文档 https://developer.android.com/reference/android/arch/persistence/room/Insert.html
Update
Room 会通过每个实体的主键进行查询,然后再进行修改。
返回值可以是一个 int 型的值,返回更新的行数。1
2
3
4
5
public interface MyDao {
public void updateUsers(User... users);
}
Delete
Room 会数据实体的主键删除相应的数据。
返回值可以是一个 int 型的值,用来表示删除的行数。1
2
3
4
5
public interface MyDao {
public void deleteUsers(User... users);
}
查询信息
@Query 是 DAO 中主要使用的注解。它可以执行对数据库的读写操作。每一个 @Query 方法都会在编译时验证,如果出现问题也是在编译时出现问题不会在运行时出现问题。
Room 也会验证方法的返回值,如果返回对象中的字段名称和查询响应中的字段名字不匹配, Room 会通过以下方式给出提示
- 如果只有一些字段名称不匹配,会发出警告
- 如果没有字段名称匹配,会发出错误。
简单查询
1 | @Dao |
这是一个非常简单的查询所有用户的查询。在编译时,Room会知道是查询用户表的所有列。如果查询包含语法错误或者数据库中不存在这个表。Room会在编译时报错并给出错误信息。
将参数传递给查询
大部分时候查询都是需要过滤参数的。比如要查询一些年龄比较大的用户。1
2
3
4
5
public interface MyDao {
("SELECT * FROM user WHERE age > :minAge")
public User[] loadAllUsersOlderThan(int minAge);
}
在编译时,Room会将 :minAge 与方法参数匹配绑定。 Room使用参数名字匹配,如果匹配不上给出错误提示。
也可以传递多个参数或者引用多次:1
2
3
4
5
6
7
8
9
public interface MyDao {
("SELECT * FROM user WHERE age BETWEEN :minAge AND :maxAge")
public User[] loadAllUsersBetweenAges(int minAge, int maxAge);
("SELECT * FROM user WHERE first_name LIKE :search "
+ "OR last_name LIKE :search")
public List<User> findUserWithName(String search);
}
返回列的子集
很多时候只需要数据实体的中几个列。例如你可能只想显示用户的姓和名而不是全部的用户信息。只查询需要的列可以节省资源并且查询的更快。
Room 允许返回任何的Java对象。只要查询的结果列能够和Java对象映射上即可。所以我们可以创建一个只包含需要的列的类。
1 | public class NameTuple { |
使用这个 POJO
1 |
|
Room 知道查询的值并知道怎么映射到对应的NameTuple字段中。所以 Room 会生成正确的代码。如果查询返回的列多了或者少了,Room会给出警告
这里也可以使用@Embedded注解
传递参数集合
有时候查询的参数数量是动态的,只有运行的时候才知道。例如只查询某些地区的用户。
当参数是一个集合的时候,Room 会在运行的时候自动扩展它。
1 |
|
可观察的查询
在执行查询时,我们经常想让UI在数据更改时自动更新。要实现这一点,可以在查询方法使用 LiveData 类行的返回值。当数据更新时 Room 会自动生成所需的代码已更新LiveData。
1 |
|
从版本1.0开始,Room使用查询中访问的表的列表来决定是否更新LiveData的实例。
使用 RxJava 进行响应查询
Room还可以从定义的查询中返回 RxJava2 的 Publisher 和 Flowable 对象。要使用此功能,需要将 Room 组中的 android.arch.persistence.room:rxjava2 组件添加到构建Gradle依赖项中,添加组件之后就可以返回 Rxjava2 中的对象
1 |
|
更多 Room 和 Rxjava2 的使用 看另一篇文章 https://medium.com/google-developers/room-rxjava-acb0cd4f3757
直接访问 Cursor
1 | @Dao |
非常不推荐使用Cursor API,因为它不能保证行是否存在或行包含的值。只有当已经拥有需要游标并且无法轻松重构的代码时才使用此功能。
查询多个表
有些时候可能需要查询多个表中的数据来计算结果。Room运行我们写任何查询,当然也允许连接其他表。如果响应式可观察数据类型,例如 Flowable 或者 LiveData,Room会监视查询中的所有表,使其无效。
1 |
|
也可以从这些查询中返回POJO。例如,可以编写一个查询来加载用户及其宠物的名称,如下所示:1
2
3
4
5
6
7
8
9
10
11
12
13
14
public interface MyDao {
("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 数据库
原文 https://developer.android.com/training/data-storage/room/migrating-db-versions.html
在APP升级时可能需要更改数据库来策应新的功能。这个时候当然不希望数据库中的数据丢失。
Room 允许我们编写 Migration ,以此来迁移数据。每个迁移类制定一个开始版本和结束版本。
在运行时,Room会运行每个Migration类的migrate()方法,并使用正确的顺序将数据库迁移到更高版本。
如果不提供必要的Migration , Room 会重建数据库,所以数据会丢失
1 | Room.databaseBuilder(getApplicationContext(), MyDb.class, "database-name") |
要保持迁移逻辑按预期运行,请使用完整查询,而不是引用表示查询的常量。
在迁移完成之后,Room 验证模式会确认迁移正确进行,如果 Room 发现错误,会抛出一个包含不匹配的异常。
测试迁移
数据迁移是很重要的,一旦迁移失败可能会发生Crash。为了保证程序的稳定性,一定要确认是否否迁移成功。Room 提供了一个测试工件来帮助我们测试,为保证测试工件的正确运行,必须开启导出模式。
导出模式
编译后,Room将数据库的模式信息导出到JSON文件中。要导出模式,在build.gradle文件中设置room.schemaLocation注解处理器属性,如下面的代码片段所示:
build.gradle1
2
3
4
5
6
7
8
9
10
11
12android {
...
defaultConfig {
...
javaCompileOptions {
annotationProcessorOptions {
arguments = ["room.schemaLocation":
"$projectDir/schemas".toString()]
}
}
}
}
我们应该把导出的 json 文件加入到版本控制中,它记录了数据库的模式历史,它能让Room在测试时创建老版本的数据库。
为了测试迁移,增加 Room 的测试工件依赖,并设置数据库模式文件地址,如下所示:
1 | android { |
测试包提供了一个MigrationTestHelper类,它可以读取这些模式文件。它实现了 JUnit4 的 TestRule 接口,它能够管理已经创建的数据库。
下面是一个简单的测试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@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.
}
}
测试数据库
使用 Room 创建数据库时,验证数据库和用户数据的稳定性非常重要。
测试数据库有两种方法
- 在Android 设备上
- 在开发主机上(不推荐)
关于测试指定数据库升级的信息 上面已经说过了。
注意:在测试时,Room允许创建Dao的模拟实例。这样的话,如果不是测试数据库本身就不需要创建完整的数据库,这个功能是很好的,Dao不会泄露数据库的任何信息
在设备上测试
测试数据库实现的推荐方法是编写在Android设备上运行的JUnit测试,由于这些测试不需要创建活动,它们应该比UI测试更快执行。
在设置测试时,应该创建数据库的内存中版本,以使测试更加密封,如以下示例所示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@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));
}
}
在电脑上测试
Room使用SQLite Support Library,它提供了与Android Framework类中的接口相匹配的接口。此支持允许您传递支持库的自定义实现以测试数据库查询。
注意:即使此设置允许您的测试运行速度非常快,也不建议这样做,因为设备上运行的SQLite版本以及用户的设备可能与主机上的版本不匹配
使用Room引用复杂数据
Room提供了原始和包装类型转换的功能,但是不允许实体间对象引用。这里会解释为什么不支持对象引用和怎么使用类型转换器。
使用类型转换器
有时候你想存储自定义的数据类型在数据库的单个列中。这就需要为自定义类型添加一个类型转换器,这个转换器会将自定类型转换为Room能够认识的原始类型。
例如,我想保存Date类型的实例,我可以编写下面的类型转换器来在数据库中存储等效的Unix时间戳:1
2
3
4
5
6
7
8
9
10
11public 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对象持久化。
接下来,在AppDataBase类添加注解 @TypeConverters 这样AppDataBase中的Dao和实体就都能使用这个转换器了。
AppDatabase.java1
2
3
4
5@Database(entities = {User.class}, version = 1)
@TypeConverters({Converters.class})
public abstract class AppDatabase extends RoomDatabase {
public abstract UserDao userDao();
}
这样就可以使用自定义类型了,就像使用其他原始类型一样。
User.java1
2
3
4
5@Entity
public class User {
...
private Date birthday;
}
UserDao.java
1 | @Dao |
还可以将@TypeConverters限制到不同的作用域,包括个体实体,DAO和DAO方法。关于 @TypeConverters更详细的介绍 请查看文档 https://developer.android.com/reference/android/arch/persistence/room/TypeConverters.html
理解Room不允许使用对象引用的原因
关键问题:Room不允许实体类之间的对象引用。相反,您必须明确您的应用需要的数据。
将数据库中的关系映射到相应的对象模型是常见的做法,并且在服务器端运行良好。即使程序在访问时加载字段,服务器仍然运行良好。
但是,在客户端,这种延迟加载不可行,因为它通常发生在UI线程上,并且在UI线程中查询磁盘上的信息会产生严重的性能问题。UI线程通常具有约16 ms的时间来计算和绘制活动的更新布局,因此即使查询只需要5 ms,仍然可能您的应用程序将耗尽时间来绘制框架,从而导致明显的视觉干扰。如果有单独的事务并行运行,或者设备正在运行其他磁盘密集型任务,则查询可能需要更多时间才能完成。但是,如果不使用延迟加载,则应用会获取比所需更多的数据,从而导致内存消耗问题。
对象关系映射通常将这个决定留给开发人员,以便他们可以为他们的应用程序的用例做最好的事情。开发人员通常决定在应用程序和用户界面之间共享模型。然而,这种解决方案并不能很好地扩展,因为随着UI的变化,共享模型会产生一些难以让开发人员预测和调试的问题。
例如,考虑加载一个Book对象列表的UI,每个书都有一个Author对象。最初可能会将查询设计为使用延迟加载,以便Book的实例使用getAuthor()方法返回作者。过了一段时间,你意识到你也需要在应用程序的用户界面中显示作者姓名。您可以轻松地添加方法调用,如以下代码片段所示:1
authorNameTextView.setText(user.getAuthor().getName());
但是,这个看起来无害的更改会导致在主线程上查询Author表。
如果提前查询作者信息,如果不再需要数据,则很难更改数据的加载方式。例如,如果您的应用程序的用户界面不再需要显示作者信息,则您的应用程序会有效地加载不再显示的数据,从而浪费宝贵的内存空间。如果作者类引用另一个表(如Books),则应用程序的效率会进一步降低。
要使用Room同时引用多个实体,需要创建一个包含每个实体的POJO,然后编写一个查询来加入相应的表。这种结构良好的模型与Room强大的查询验证功能相结合,可让您的应用在加载数据时消耗更少的资源,从而改善应用的性能和用户体验。
end