Jetpack —— 使用 Room 来替代 SQLite

Android 强烈建议使用 Room 来替代 SQLite 。

Room 提供了一个覆盖 SQLite 的抽象层,使用 Room 可以流畅的访问 SQLite 的全部功能。

罗哩罗嗦的介绍就不写了,直接开始用法吧

依赖

dependencies {
  def room_version = "2.2.0-alpha01"

  implementation "androidx.room:room-runtime:$room_version"
  annotationProcessor "androidx.room:room-compiler:$room_version" // For Kotlin use kapt instead of annotationProcessor

  // optional - Kotlin Extensions and Coroutines support for Room
  implementation "androidx.room:room-ktx:$room_version"

  // optional - RxJava support for Room
  implementation "androidx.room:room-rxjava2:$room_version"

  // optional - Guava support for Room, including Optional and ListenableFuture
  implementation "androidx.room:room-guava:$room_version"

  // Test helpers
  testImplementation "androidx.room:room-testing:$room_version"
}

Declaring dependencies

组件介绍

Room 主要包含三个组件:

  • Database: 包含数据库持有者,作为与应用持久化相关数据的底层连接的主要接入点。这个类需要用 @Database 注解,并满足下面条件:

    • 必须是继承 RoomDatabase 的抽象类
    • 注解中包含该数据库相关的实体类列表
    • 包含的抽象方法必须是无参的,且返回值必须是被 @Dao 注解的类
  • Entity: 表示了数据库中的一张表
  • DAO: 包含了访问数据库的一系列方法

在运行时,可以通过 Room.databaseBuilder() 或者 Room.inMempryDatabaseBuilder() 方法来获取到 Database 实例。

Room 各个组件和 APP 的关系如下图:

Entity

它代表了数据库中的一个表,这个类的属性就是表中的字段:

@Entity(tableName = "StudentTable")
public class Student implements Serializable {
    @PrimaryKey(autoGenerate = true)
    private int studentId;

    @ColumnInfo(name = "student_name")
    private String studentName;

    @ColumnInfo(name = "student_age")
    private int studentAge;
    
    @ColumnInfo(defaultValue = "中心小学")
    private String school;
    
    @Ignore
    private Bitmap bitmap;
    
    //省略构造方法和 set/get 方法
}

其中:

  • Entity 必须添加这个注解,Room 才会将这个类看作是代表一张表

    • 表名可以通过 tableName 来定义,如果不定义,会默认为类名。
    • Entity 类的属性必须是 public 的,或者提供了 set/get 方法,否则 Room 会无法访问到 Entity 中的属性。
  • @PrimaryKey 设置主键,这个是必须的,这一点和 SQLite 不同

    • 可以通过 autoGenerate = true 来设置主键自增
  • ColumnInfo 表示数据库中的字段名,默认是属性名,也可以使用 name 来自定义
  • 可以在 ColumnInfo 中使用 defaultValue 来为数据库字段设置默认值
  • 如果你不想持久化某个属性(数据库中不需要这个字段),是用 @Ignore 注解忽略之即可。

索引、外键、关系等进阶内容在后面

Dao

Dao 是应用操作数据库的接口,应用程序无须关注数据库的具体操作,只需要使用 Dao 提供的接口来对数据做增删查改即可。

DAO 可以是接口,也可以是抽象类。如果它是一个抽象类,它可以有一个以 RoomDatabase 为唯一参数的构造函数(没有也行)。

  • Dao 类必须添加 Dao 注解
  • Room 在编译时会自动创建每个 DAO 的实现类。假如提示类似 XXXImpl does not exist 的话,请检查依赖是否添加正确。

下面是常用的 CURD 操作

@Dao
public abstract class StudentDao {
    
    //添加单个 Entity
    @Insert()
    public abstract long insert(Student student);
    
    //添加多个 Entity,使用可变长参数形式或者参数为 List
    @Insert()
    public abstract long[] insertAll(Student... students);
    //或者
    @Insert()
    public abstract List<Long> insertAll(List<Student> students);
}   

方法的参数可以是一个 Entity 对象或者 Entity 对象的可变长参数或者 List;对于插入方法的返回值:

  • 如果参数是一个 Entity 对象,则返回一个代表新增列 rowId 的 long 值。
  • 如果参数是一个可变长参数或者 List,返回值则为 long[] 或者 List<Long>

当我们在 Entity 中有部分字段是有默认值的,那么我们在插入数据的时候,还可以这样写:

 @Entity
 public class Playlist {
   @PrimaryKey(autoGenerate = true)
   long playlistId;
   String name;
   @Nullable
   String description
   @ColumnInfo(defaultValue = "normal")
   String category;
   @ColumnInfo(defaultValue = "CURRENT_TIMESTAMP")
   String createdTime;
   @ColumnInfo(defaultValue = "CURRENT_TIMESTAMP")
   String lastModifiedTime;
 }

 public class NameAndDescription {
   String name;
   String description
 }

 @Dao
 public interface PlaylistDao {
   @Insert(entity = Playlist.class)
   public void insertNewPlaylist(NameAndDescription nameDescription);
 }

在上面的例子中,insertNewPlaylist 方法使用 entity 指定了 Entity 类,那么参数就可以任意 POJO 对象,只不过这个对象的字段必须是指定的 Entity 类中未设置默认值的字段。

@Dao
public abstract class StudentDao {

    public StudentDao(RoomDatabase database) {
    }

    @Delete
    public abstract int delete(Student student);

    @Delete
    public abstract int deleteAll(Student... students);

    @Delete
    public abstract int deleteAll(List<Student> students);
}

