天天看点

Android Arch Comp - Room Persistence LibraryRoom Persistence Library

Room Persistence Library

Room数据持久化库

Room在SQLite之上提供了一个抽象层,能够非常方便的接入数据库和使用SQLite的全部功能。

注:如何在项目中引入Room请参考adding components to your project.

应用可以很方便地通过本地持久化的数据加载少量的结构化数据。最常见的使用场景就是缓存用户当前交互界面的相关数据。这样,当移动设备无法访问网络时,用户仍然可以查看离线后的界面和数据,提高了用户体验。任何用户在离线后的数据更改都会在网络重新连接时和服务器进行同步。

Android核心框架中提供了在构建时对原始的SQL语句支持。这些API的功能相当强大,但是相对比较低级,需要花大量的时间和精力去学习和使用。

  • 未在编译时对SQL脚步语句进行校验。如果你的数据结构发生变化,你需要手动去修改SQL脚步语句。这个过程很难免会发生错误,且很不方便,除非你是一个很有经验的老手。
  • 你需要使用很多模版代码来转换SQL查询和Java数据结构

Room所提供的抽象层中为你考虑到了以上两点,并完美的解决了。

以下是Room三个主要的组件:

  • Database :你可以使用这个组件来创建数据库持有者。这个注解定义了一系列的实体,并且这个类中还定义了一系列数据库接入对象。这也是数据库基础连接的入口。

    被注解的类必须是一个抽象类并继承RoomDatabase。在运行时,可以通过调用Room.databseBuilder()和Room.inMemoryDatabaseBuilder()两个API获取数据库实例。

  • Entity : 这个组件代表了数据库中的一条记录即表中的一行。对于每个Entity,创建数据库表来持有这些Entity的具体数据。必须通过数据库类中的实体数组引用实体类。Entity的每个字段都保存在数据库中,除非用@注释来忽略它。

    注:Entity可以有一个空构造函数(如果DAO类可以访问每个持久化字段),或者构造函数的参数包含与实体中的字段匹配的类型和名称。Room可以使用全部或部分构造函数,例如只接收一些字段的构造函数。

  • DAO :该组件表示类或接口作为数据访问对象(DAO)。DAO是Room的主要成分,是负责定义访问数据库的方法(增删查改)。使用@Database注解的类必须包含一个抽象方法,该方法有0个参数,并返回用@Dao注解的类。当在编译时生成代码时,Room会为注解的类创建这个类的一个实例。

    注:通过使用DAO而不是查询构建器或直接查询访问数据库,可以分离数据库架构中的不同组件。此外,DAO可以让你轻松模拟数据库访问以此来测试应用程序。

下图展示类Room的这些组件和应用其他部分的相互关系:

Android Arch Comp - Room Persistence LibraryRoom Persistence Library

接下来的代码片段将包含一个简单的数据库,这个数据库配置了一个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 ")
    User findByName(String first, String last);

    @Insert
    void insertAll(User... users);

    @Delete
    void delete(User user);
}
           

AppDatabase.java

@Database(entities = {User.class}, version = )
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中的每个成员变量创建数据表中的一列。如果有哪些成员变量的数据是不需要进行持久化的,可以使用@Ignore进行注释,具体代码如下:

@Entity
class User {
    @PrimaryKey
    public int id;

    public String firstName;
    public String lastName;

    @Ignore
    Bitmap picture;
}
           

为了持久化一个成员变量,必须保证Room可以访问它,所以这个成员变量必须声明为public,或者为这个成员变量创建set/get方法,保证能获取到,Room是基于Java Bean协议的。

Primary key

主键

每个Entity必须至少定义一个成员变量为主键,即便只有一个成员变量也要打上@PrimaryKey注解。另外,如果你想让Room自动为你生成主键,可以通过设置@PrimaryKey的autoGenerate属性。如果这个Entity中需要多个主键,可以通过@Entity注解来设置primary属性,代码如下:

@Entity(primaryKeys = {"firstName", "lastName"})
class User {
    public String firstName;
    public String lastName;

    @Ignore
    Bitmap picture;
}
           

