Android Room Orm框架学习

原文地址

Room持久化库

Room为SQLite提供一个抽象层,在充分利用SQLite的同时,允许流畅的数据库访问

注意:引入Room到你的android工程,参看 adding components to your project

应用处理大量的结构化数据能够从本地持久化数据获益很多,最通用的例子是缓存相关的数据碎片。那样,当设备不能访问网络的时候,用户仍然可以浏览内容。任何用户发起的内容改变在设备恢复网络的时候同步到服务器上。

核心框架对raw SQL内容提供嵌入支持。尽管这些APIs是很给力的,但是他们相当低级并且需要大量的时间和精力去使用:

  • raw SQL查询没有编译时验证。当你的数据图改变,你需要手动的更新受影响的SQL查询。这个过程是耗时的和容易出错的。
  • 你需要使用大量的样板代码在数据查询和java数据对象之间转换

Room为你处理这些问题。在Room中有三个主要组件。

  • Database: 你可以使用这个组件创建一个数据库holder。注解定义了一系列entities并且类的内容提供了一系列DAOs,它也是下层的主要连接 的访问点。
    注解的类应该是一个抽象的继承 RoomDatabase的类。在运行时,你能获得一个实例通过调用Room.databaseBuilder()或者 Room.inMemoryDatabaseBuilder()
  • Entity:这个组件代表了一个持有数据行的类。对于每个entity,一个数据库表被创建用于持有items。你必须引用entity类通过Database类中的entities数组。每个entity字段被持久化到数据库中除非你注解它通过@Ignore.

注意:Entities能够有一个空的构造函数(如果dao类能够访问每个持久化的字段)或者一个参数带有匹配entity中的字段的类型和名称的构造函数,例如一个只接收其中一些字段的构造函数。

  • DAO:这个组件代表了一个类或者接口作为DAO。DAOs 是Room中的主要组件,并且负责定义访问数据库的方法。被注解为@Database的类必须包含一个没有参数的抽象方法并且返回注解为@Dao的类。当在编译时生成代码,Room创建一个这个类的实现。

注意:使用DAO类访问数据库而不是query builders或者直接查询。你可以把数据库分成几个组件。还有,DAOs允许你轻松的模拟数据库访问当你测试你的应用的时候。

这些组件和rest app的关系,如图1.

Paste_Image.png

图1:room 架构图
如下代码片段包含一个数据库配置的例子、一个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();
}

通过创建以上文件,你可以使用如下代码创建一个数据库实例:

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

注意:你必须遵守单例模式当初始化一个AppDatabase对象,因为每个RoomDatabase实例是相当昂贵的,并且你几乎不需要访问多个实例。

Entities

当一个类被注解为@Entity并且引用到带有@Database 注解的entities属性,Room为这个数据库做的entity创建一个数据表。
默认情况下,Room为每个定义在entity中的字段创建一个列。如果一个entity的一些字段你不想持久化,你可以使用@Ignore注解它们,像如下展示的代码片段:

@Entity
class User {
    @PrimaryKey
    public int id;

    public String firstName;
    public String lastName;

    @Ignore
    Bitmap picture;
}

为了持久化一个字段,Room必须有它的入口。你可以使字段为public,或者你可以提供一个setter或者getter。如果你使用setter或者getter方法,记住在Room中他们遵守Java Beans的惯例。

Primary Key(主键)

每个entity必须定义至少一个字段作为主键。即使这里只有一个字段,你仍然需要使用@PrimaryKey注解这个字段。并且,如果你想Room动态给entities分配IDs,你可以设置@PrimaryKey’s 的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使用字段名称作为列名称。如果你希望一个列有不同的名称,为字段增加@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 and 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和last列的值集合。

@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;
}

Relationships

因为SQLite是个关系型数据库,你能够指明两个对象的关系。虽然大多数ORM库支持entity对象引用其他的。Room明确的禁止这样。更多细节请参考 Addendum: No object references between entities.

即使你不能使用直接关系,Room仍然允许你定义外键约束在两个entities中。

例如:如果有一个entity叫book,你可以定义它和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;
}

外键是十分强大的,因为它们允许你指明当引用的entity被更新后做什么。例如,你可以让SQLite为一个user删除所有的书籍如果相应的user实例被删除了通过包含@ForeignKey注解的onDelete=CASCADE属性