Delete 方法使用主键来查找需要删除的主体。方法的参数可以是 Entity 对象,或者是 Entity 对象的可变长参数或是 List;返回值是一个 int 值,表示删除成功的行数。

有一点搞不懂,官方文档中,Delete 也可以像 Insert 那样,通过制定一个 Entity 类,然后参数使用任意 POJO 对象,但是 Delete 方法是使用主键来查找需要删除的主题的,所以这个功能似乎没啥用,对于参数对象,无须指定其他属性,只需要设置主键属性即可,所以这个功能似乎有些多余。

@Dao
public abstract class StudentDao {

    public StudentDao(RoomDatabase database) {
    }

    @Query("SELECT * FROM Student")
    public abstract Student[] queryAllStudent();

    @Query("SELECT * FROM Student WHERE studentId = :id")
    public abstract List<Student> queryStudentById(int id);

    @Query("SELECT student_name FROM Student")
    public abstract List<String> queryAllStudentName();
    
    @Query("SELECT student_name FROM Student where studentId in (:ids)")
    public abstract List<String> queryStudentNameById(List<Integer> ids);
}

在编译的时候,Room 就会对 SQL 语句做检查,如果存在问题,会编译不通过,而不会在运行时出错。

对于方法参数,Room 支持使用 :xxx 形式将参数列表绑定到查询语句中,但是需要注意的是,只支持 999 个项绑定到语句中,这是 SQLite 的限制(999也足够了...)。

在 Query 方法中,除了 SELECT 语句之外,还支持 INSERTUPDATEDELETE

  • INSERT 查询可以返回 voidlong。如果是 long 值,则该值是插入的行的 rowid。

请注意,插入多行不能返回多个 rowid,只返回最后一个。

  • UPDATE 或 DELETE 查询可以返回 voidint。如果是int,则该值是受此查询影响的行数。

修改和删除其实没啥区别,只是注释改成了 Update,同样是根据主键来确定更新的主体:

代码略

禁止使用 Entity 对象引用

我们经常会有一个对象的属性是另一个类的对象的情况,在 Room 中是明确禁止这样做的,具体原因看这里:

Room 为什么不允许对象引用

虽然不允许直接引用其他 Entity 对象,但是对于类似需求,Room 可以使用外键 @ForeignKey 来定义和其他 Entity 的联系:

@Entity(foreignKeys = @ForeignKey(entity = User.class,
                                  parentColumns = "id",
                                  childColumns = "user_id"))
public class Book {
    @PrimaryKey 
    public int bookId;

    public String title;

    @ColumnInfo(name = "user_id") 
    public int userId;
}

在上面的代码中, Book entity 有一个作者的外键引用 User,可以通过 @ForeignKey 注解指定这个外键约束。

外键约束还可以通过 @ForeignKey 注解的 onDeleteonUpdate 属性指定级联操作,如级联更新和级联删除,@ForeignKey 注解中有两个属性 onDeleteonUpdate, 这两个属性对应 ForeignKey 中的 onDelete()onUpdate(), 通过这两个属性的值来设置当 User 对象被删除/更新时,Book 对象作出的响应。这两个属性的可选值如下:

  • CASCADE:User 删除时对应 Book 一同删除; 更新时,关联的字段一同更新
  • NO_ACTION:User 删除时不做任何响应
  • RESTRICT:禁止 User 的删除/更新。当 User 删除或更新时,Sqlite 会立马报错。
  • SET_NULL:当 User 删除时, Book中 的 userId 会设为 NULL
  • SET_DEFAULT:与 SET_NULL 类似,当 User 删除时,Book 中的 userId 会设为默认值

Entity 嵌套

在某些情况下, 对于一张表中的数据我们会用多个 POJO 类来表示,在这种情况下可以用@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 表中,Column 为id, firstName, street, state, city 还有 post_code

联表查询

Room支持联表查询,接口定义上与其他查询差别不大, 主要还是 sql 语句的差别。

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

创建数据库

Room 中 DataBase 类似 SQLite API 中 SQLiteOpenHelper,是提供 DB 操作的切入点,但是除了持有 DB 外, 它还负责持有相关数据表(Entity)的数据访问对象(DAO), 所以 Room 中定义 Database 需要满足三个条件:

  • 继承 RoomDataBase,并且是一个抽象类
  • @Database 注解,并定义相关的 entity 对象, 当然还有必不可少的数据库版本信息
  • 定义返回 DAO 对象的抽象方法
@Database(entities = {User.class}, version = 1)
public abstract class AppDatabase extends RoomDatabase {
    public abstract UserDao userDao();
}

创建好以上 Room 的三大组件后, 在代码中就可以通过以下代码创建 Database 实例。

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

升级数据库

在使用 SQLite 的时候,当需要升级数据库需要修改数据库的 version,当 version 大于现存数据库的 version 的时候,会执行 SQLiteOpenHelper 的 onUpgrade 方法里面的升级 SQL 语句。

Room 提供了 Migration 类来实现数据库的升级:

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

在创建Migration类时需要指定 startVersionendVersion, 代码中 MIGRATION_1_2MIGRATION_2_3 的 startVersion 和 endVersion 是递增的, Migration 其实是支持从版本 1 直接升到版本 3,只要其 migrate() 方法里执行的语句正常即可。那么 Room 是怎么实现数据库升级的呢?其实本质上还是调用 SQLiteOpenHelper.onUpgrade,Room 中自己实现了一个 SQLiteOpenHelper, 在 onUpgrade() 方法被调用时触发 Migration,当第一次访问数据库时,Room 做了以下几件事:

  • 创建Room Database实例
  • SQLiteOpenHelper.onUpgrade 被调用,并且触发 Migration
  • 打开数据库