默认情况下,Room会使用类名来作为表名。如果想要自定义表名,可以通过@Entity的tableName属性来设置。

@Entity(tableName = "users")
class User {
    ...
}
           

注:在SQLite中表名是大小写不敏感的。

和表名设置一样,Room也支持自定义列名,通过@ColumnInfo的name属性来自定义列名。

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

有时候,某些成员变量或数据库中的字段组中必须是唯一的。通过将索引注释的唯一属性设置为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;
}
           

Relationships

关联

因为SQLite是一个关系型数据库,你可以指定对象之间的关联。尽管大多数ORM库允许实体对象互相引用,但Room明确禁止这一点。有关详细信息,请查看Addendum: No object references between entities。

尽管您不能进行直接关联,但仍然可以通过在实体之间定义外键约束。

例如,如果有一个实体Book,可以使用@ForeignKey注释定义它的关系到User实体,具体代码如下:

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

外键非常强大,因为它们允许您指定引用实体更新时发生的情况。例如,你可以告诉SQLite删除指定用户的所有书籍,当该用户被删除后。前提是在@ForeignKey注解中使用OnDelete = CASCADE属性。

注:SQLite通过@Insert(OnConflict=REPLACE) 来处理Remove, Replace操作,而不仅仅是update操作。这种方法来修改冲突的值会影响相关联的外键。详情见on_conflict 在SQLite documentation的条款。

Nested objects

对象嵌套

有时,你想表达一个实体或普通的java对象(POJOs)作为你的数据库逻辑连贯的整体,即使对象包含多个字段。在这种情况下,你可以使用“@Embedded来表示一个对象,你想分解成它的子域内的表。然后您可以像其他单独的列一样查询嵌入式字段。

例如,User可以包含一个Address的字段,它代表一个字段命名的街道组成的城市,省,邮编。要将组合的列分别存储在表中,请在User中包含与 @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具有相同类型的多个嵌入字段,则可以通过设置前缀属性来保持每个列的唯一性。Room将会为每个嵌入的对象赋值。

Data Access Objects (DAOs)

数据接入对象

DAO是Room中的主要组件。DAO通过一种简洁的方式抽象访问数据库。

Dao可以是一个接口也可以是一个抽象类。如果是一个抽象类,可以有一个以RoomDatabse作为参数的构造函数。

注:Room不允许在主线程中访问数据库,除非在builder中调用allowmainthreadqueries()接口,因为它可能会导致UI界面长时间无响应。异步查询(返回LiveData或RxJava Flowable)将不受这个规则限制。

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方法只接收一个参数,可以返回一个long值,代表所插入的具体的哪一行的id,如果参数是一个数组或集合,将会返回long[]或List.

有关更多关于@Insert注解的相关信息,请参考 SQLite documentation for rowid tables

Update

Update是更新数据库很方便的一个方法,通过主键来查找要更新的数据,然后进行更新,具体代码如下:

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

在需要的适合也可以返回具体更新的id作为返回值返回。

Delete

和Update类似,这里就不进行过多的叙述。

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

Methods using @Query

使用@Query注解的方法

@Query是Dao中主要的一个注解。它允许对数据库进行读写操作。每个@Query注解的方法都会在编译的时候进行校验,如果脚本有错,就不会在运行时才抛出异常。

Room还验证查询的返回值,以便如果返回对象中字段的名称与查询响应中的相应列名不匹配,Room将会通过以下两种方式进行警告:

  • 当只有部分列匹配时会发出警告
  • 如果没有列匹配时会发出错误提示

简单查询

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

这是一个非常简单的查询,查询所有用户。在编译时,Room知道将要查询用户表中的所有列的数据。如果查询包含语法错误,或者如果数据库中不存在用户表,那么当应用程序编译时,Room将会在编译时显示相应的错误信息。

带参数查询

大多数情况下,会使用条件查询,例如只显示大于某个年龄的用户。要完成此任务,需要在Room注释中使用方法参数,代码如下:

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