注意:SQLite处理@Insert(OnConflict=REPLACE) 作为一个REMOVE和REPLACE操作而不是单独的UPDATE操作。这个替换冲突值的方法能够影响你的外键约束。更多细节,参看 SQLite documentation

Nested objects

有时,你希望entity或者POJOs作为一个整体在你数据库的逻辑当中,即使对象包含几个字段。在这种情况下,你可以使用@Embedded注解去代表一个你希望分解成一个表中的次级字段的对象。接着你就可以查询嵌入字段就像其他单独的字段那样。

例如,我们的user类能够包含一个代表了street,city,state,postCode的组合字段Address。为了分别的保存组合列,包括被@Embedded注解的user类中的Address字段,如下所示:

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表示了一个包含如下名称列的User对象:id,firstName,street,state,city和post_code。

注意:嵌入字段也包括其他嵌入字段

如果一个字段有多个同一类型的嵌入字段,你能保持每个列是独一无二的通过设置prefix属性。Room然后将所提供的值添加到嵌入对象中每个列名的开头

Data Access Objects (DAOs)

Room中的主要组件是Dao类。DAOs抽象地以一种干净的方式去访问数据库。

注意:Room不允许在主线程中访问数据库除非你在建造器中调用allowMainThreadQueries(),因为它可能长时间的锁住UI。异步查询(返回LiveData或者RxJava流的查询)是从这个规则中豁免的因为它们异步的在后台线程中进行查询。

Methods for convenience(惯例方法)

这里有很多你可表示的查询惯例使用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方法接收只有一个参数,它可以返回一个插入item的新rowId 的long值,如果参数是一个集合的数组,它应该返回long[]或者List<Long>

更多细节,参看文档 @Insert
注解,和 SQLite documentation for rowid tables

Update

Update 是更新一系列entities集合、给定参数的惯例方法。它使用query来匹配每个entity的主键。如下代码说明如何定义这个方法:

@Dao
public interface MyDao {
    @Update
    public void updateUsers(User... users);
}

尽管通常不是必须的,你能够拥有这个方法返回int值指示数据库中更新的数量。

Delete

Delete是一个从数据库中删除一系列给定参数的entities的惯例方法。它使用主键找到要删除的entities。如下所示:

@Dao
public interface MyDao {
    @Delete
    public void deleteUsers(User... users);
}

尽管通常不是必须的,你能够拥有这个方法返回int值指示数据库中删除的数量。

Methods using @Query

@query 是DAO类中使用的主要注解,它允许你执行读/写操作在数据库中。每个@Query方法在编译时被校验,所以如果查询出了问题,将在编译时出现而不是运行时。

  • 它给出警告如果仅有一些字段匹配
  • 它报错如果没有字段匹配

查询示例:

@Dao
public interface MyDao {
    @Query("SELECT * FROM user")
    public User[] loadAllUsers();
}

这是载入所有用户的非常简单的查询例子。在编译时,Room知道这是查询user表中的所有列。如果查询包含语法错误,或者如果用户表不存在,Room在你app编译时会报出合适的错误消息。

往查询中传入参数:
大多数时间,你需要传入参数到查询中去过滤操作,例如只展示比一个特定年龄大的用户,为了完成这个任务,在你的Room注解中使用方法参数,如下所示:

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

当这个查询在编译器被处理,Room匹配: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);
}

Returning subsets of columns(返回列中的子集)

多数时候,你仅仅需要获取一个entity中的部分字段。例如,你的UI可能只展示user’s第一个和最后一个名称,而不是所有关于用户的细节。你保存有价值的资源通过获取展示在你app’s的UI的列,你的查询完成的更快。
Room允许你返回任何java对象从查询中只要列结果集能够被映射到返回的对象中。例如:
你能够创建如下POJO通过拿取用户的姓和名。

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

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

现在,你可以使用这个POJO在你的查询方法中:

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

Room理解查询返回first_name和last_name的列值并且这些值被映射到NameTuple类的字段中。因此,Room能够生成合适的代码。如果查询返回太多columns,或者一个列不存在,Room将会报警。

注意:这些POJOs也使用@Embedded注解

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

你经常希望你的app’sUI自动更新当数据发生改变。为了实现这点,使用返回值类型为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);
}

注意:在1.0版本,Room使用被访问的table列表在查询中决定是否更新数据对象。

RxJava

Room也能返回RxJava2 Publisher和Flowable对象从你定义的查询当中。为了使用这个功能,添加android.arch.persistence.room:rxjava2 到你的build Gradle依赖。你能够返回Rxjava2定义的对象,如下所示:

