,一个 SQLite 的 ORM 库,可以方便地将 Java 对象转成 SQLite 的表数据,不用再像传统方式那样写 SQLite API 的样板代码了。同时 Room 提供了 SQLite 语法的编译时检查,并且可以返回 RxJava,Flowable 和 LiveData observables。
添加依赖
// Room (use 1.1.0-beta2 for latest beta) implementation "android.arch.persistence.room:runtime:1.0.0" annotationProcessor "android.arch.persistence.room:compiler:1.0.0" // Test helpers for Room testImplementation "android.arch.persistence.room:testing:1.0.0"复制代码
基本使用
Room 主要包含三个组件:
- Database: 包含数据库持有者,作为与应用持久化相关数据的底层连接的主要接入点。这个类需要用
@Database
注解,并满足下面条件:- 必须是继承
RoomDatabase
的抽象类 - 注解中包含该数据库相关的实体类列表
- 包含的抽象方法不能有参数,且返回值必须是被
@Dao
注解的类
- 必须是继承
- Entity: 表示了数据库中的一张表
- DAO: 包含了访问数据库的一系列方法
它们与应用程序的关系如图所示:
@Entity(tableName = "products")public class ProductEntity { @PrimaryKey private int id; private String name; private String description; ...}复制代码
@Daopublic interface ProductDao { @Query("select * from products") ListgetAllProducts(); @Query("select * from products where id = :id") ProductEntity findProductById(int id); @Insert(onConflict = OnConflictStrategy.REPLACE) void insertProduct(ProductEntity product); @Insert(onConflict = OnConflictStrategy.REPLACE) void insertAllProducts(List products); @Delete void deleteProduct(ProductEntity product);}复制代码
@Database(entities = {ProductEntity.class}, version = 1)public abstract class AppDatabase extends RoomDatabase { public abstract ProductDao productDao();}复制代码
AppDatabase appDatabase = Room.databaseBuilder(this, AppDatabase.class, "product.db").build(); ProductDao productDao = appDatabase.productDao(); ... ListallProducts = productDao.getAllProducts(); ... productDao.insertProduct(productEntity);复制代码
每个 entity 都代表了一张表,其中的字段代表表中的一列。注解处理器会自动生成 AppDatabase
和 ProductDao
对应的实现类 AppDatabase_Impl
和 ProductDao_Impl
。可以通过调用Room.databaseBuilder()
或 Room.inMemoryDatabaseBuilder()
在运行时获取Database
实例,但要注意,实例化 RoomDatabase
是相当昂贵的,最好按照单例模式只创建一个Database
实例。
定义 Entity
为了让 Room 可以访问 entity,entity 中的字段必须是 public 的,或者提供了getter/setter方法。默认情况下,Room 会将 entity 中的每个字段作为数据库表中一列,如果你不想持久化某个字段,可以使用 @Ignore
注解。默认数据库表名为 entity 类名,你可以通过 @Entity
注解的 tableName
属性 更改,默认列名是字段名,你可以通过 @ColumnInfo
注解更改。
主键
每个 entity 必须至少有一个字段作为主键(primary key),即使该 entity 只有一个字段。使用 @PrimaryKey
注解来指定主键,如果你希望 SQLite 帮你自动生成这个唯一主键,需要将 @PrimaryKey
的 autoGenerate
属性设置成 true
,不过需要改列是 INTEGER
类型的。如果字段类型是 long
或 int
,Insert
方法会将 0 作为缺省值,如果字段类型是 Integer
或 Long
类型,Insert
方法会将 null 作为缺省值。
@Entity
注解的 primaryKeys
属性定义这个约束,如: @Entity(primaryKeys = { "firstName", "lastName"})class User { public String firstName; public String lastName; public String avatar;}复制代码
索引
有些时候,我们需要添加索引以加快查询速度,可以使用 @Entity
注解的 indices
属性创建索引,如果某个字段或字段组是唯一的,可以将 @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;}复制代码
关系
SQLite 是关系型数据库,很多时候我们需要指定对象间的关系。即使大多数 ORM 库允许实体类对象间相互引用,但 Room 明确禁止这样做。因为级联查询不能发生在 UI 线程,UI 线程只有 16 ms 时间计算和绘制布局,所以即使一个查询只花费 5 ms,你的应用仍可能因此绘制超时,造成明显的视觉问题。而且如果此时还有其他的数据库事务正在运行或者设备正在运行其他磁盘敏感任务,那么该查询将花费更多的时间。而如果你不使用懒加载,你的应用将不得不去获取比所需要的更多的数据,从而产生内存占用问题。
ORM 库通常把这个决定权交给开发者,以便开发者根据自己应用的情况采取措施,而开发者通常会决定在应用和 UI 之间共享 model,然而,这种解决方案并不能很好地扩展,因为随着UI的变化,共享 model 会产生一些难以让开发人员预测和调试的问题。 例如,UI 加载了Book
对象列表,每个 book 都有一个 Author
对象,你可能最开始想采用懒加载的方式获取 Book
实例(使用getAuthor()
方法获取 author),第一次调用 getAuthor()
会调用数据库查询。过一会,你意识到你需要在 UI 上显示作者名,你写了下面这样的代码: authorNameTextView.setText(user.getAuthor().getName());复制代码
这看似正常的变更会导致 Author
表在主线程中被查询。那提前查询好作者信息是不是就行了呢?明显不行,如果你不再需要这些数据,就很难改变数据的加载方式了。例如,如果你的 UI 不再需要显示作者信息了,你的应用仍然会加载这些不需要的数据,从而浪费昂贵的内存空间,如果 Author
又引用了其他表,那么应用的效率将会进一步降低。
Book
entity 有一个作者的外键引用 User
,可以通过 @ForeignKey
注解指定这个外键约束: @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;}复制代码
可以通过 @ForeignKey
注解的 onDelete
和 onUpdate
属性指定级联操作,如级联更新和级联删除:
@Entity(foreignKeys = @ForeignKey(entity = User.class, parentColumns = "id", childColumns = "user_id", onUpdate = ForeignKey.CASCADE, onDelete = ForeignKey.CASCADE))复制代码
有时,一个包含嵌套对象的 entity 或 POJO 表示一个完整的数据库逻辑,可以使用 @Embedded
注解将该嵌套对象的字段分解到该表中,如 User
表需要包含 Address
相关字段,可以使用 @Embedded
注解表示这是个组合列:
public class Address { public String street; public String state; public String city; @ColumnInfo(name = "post_code") public int postCode;}@Entitypublic class User { @PrimaryKey public int id; public String firstName; @Embedded public Address address;}复制代码
也就是说, User
表包含 id
,firstName
,street
,state
,city
,和 post_code
列。
Address
类型的,可以使用 @Embedded
注解的 prefix
属性添加列名前缀以保证列的唯一性。 使用 DAO
DAO(data access objects)是应用中操作数据库的最直接的接口,应用中对数据库的操作都表现在这个对象上,也就是说,应用不需要知道具体的数据库操作方法,只需要利用 DAO 完成数据库操作就行了,所以这一系列 Dao
对象也构成了 Room 的核心组件。DAO 可以是个接口,也可以是个抽象类,如果是个抽象类,那么它可以有个构造器,以 RoomDatabase
作为唯一参数,Room 会在编译时自动生成每个 DAO 的实现类。
新增
定义一个用 @Insert
注解的 DAO 方法,Room 会自动生成一个在单个事务中将所有参数插入数据库的实现,如果方法只有一个参数,那么它可以返回 long
类型的 rowId
,如果方法参数是数组或集合,那么它可以返回 long[]
或 List<Long>
:
@Daopublic 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, Listfriends);}复制代码
更新
@Update
注解的方法可以更改一系列给定的 entity, 使用匹配的主键去查询更改这些 entity,可以返回 int
型的数据库更新行数:
@Daopublic interface MyDao { @Update public void updateUsers(User... users);}复制代码
删除
@Delete
注解的方法可以删除一系列给定的 entity, 使用匹配的主键去查询更改这些 entity,可以返回 int
型的数据库删除行数:
@Daopublic interface MyDao { @Delete public void deleteUsers(User... users);}复制代码
查询
@Query
注解的方法可以让你方便地读写数据库,Room 会在编译时验证这个方法,所以如果查询有问题编译时就会报错。Room 还会验证查询的返回值,如果查询响应的字段名和返回对象的字段名不匹配,如果有些字段不匹配,你会看到警告,如果所有字段都不匹配,你会看到 error。下面是一个简单的查询,查询所有的用户:
@Daopublic interface MyDao { @Query("SELECT * FROM user") public User[] loadAllUsers();}复制代码
如果你想要添加查询条件,可以使用 :
加 参数名
的方式获取参数值:
@Daopublic 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 ListfindUserWithName(String search);}复制代码
当然,查询条件集合也是支持的:
@Daopublic interface MyDao { @Query("SELECT first_name, last_name FROM user WHERE region IN (:regions)") public ListloadUsersFromRegions(List regions);}复制代码
很多时候,我们不需要查询表中的所有字段,我们只用到了 UI 用到的那几列,为了节省资源,也为了加快查询速度,我们就可以定义一个包含用到的字段的 POJO(这个 POJO 可以使用 @Embedded
注解) ,查询方法可以使用这个 POJO:
public class NameTuple { @ColumnInfo(name="first_name") public String firstName; @ColumnInfo(name="last_name") public String lastName;}复制代码
@Daopublic interface MyDao { @Query("SELECT first_name, last_name FROM user") public ListloadFullName();}复制代码
Room 也允许你方便地进行多表查询,如查询某个用户所借的所有书籍信息:
@Daopublic 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 ListfindBooksBorrowedByNameSync(String userName);}复制代码
多表查询也能使用 POJO,如查询用户名和他的宠物名:
@Daopublic interface MyDao { @Query("SELECT user.name AS userName, pet.name AS petName " + "FROM user, pet " + "WHERE user.id = pet.user_id") public LiveData
> 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; }}复制代码
查询方法的返回值可以是 LiveData
以便你能随着数据库的更新实时更新 UI,返回值也可以是 RxJava2
的 Publisher
或 Flowable
(需要添加 android.arch.persistence.room:rxjava2
依赖),甚至可以是 Cursor
(不建议直接使用 Cursor API )。
数据库的更新与迁移
随着应用功能的改变,你需要去更改 entity 和数据库,但很多时候,你不希望因此丢失数据库中已存在的的数据,尤其是无法从远程服务器恢复这些数据时。也就是说,如果你不提供必要的迁移操作,Room 将会重建数据库,数据库中所有的数据都将丢失。
为此, Room 允许你写一些Migration
类去保护用户数据,每个 Migration
类指定一个 startVersion
和 endVersion
,在运行时,Room 会运行每个 Migration
类的 migrate()
方法,以正确的顺序将数据库迁移到最新版本: 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 会验证 schema 以确保迁移正确的完成了,如果 Room 发现了问题,会抛出一个包含不匹配信息的异常。
迁移数据库是很重要也是无法避免的操作,如果迁移出错可能会导致你的应用陷入崩溃循环,为了保持应用的稳定性,你必须提前测试好迁移的整的过程。为了更好地测试,你需要添加android.arch.persistence.room:testing
依赖,并且你需要导出数据库的 schema。在编译时,Room 会将你数据库的 schema 信息导出为 JSON 文件。为了导出 schema,你需要在 build.gradle
文件中设置 注解处理器属性room.schemaLocation
: android { ... defaultConfig { ... javaCompileOptions { annotationProcessorOptions { arguments = ["room.schemaLocation": "$projectDir/schemas".toString()] } } }}复制代码
你需要将这个导出的 JSON 文件保存在版本控制系统中,因为这个文件代表了数据库的 schema 历史记录。同时你需要添加 schema 位置作为 asset 文件夹:
android { ... sourceSets { androidTest.assets.srcDirs += files("$projectDir/schemas".toString()) }}复制代码
测试工具中的 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.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. }}复制代码
数据库的测试
写 JUnit 测试通常比 UI 测试更快更直观,利用 Room.inMemoryDatabaseBuilder
构造 in-memory 版本的数据库可以让你的测试更封闭:
@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); ListbyName = mUserDao.findUsersByName("george"); assertThat(byName.get(0), equalTo(user)); }}复制代码
高级用法与技巧
TypeConverter
有些时候,我们需要把一些自定义数据类型存入数据库,或者在存入数据库前做一些类型转换,如我们需要把 Date
类型的字段作为 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(); }}复制代码
然后使用 @TypeConverters
注解那些需要使用转换器的元素。如果注解了 Database
,那么数据库中所有的 Dao
和 Entity
都能使用它。如果注解了 Dao
,那么 Dao 中所有的方法都能使用它。如果注解了 Entity
,那么 Entity 中所有的字段都能使用它。如果注解了 POJO,那么 POJO 中所有的字段都能使用它。如果注解了 Entity
字段,那么只有这个 Entity 字段能使用它。如果注解了 Dao
方法,那么该 Dao 方法中所有的参数都能使用它。如果注解了 Dao
方法参数,那么只有这个参数能使用它:
@Database(entities = {User.class}, version = 1)@TypeConverters({Converters.class})public abstract class AppDatabase extends RoomDatabase { public abstract UserDao userDao();}复制代码
查询的时候,你仍然可以用你的自定义类型,就像使用原语类型一样:
@Daopublic interface UserDao { ... @Query("SELECT * FROM user WHERE birthday BETWEEN :from AND :to") ListfindUsersBornBetweenDates(Date from, Date to);}复制代码
Database 对象的创建
实例化 RoomDatabase
是相当昂贵的,最好使用 Dagger2
等依赖注入工具注入唯一的 Database
实例,如:
@Module(includes = ViewModelModule.class)class AppModule { ... @Singleton @Provides GithubDb provideDb(Application app) { return Room.databaseBuilder(app, GithubDb.class,"github.db").build(); } @Singleton @Provides UserDao provideUserDao(GithubDb db) { return db.userDao(); } @Singleton @Provides RepoDao provideRepoDao(GithubDb db) { return db.repoDao(); }}复制代码
即使不使用依赖注入,也应该采用单例的方式创建 Database:
@Database(entities = {User.class}, version = 1)public abstract class AppDatabase extends RoomDatabase { private static volatile AppDatabase INSTANCE; public abstract UserDao userDao(); public static AppDatabase getInstance(Context context) { if (INSTANCE == null) { synchronized (AppDatabase.class) { if (INSTANCE == null) { INSTANCE = Room.databaseBuilder(context.getApplicationContext(), AppDatabase.class, "sample.db") .build(); } } } return INSTANCE; }}复制代码
线程切换
操作数据库是个非常耗时操作,所以不能在主线程(UI线程)中查询或更改数据库,Room 也为此做了线程检查,如果你在主线程中操作了数据库会直接抛出异常。为了方便,Room 还允许你在查询操作中直接返回 LiveData
或 RxJava
的 Publisher
和 Flowable
。