当在编译的时候处理这个查询时,Room将: minAge和方法参数minAge进行匹配和绑定。Room使用名称进行匹配。如果不匹配,将会在应用编译时报错。

当然也支持多条件查询:

@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中的部分字段数据。例如,界面上只需要显示用户的姓名,而不是用户的所有信息。通过只提取应用程序UI中需要的列,可以节省宝贵的资源,并且更快的完成查询。

Room允许在查询的结果中和返回对象进行映射。例如,

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

Room可以将查询数据中的firstname, lastname和返回对象中的数据进行映射。因此,Room会生成对应的代码。如果查询返回的数据列过多,或者没有匹配到,会提示警告。

多个数据查询

一些查询可能要求传递一个可变数量的参数,参数的确切数量直到运行时才知道。例如,可能想找回指定地区的所有用户信息。Room会自动解析集合,并进行查询。

@Dao
public interface MyDao {
    @Query("SELECT first_name, last_name FROM user WHERE region IN (:regions)")
    public List<NameTuple> loadUsersFromRegions(List<String> regions);
}
           

可观察的查询

在查询时,可能会希望UI界面会跟随数据的变化而自动更新。要达到这个目的,必须使用LiveData作为查询参数进行返回。Room会自动生成代码,当数据库数据发生变化时会自动更新LiveData

RxJava

Room查询还可以返回RxJava2 Publisher和Flowable对象。如果要使用这个返回对象,需要添加依赖的库。

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

更多详情可以查看谷歌开发者中的文章:Room and RxJava

返回游标

如果想要在查询中直接返回游标来访问对应的数据对象,代码如下:

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

注:非常不推荐使用这种方式返回数据,它不能保证访问的行数据是否存在,或者当前行数据包含哪些列数据。而且不能对使用游标的查询进行重构。

多表查询

有些数据查询会涉及多个表,Room支持各种查询,比如多表联合查询。此外,如果响应是一个可观察的数据类型,比如LiveData或Flowable,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);
}
           
@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支持数据类型的自动装箱和拆箱。但是,有时使用一种自定义的数据类型,并希望在数据库中一个列中存储对应的值。使用TypeConvert可以提供自定义数据结构的存储,它会将自定义类型转化成当前支持的数据类型进行持久化。

比如说,如果要持久化Date对象,就可以使用TypeConvert进行时间戳数据转换:

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就可以将Date对象持久化成已经支持的long数据

然后就是使用@TypeConvert注解对数据库进行注释:

@Database(entities = {User.class}, version = )
@TypeConverters({Converters.class})
public abstract class AppDatabase extends RoomDatabase {
    public abstract UserDao userDao();
}
           

有了这个类型转换,就可以在自定义类型中直接使用Date对象:

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

当然也可以限制@TypeConvert注解的作用范围,比如单独的Entity, Dao, 或具体的方法。更多详细可参考: @TypeConverters

Database migration

数据库迁移或升级

当在应用程序中添加和更改功能时,需要修改实体类来反映这些更改。当用户更新到应用程序的最新版本时,您不希望它们丢失所有现有数据,尤其是如果不能从远程服务器恢复数据。

Room允许你写升级类保存这样的用户数据。每个升级类指定Startversion和endversion。在运行时,Room执行每个升级类的migrate()方法,使用正确的顺序将数据库迁移到新版本。

注:如果没有提供迁移类,Room会重新创建数据库,这也就意味着将会出现数据丢失。

Room.databaseBuilder(getApplicationContext(), MyDb.class, "database-name")
        .addMigrations(MIGRATION_1_2, MIGRATION_2_3).build();

static final Migration MIGRATION_1_2 = new Migration(, ) {
    @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(, ) {
    @Override
    public void migrate(SupportSQLiteDatabase database) {
        database.execSQL("ALTER TABLE Book "
                + " ADD COLUMN pub_year INTEGER");
    }
};
           

注:为了保持迁移逻辑的正常运行,使用完整查询,而不是引用表示查询的常量。

在迁移过程完成后,Room将验证数据库,以确保正确迁移数据库。如果Room发现问题,它会抛出一个包含不匹配信息的异常。

具体的测试:// TODO