@Dao
public interface MyDao {
    @Query("SELECT * from user where id = :id LIMIT 1")
    public Flowable<User> loadUserById(int id);
}

Direct cursor access(直接游标访问)

如果你的应用逻辑直接访问返回的行,你可以返回一个Cursor对象从你的查询当中,如下所示:

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

注意:非常不建议使用Cursor API 因为它不能保证行是否存在或者行包含什么值。使用这个功能仅仅是因为你已经有期望返回一个cursor的代码并且你不能轻易的重构。

Querying multiple tables

你的一些查询可能访问多个表去计算结果。Room允许你写任何查询,所以你也能连接表格。还有,如果答复是一个observable数据类型,例如Flowable或者LiveData,Room监视所有被查询中被引用的无效的表格。

如下代码段展示如何执行一个表格连接去联合当前正在借出的书和借的有书的人的信息。

@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;
   }
}

Using type converters (使用类型转换)

Room为原始类型和可选的装箱类型提供嵌入支持。然而,有时你可能使用一个单独存入数据库的自定义数据类型。为了添加这种类型的支持,你可以提供一个把自定义类转化为一个Room能够持久化的已知类型的TypeConverter。
例如:如果我们想持久化日期的实例,我们可以写如下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能够使用你已经为每个entity定义的转换器和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);
}

您还可以将@typeconverter限制在不同的范围内,包含单独的entities,DAOs,和DAO methods。更多细节,请参考 @TypeConverters
文档

Database migration

当你添加或改变你app的特性,你需要修改你的entity类去反映这些改变。当一个用户更新你应用到最近的版本,你不希望他们丢失已经存在的数据,特别是你无法从远程服务器恢复数据。
Room允许你使用Migration类保留用户数据以这种方式。每个Migration类在运行时指明一个开始版本和一个结束版本,Room执行每个Migration类的migrate()方法,使用正确的顺序去迁移数据库到一个最近版本。

注意:如果你不提供必需的migrations类,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");
    }
};

注意:为了保持你的迁移逻辑与预期一致,使用完全查询而不是代表查询的引用常量。
当迁移过程结束,Room验证schema去保证迁移成功。如果Room发现问题,它将抛出不匹配异常。

Testing migrations

迁移并不是一件简单的事情,如果不能正确编写将会造成应用崩溃。为了保证你应用的稳定性,你应该在提交前测试你的迁移类。Room提供一个测试Maven组件去协助测试过程。然而,为了让这个组件工作,你需要到处你的数据库schema。

Exporting schemas

根据编译,Room导出你的数据库Schema到一个JSON文件中。为了导出schema,设置 注释处理器的属性room.schemaLocation在你的build.gradle文件中,如下所示:
build.gradle

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

你应该存储导出的JSON文件-代表了你数据库schema的历史-在你的版本控制系统中,正如它允许创建老版本的数据库去测试。

为了测试这些migrations,添加 android.arch.persistence.room:testing Maven artifac从Room当中到你的测试依赖当中,并且把schema 位置当做一个asset文件添加,如下所示:
build.gradle

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

测试package提供一个 可以读取这些schema文件的MigrationTestHelper类。它也是Junit4 TestRule类,所以它能管理创建的数据库。
如下代码展示了一个测试migration的例子:

@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.
    }
}

Testing your database

当运行你app的测试时,你不应该创建一个完全的数据库如果你不测试数据库本身。Room允许你轻松的模仿数据访问层在测试当中。这个过程是可能的因为你的DAOs不暴漏任何你数据库的细节。当测试你的应用,你应该创建模仿你的DAO类的假的实例。
这儿有两种方式去测试你的数据库:

  • 在你的开发主机上
  • 在一个Android设备上

Testing on your host machine

Room使用SQLite支持库,这个支持库提供匹配这些Android Framework类的接口并且允许你通过自定义支持库实现去测试你的数据库查询。
即使这个装置允许你的测试运行很快,它是不建议的因为用户设备的SQLite版本和可能与host主机不匹配。

Testing on an Android device

测试你的数据库推荐的方法实现是写一个单元测试在Android设备上。因为这些测试不需要创建一个activity,他讲bicentennialUI单元测试快。
当装置你的测试用例时,你应该创建一个数据库的内存版本好让你的测试更密闭,如下所示:

@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));
    }
}

更多关于测试数据库migrations的信息参看 Migration Testing

推荐阅读更多精彩内容