理解Android Architecture Components系列之Room(六)

Room在SQLite上提供了一个方便访问的抽象层。App把经常需要访问的数据存储在本地将会大大改善用户的体验。这样用户在网络不好时仍然可以浏览内容。当用户网络可用时,可以更新用户的数据。

使用原始的SQLite可以提供这样的功能,但是有以下两个缺点:

  • 没有编译时SQL语句的检查。尤其是当你的数据库表发生变化时,需要手动的更新相关代码,这会花费相当多的时间并且容易出错。
  • 编写大量SQL语句和Java对象之间相互转化的代码。

针对以上的缺点,Google提供了Room来解决这些问题。Room包含以下三个重要组成部分:

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


room_architecture.png

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

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

在创建了上面三个文件后,就可以通过如下代码创建数据库了:

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

下面详细介绍提到的各个部分:

Entities

@Entity
如果上面的User类中包含一个字段是不希望存放到数据库中的,那么可以用@Ignore注解这个字段:

@Entity
class User {
    @PrimaryKey
    public int id;

    public String firstName;
    public String lastName;

    //不需要被存放到数据库中
    @Ignore
    Bitmap picture;
}

Room持久化一个类的field必须要求这个field是可以访问的。可以把这个field设为public或者设置setter和getter。

Primary Key 主键

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

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

    @Ignore
    Bitmap picture;
}

在默认情况下Room使用类名作为数据库表的名称。如果想要设置不同的名称,可以参考下面的代码,设置表名tableName为users:

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

和设置tableName相似,Room默认使用field的名称作为表的列名。如果想要使用不同的名称,可以通过@ColumnInfo(name = "first_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;
}

索引和唯一性

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

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

下面的代码展示了对数据库中特定的field设置唯一性(这个表中的firstNamelastName不能同时相同):

@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库允许Entity对象互相引用,但Room明确禁止了这样做。详细的原因,可以参考这里

既然不允许建立直接的关系,Room提供以外键的方式在两个Entity之间建立联系。

外键

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

@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实现。

@Insert(OnConflict = REPLACE)
定义了REMOVEREPLACE而不是简单的UPDATE操作。这样产生的后果会影响外键定义的约束行为,详细的信息可以参考 SQLite documentation

获取关联的Entity

Entity之间可能也有一对多之间的关系。比如一个User有多个Pet,通过一次查询获取多个关联的Pet。

public class UserAndAllPets {
    @Embedded
    public User user;
    @Relation(parentColumn = "id", entityColumn = "user_id")
    public List<Pet> pets;
}

 @Dao
 public interface UserPetDao {
     @Query("SELECT * from User")
     public List<UserAndAllPets> loadUserAndPets();
 }

使用 @Relation 注解的field必须是一个List或者一个Set。通常情况下, Entity 的类型是从返回类型中推断出来的,可以通过定义 entity()来定义特定的返回类型。
@Relation 注解的field必须是public或者有public的setter。这是因为加载数据是分为两步的:1. 父Entity被查询 2. 触发用 @Relation 注解的entity的查询。所以,在上面UserAndAllPets例子中,首先User所在的数据库被查询,然后触发查询Pets的查询。即Room首先出创建一个空的对象,然后设置父Entity和一个空的list。在第二次查询后,Room将会填充这个list。

对象嵌套对象

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

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 来保证列的唯一性。

Data Access Objects(DAOs)

DAOs是数据库访问的抽象层。
Dao可以是一个接口也可以是一个抽象类。如果是抽象类,那么它可以接受一个RoomDatabase作为构造器的唯一参数。
Room不允许在主线程中防伪数据库,除非在builder里面调用allowMainThreadQueries() 。因为访问数据库是耗时的,可能阻塞主线程,引起UI卡顿。

添加方便使用的方法

Insert

使用 @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,对应着插入的rowId。如果接受多个参数,或者数组,或者集合,那么就会返回一个long的数组或者list。

Update

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

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

Delete

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

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

使用@Query注解的方法

@Query 注解的方法在编译时就会被检查,如果有任何查询的问题,都会抛出编译异常,而不是等到运行以后才会发现异常。
Room也会检查查询返回值的类型,如果返回类型的字段和数据路列名存在不一致,会收到警告。如果两者完全不一致,就会产生错误。

简单的查询

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

带参数查询

下面的代码显示了如何根据年龄条件查询User信息:

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

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

@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也提供了这样的功能。

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 的配合使用,就可以实现当数据库内容发生变化时自动收到变化后的数据的功能。

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

使用RxJava实现响应式查询

Room也可以返回RxJava2中PublisherFlowable
格式的数据。如果需要使用这项功能,需要在Gradle中添加android.arch.persistence.room:rxjava2

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

详细的信息可以参考 Room and RxJava这篇文章。

直接获取Cursor

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

查询多个表

有时可能需要查询多个表来获取结果,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);
}

也可以从查询中返回POJO类。代码如下:

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

使用类型转换器

如果想要在数据库中存储Date,可以存储等价的Unix时间戳。通过 TypeConverter 可以很方便的做到这一点:

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和Unix时间戳相互转换。Room支持存储Long类型的对象,这样就可以通过这种方法存储Date。
接下来将 TypeConverter添加到AppDatabase中,这样Room就能识别这种转换:
AppDatabase.java

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

接下来就可以像使用基本类型一样使用自定义类型的查询,比如:
User.java

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

UserDao.java

@Dao
public interface UserDao {
    ...
    @Query("SELECT * FROM user WHERE birthday BETWEEN :from AND :to")
    List<User> findUsersBornBetweenDates(Date from, Date to);
}

关于更多 @TypeConverters的用法,可以参考这里

数据库迁移

随着业务的扩展有时候需要对数据库调整一些字段。当数据库升级时,需要保存已有的数据。
Room使用 Migration 来实现数据库的迁移。每个 Migration 都指定了startVersionendVersion。在运行的时候Room运行每个 Migrationmigrate() 方法,按正确的顺序来迁移数据库到下个版本。如果没有提供足够的迁移信息,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的使用。如果想要详细了解Room,建议大家动手去写一个简单的demo。文章结尾主要还是想提一下数据库在设计一个App架构的重要地位。下一篇文章将会讲解一个高效加载数据的库。

相关文章:
理解Android Architecture Components系列(一)
理解Android Architecture Components系列(二)
理解Android Architecture Components系列之Lifecycle(三)
理解Android Architecture Components系列之LiveData(四)
理解Android Architecture Components系列之ViewModel(五)
理解Android Architecture Components系列之Room(六)
理解Android Architecture Components系列之Paging Library(七)
理解Android Architecture Components系列之WorkManager(八)

推荐阅读更多精彩内容