Android框架式编程之Room
Room是Google官方出品的ORM(Object-relational mapping) 框架。当前我们也知道当前还有很多的ORM框架,例如GreenDao、OrmLite、Litepal等。目前并没有深入了解这些框架,没办法比较各个框架的优缺点,但是相对而言,Room比较官方出品,且能够更好的与LiveData及RxJava等框架结合使用,还是推荐各位学习和使用Room框架作为数据存储的基础框架的。官方文档: https://developer.android.com/training/data-storage/room/
Room由三个重要的组件组成:Database、Entity、DAO。
Database
Database 是数据库的持有者,是应用持久关联数据的底层连接的主要访问点。而且Database对应的类编写时必须满足下面几个条件:
1. 必须是abstract类而且的extends RoomDatabase。
2. 必须在类头的注释中包含与数据库关联的实体列表(Entity对应的类)。
3. 包含一个具有0个参数的抽象方法,并返回用@Dao注解的类。
在运行时,你可以通过Room.databaseBuilder() 或者 Room.inMemoryDatabaseBuilder()获取Database实例。
Entity
Entity代表数据库中某个表的实体类。
DAO
DAO封装用于访问数据库的方法。
一、Room 配置方式
在build.gradle中添加如下配置:
// add for room
implementation "android.arch.persistence.room:runtime:1.1.1"
// room 配合 RxJava
implementation "android.arch.persistence.room:rxjava2:1.1.1"
annotationProcessor 'android.arch.persistence.room:compiler:1.1.1'
// RxJava
implementation 'io.reactivex.rxjava2:rxandroid:2.0.1'
implementation 'io.reactivex.rxjava2:rxjava:2.1.3'
二、Entity(实体)
每个Entity代表数据库中某个表的实体类。默认情况下Room会把Entity里面所有的字段对应到表上的每一列。
如果需要制定某个字段不作为表中的一列需要添加@Ignore注解,示例如下:
@Entity public class User { @PrimaryKey public int id; public String firstName; public String lastName; @Ignore Bitmap picture; }
示例中的picture字段代码因为使用了@Ignore所以该字段不会映射到User表中。
Entity的实体类都需要添加@Entity注解,而且Entity类中需要映射到表中的字段需要保证外部能访问到这些字段,这里建议把字段设置为public或者实现字段的getter和setter方法。
@Entity注解包含的属性有:
- tableName:设置表名字。默认是类的名字。
- indices:设置索引。
- inheritSuperIndices:父类的索引是否会自动被当前类继承。
- primaryKeys:设置主键。
- foreignKeys:设置外键。
1. 设置表的名字
默认情况下Entity类的名字就是表的名字(不区分大小写)。但是我们也可以通过@Entity的tableName属性来自定义表名字。
如下代码所示users表对应的实体类。
@Entity(tableName = "users") public class User { ... }
2. 设置列的名字
如下代码users表中first_name列对应firstName字段,last_name列对应lastName字段。
@Entity(tableName = "users") public class User { @PrimaryKey public int id; @ColumnInfo(name = "first_name") public String firstName; @ColumnInfo(name = "last_name") public String lastName; }
3. 设置主键
每个Entity都需要至少一个字段设置为主键。即使这个Entity只有一个字段也需要设置为主键。Entity设置主键的方式有两种:
- 通过@Entity的primaryKeys属性来设置主键(primaryKeys是数组可以设置单个主键,也可以设置复合主键)。如下:
@Entity(primaryKeys = {"firstName", "lastName"}) public class User { public String firstName; public String lastName; }
- 同@PrimaryKey注解来设置主键。如下:
@Entity public class User { @PrimaryKey public String firstName; @PrimaryKey public String lastName; }
如果希望Room给entity设置一个自增的字段,可以设置@PrimaryKey的autoGenerate属性。
4. 设置索引
数据库索引用于提高数据库表的数据访问速度的。数据库里面的索引有单列索引和组合索引。Room里面可以通过@Entity的indices属性来给表格添加索引。
@Entity(indices = {@Index("firstName"), @Index(value = {"last_name", "address"})}) public class User { @PrimaryKey public int id; public String firstName; public String address; @ColumnInfo(name = "last_name") public String lastName; @Ignore Bitmap picture; }
索引也是分两种的唯一索引和非唯一索引。唯一索引就想主键一样重复会报错的。可以通过的@Index的unique数学来设置是否唯一索引。
@Entity(indices = {@Index(value = {"first_name", "last_name"}, unique = true)}) public class User { @PrimaryKey public int id; @ColumnInfo(name = "first_name") public String firstName; @ColumnInfo(name = "last_name") public String lastName; @Ignore Bitmap picture; }
5. 设置外键
因为SQLite是关系型数据库,表和表之间是有关系的。这也就是我们数据库中常说的外键约束(FOREIGN KEY约束)。Room里面可以通过@Entity的foreignKeys属性来设置外键。正常情况下,数据库里面的外键约束。子表外键于父表。当父表中某条记录子表有依赖的时候父表这条记录是不能删除的,删除会报错。一般大型的项目很少会采用外键的形式。一般都会通过程序依赖业务逻辑来保证的。
下面我们举一个具体的例子来说明一下:
@Entity(indices = {@Index(value = {"first_name", "last_name"}, unique = true)}) public class User { @PrimaryKey public int id; @ColumnInfo(name = "first_name") public String firstName; @ColumnInfo(name = "last_name") public String lastName; @Ignore Bitmap picture; } @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; }
上述代码中,foreignKeys修饰后的Book表中的userId来源于User表中的id。
@ForeignKey属性介绍:
-
entity:parent实体类(引用外键的表的实体)。
-
parentColumns:parent外键列(要引用的外键列)。
-
childColumns:child外键列(要关联的列)。
-
onDelete:默认NO_ACTION,当parent里面有删除操作的时候,child表可以做的Action动作有:
1. NO_ACTION:当parent中的key有变化的时候child不做任何动作。
2. RESTRICT:当parent中的key有依赖的时候禁止对parent做动作,做动作就会报错。
3. SET_NULL:当paren中的key有变化的时候child中依赖的key会设置为NULL。
4. SET_DEFAULT:当parent中的key有变化的时候child中依赖的key会设置为默认值。
5. CASCADE:当parent中的key有变化的时候child中依赖的key会跟着变化。 -
onUpdate:默认NO_ACTION,当parent里面有更新操作的时候,child表需要做的动作。Action动作方式和onDelete是一样的。
-
deferred:默认值false,在事务完成之前,是否应该推迟外键约束。这个怎么理解,当我们启动一个事务插入很多数据的时候,事务还没完成之前。当parent引起key变化的时候。可以设置deferred为ture。让key立即改变。
6. 创建嵌套对象
public class Address { public String street; public String state; public String city; @ColumnInfo(name = "post_code") public int postCode; } @Entity public class User { @PrimaryKey public int id; public String firstName; @Embedded public Address address; }
@Embedded注解属性:
- prefix:如果实体具有多个相同类型的嵌入字段,则可以通过设置前缀属性来保持每个列的唯一性。然后Room会将提供的值添加到嵌入对象中每个列名的开头。
三、DAO(数据访问对象)
DAO是Room的主要组件,负责定义访问数据库的方法。Room使用过程中一般使用抽象DAO类来定义数据库的CRUD操作。
DAO可以是一个接口也可以是一个抽象类。如果它是一个抽象类,它可以有一个构造函数,它将RoomDatabase作为其唯一参数。Room在编译时创建每个DAO实例。DAO里面所有的操作都是依赖方法来实现的。
1. Insert(插入)
当DAO里面的某个方法添加了@Insert注解。Room会生成一个实现,将所有参数插入到数据库中的一个单个事务。
- onConflict:默认值是OnConflictStrategy.ABORT,表示当插入有冲突的时候的处理策略。OnConflictStrategy封装了Room解决冲突的相关策略:
1. OnConflictStrategy.REPLACE:冲突策略是取代旧数据同时继续事务。
2. OnConflictStrategy.ROLLBACK:冲突策略是回滚事务。
3. OnConflictStrategy.ABORT:冲突策略是终止事务。
4. OnConflictStrategy.FAIL:冲突策略是事务失败。
5. OnConflictStrategy.IGNORE:冲突策略是忽略冲突。
一个简单的实例如下:
@Dao public interface UserDao { @Insert(onConflict = OnConflictStrategy.REPLACE) void insertUsers(User... users); }
2. Update(更新)
当DAO里面的某个方法添加了@Update注解。Room会把对应的参数信息更新到数据库里面去(会根据参数里面的primary key做更新操作)。
@Dao public interface UserDao { @Update(onConflict = OnConflictStrategy.REPLACE) int updateUsers(User... users); }
@Update注解的方法也可以返回int变量。表示更新了多少行。
3. Delete(删除)
当DAO里面的某个方法添加了@Delete注解。Room会把对应的参数信息指定的行删除掉(通过参数里面的primary key找到要删除的行)。
@Delete也是可以设置onConflict来表明冲突的时候的解决办法。
@Dao public interface UserDao { @Delete void deleteUsers(User... users); }
@Delete对应的方法也是可以设置int返回值来表示删除了多少行。
4. Query(查询)
@Query注解是DAO类中使用的主要注释。它允许您对数据库执行读/写操作。@Query在编译的时候会验证准确性,所以如果查询出现问题在编译的时候就会报错。
Room还会验证查询的返回值,如果返回对象中的字段名称与查询响应中的相应列名称不匹配的时候,Room会通过以下两种方式之一提醒您:
- 如果只有一些字段名称匹配,它会发出警告。
- 如果没有字段名称匹配,它会发生错误。
@Query注解value参数:查询语句,这也是我们查询操作最关键的部分。
4.1 简单的查询
查询所有的信息。
@Dao public interface UserDao { @Query("SELECT * FROM user") User[] loadAllUsers(); }
返回结果可以是数组,也可以是List
4.2 带参数的查询
大多数情况下我们都需要查询满足特定的查询条件的信息。
@Dao public interface UserDao { @Query("SELECT * FROM user WHERE firstName == :name") User[] loadAllUsersByFirstName(String name); }
查询需要多个参数的情况
@Dao public interface UserDao { @Query("SELECT * FROM user WHERE age BETWEEN :minAge AND :maxAge") User[] loadAllUsersBetweenAges(int minAge, int maxAge); @Query("SELECT * FROM user WHERE firstName LIKE :search " + "OR lastName LIKE :search") List<User> findUserWithName(String search); }
4.3 查询返回列的子集
有的时候可能指向返回某些特定的列信息。
下来的例子只查询user表中的firstName和lastName信息。
@Entity public class User { @PrimaryKey public String firstName; @PrimaryKey public String lastName; public int age; } public class NameTuple { private String firstName; private String lastName; public String getFirstName() { return firstName; } public void setFirstName(String firstName) { this.firstName = firstName; } public String getLastName() { return lastName; } public void setLastName(String lastName) { this.lastName = lastName; } } @Dao public interface UserDao { @Query("SELECT firstName, lastName FROM user") List<NameTuple> loadFullName(); }
4.4 查询的时候传递一组参数
在查询的时候您可能需要传递一组(数组或者List)参数进去。
@Dao public interface UserDao { @Query("SELECT firstName, lastName FROM user WHERE region IN (:regions)") public List<NameTuple> loadUsersFromRegions(List<String> regions); }
4.5 Observable的查询
意思就是查询到结果的时候,UI能够自动更新。Room为了实现这一效果,查询的返回值的类型为LiveData。
@Dao public interface UserDao { @Query("SELECT firstName, lastName FROM user WHERE region IN (:regions)") LiveData<List<NameTuple>> loadUsersFromRegionsSync(List<String> regions); }
关于LiveData的具体用法,我们这里就不做过多的讨论了,后续的文章中我们会针对LiveData做详细的说明。
4.6 使用RxJava作为查询的返回值
Room的查询也可以返回RxJava2的Publisher或者Flowable对象。当然了想要使用这一功能需要在build.gradle文件添加 implementation "android.arch.persistence.room:rxjava2:1.1.1" 依赖。
@Dao public interface UserDao { @Query("SELECT * from user") Flowable<List<User>> loadUser(); }
拿到Flowable<List<User>>数据之后就可以去调用
mAppDatabase.userDao() .loadUser() .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(new Consumer<List<User>>() { @Override public void accept(List<User> entities) { } });
4.7 查询结果直接返回Cursor
查询结果直接返回cursor。然后通过cursor去获取具体的结果信息。
@Dao public interface UserDao { @Query("SELECT * FROM user WHERE age > :minAge LIMIT 5") Cursor loadRawUsersOlderThan(int minAge); }
关于如何从Cursor里去获取数据,大家肯定都非常熟悉,这里就不赘述了。
4.8 多表查询
有的时候可能需要通过多个表才能获取查询结果。这个就涉及到数据的多表查询语句了。
@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; } }
五、Database(数据库)
@Database注解可以用来创建数据库的持有者。该注解定义了实体列表,该类的内容定义了数据库中的DAO列表。这也是访问底层连接的主要入口点。注解类应该是抽象的并且扩展自RoomDatabase。
Database对应的对象(RoomDatabase)必须添加@Database注解,@Database包含的属性:
- entities:数据库相关的所有Entity实体类,他们会转化成数据库里面的表。
- version:数据库版本。
- exportSchema:默认true,也是建议传true,这样可以把Schema导出到一个文件夹里面。
在运行时,你可以通过调用Room.databaseBuilder()或者Room.inMemoryDatabaseBuilder()获取实例。
因为每次创建Database实例都会产生比较大的开销,所以应该将Database设计成单例的,或者直接放在Application中创建。
两种方式获取Database对象的区别:
- Room.databaseBuilder():生成Database对象,并且创建一个存在文件系统中的数据库。
- Room.inMemoryDatabaseBuilder():生成Database对象并且创建一个存在内存中的数据库。当应用退出的时候(应用进程关闭)数据库也消失。
我们用一个简单的实例来说明Database的创建。先定义一个abstract类AppDatabase继承RoomDatabase:
@Database(entities = {User.class, Book.class}, version = 3) @TypeConverters({Converters.class}) public abstract class AppDatabase extends RoomDatabase { public abstract UserDao userDao(); public abstract BookDao bookDao(); }
创建RoomDatabase实例(AppDatabase)。这里我们把RoomDatabase实例的创建放在Application里面。
public class AppApplication extends Application { private AppDatabase mAppDatabase; @Override public void onCreate() { super.onCreate(); mAppDatabase = Room.databaseBuilder(getApplicationContext(), AppDatabase.class, "android_room_dev.db") .allowMainThreadQueries() .addMigrations(MIGRATION_1_2, MIGRATION_2_3) .build(); } public AppDatabase getAppDatabase() { return mAppDatabase; } /** * 数据库版本 1->2 user表格新增了age列 */ static final Migration MIGRATION_1_2 = new Migration(1, 2) { @Override public void migrate(SupportSQLiteDatabase database) { database.execSQL("ALTER TABLE User ADD COLUMN age integer"); } }; /** * 数据库版本 2->3 新增book表格 */ static final Migration MIGRATION_2_3 = new Migration(2, 3) { @Override public void migrate(SupportSQLiteDatabase database) { database.execSQL( "CREATE TABLE IF NOT EXISTS `book` (`uid` INTEGER PRIMARY KEY autoincrement, `name` TEXT , `userId` INTEGER, 'time' INTEGER)"); } }; }
创建RoomDatabase实例的时候,RoomDatabase.Builder类里面主要方法的介绍:
/** * 默认值是FrameworkSQLiteOpenHelperFactory,设置数据库的factory。比如我们想改变数据库的存储路径可以通过这个函数来实现 */ public RoomDatabase.Builder<T> openHelperFactory(@Nullable SupportSQLiteOpenHelper.Factory factory); /** * 设置数据库升级(迁移)的逻辑 */ public RoomDatabase.Builder<T> addMigrations(@NonNull Migration... migrations); /** * 设置是否允许在主线程做查询操作 */ public RoomDatabase.Builder<T> allowMainThreadQueries(); /** * 设置数据库的日志模式 */ public RoomDatabase.Builder<T> setJournalMode(@NonNull JournalMode journalMode); /** * 设置迁移数据库如果发生错误,将会重新创建数据库,而不是发生崩溃 */ public RoomDatabase.Builder<T> fallbackToDestructiveMigration(); /** * 设置从某个版本开始迁移数据库如果发生错误,将会重新创建数据库,而不是发生崩溃 */ public RoomDatabase.Builder<T> fallbackToDestructiveMigrationFrom(int... startVersions); /** * 监听数据库,创建和打开的操作 */ public RoomDatabase.Builder<T> addCallback(@NonNull RoomDatabase.Callback callback);
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(); } }
六、数据迁移(升级)
大部分情况下设计的数据库在版本的迭代过程中经常是会有变化的。比如突然某个表需要新加一个字段,需要新增一个表。这个时候我们又不想失去之前的数据。Room里面以Migration类的形式提供可一个简化SQLite迁移的抽象层。Migration提供了从一个版本到另一个版本迁移的时候应该执行的操作。
当数据库里面表有变化的时候(不管你是新增了表,还是改变了某个表)有如下几个场景。
- 如果database的版本号不变。app操作数据库表的时候会直接crash掉。(错误的做法)
- 如果增加database的版本号。但是不提供Migration。app操作数据库表的时候会直接crash掉。(错误的做法)
- 如果增加database的版本号。同时启用fallbackToDestructiveMigration。这个时候之前的数据会被清空掉。如下fallbackToDestructiveMigration()设置。(不推荐的做法)
mAppDatabase = Room.databaseBuilder(getApplicationContext(), AppDatabase.class, "android_room_dev.db") .allowMainThreadQueries() .fallbackToDestructiveMigration() .build();
- 增加database的版本号,同时提供Migration。这要是Room数据迁移的重点。(最正确的做法)
所以在数据库有变化的时候,我们任何时候都应该尽量提供Migrating。Migrating让我们可以自己去处理数据库从某个版本过渡到另一个版本的逻辑。我们用一个简单的实例来说明。有这么个情况,数据库开始设计的时候我们就一个user表(数据库版本 1),第一次变化来了我们需要给user表增加一个age的列(数据库版本 2),过了一段时间又有变化了我们需要新增加一个book表。三个过程版本1->2->3。
数据库版本为1的时候的代码,如下:
@Database(entities = {User.class}, version = 1) public abstract class AppDatabase extends RoomDatabase { public abstract UserDao userDao(); } public class AppApplication extends Application { private AppDatabase mAppDatabase; @Override public void onCreate() { super.onCreate(); mAppDatabase = Room.databaseBuilder(getApplicationContext(), AppDatabase.class, "android_room_dev.db") .allowMainThreadQueries().build(); } public AppDatabase getAppDatabase() { return mAppDatabase; } }
数据库版本为2的时候的代码,User增加了age列:
@Entity public class User { @PrimaryKey(autoGenerate = true) private long uid; private String name; private String address; private String phone; private Integer age; public long getUid() { return uid; } public void setUid(long uid) { this.uid = uid; } public String getName() { return name; } public void setName(String name) { this.name = name; } public String getAddress() { return address; } public void setAddress(String address) { this.address = address; } public String getPhone() { return phone; } public void setPhone(String phone) { this.phone = phone; } public Integer getAge() { return age; } public void setAge(Integer age) { this.age = age; } } @Database(entities = {User.class}, version = 2) public abstract class AppDatabase extends RoomDatabase { public abstract UserDao userDao(); } public class AppApplication extends Application { private AppDatabase mAppDatabase; @Override public void onCreate() { super.onCreate(); mAppDatabase = Room.databaseBuilder(getApplicationContext(), AppDatabase.class, "android_room_dev.db") .allowMainThreadQueries() .addMigrations(MIGRATION_1_2) .build(); } public AppDatabase getAppDatabase() { return mAppDatabase; } /** * 数据库版本 1->2 user表格新增了age列 */ static final Migration MIGRATION_1_2 = new Migration(1, 2) { @Override public void migrate(SupportSQLiteDatabase database) { database.execSQL("ALTER TABLE user " + " ADD COLUMN age INTEGER"); } }; }
数据库版本为3的时候的代码,新增了一个Book表:
@Entity public class Book { @PrimaryKey(autoGenerate = true) private Long uid; private String name; private Date time; private Long userId; public Long getUid() { return uid; } public void setUid(Long uid) { this.uid = uid; } public String getName() { return name; } public void setName(String name) { this.name = name; } public Date getTime() { return time; } public void setTime(Date time) { this.time = time; } public Long getUserId() { return userId; } public void setUserId(Long userId) { this.userId = userId; } } @Database(entities = {User.class, Book.class}, version = 3) @TypeConverters({Converters.class}) public abstract class AppDatabase extends RoomDatabase { public abstract UserDao userDao(); public abstract BookDao bookDao(); } public class AppApplication extends Application { private AppDatabase mAppDatabase; @Override public void onCreate() { super.onCreate(); mAppDatabase = Room.databaseBuilder(getApplicationContext(), AppDatabase.class, "android_room_dev.db") .allowMainThreadQueries() .addMigrations(MIGRATION_1_2, MIGRATION_2_3) .build(); } public AppDatabase getAppDatabase() { return mAppDatabase; } /** * 数据库版本 1->2 user表格新增了age列 */ static final Migration MIGRATION_1_2 = new Migration(1, 2) { @Override public void migrate(SupportSQLiteDatabase database) { database.execSQL("ALTER TABLE User ADD COLUMN age integer"); } }; /** * 数据库版本 2->3 新增book表格 */ static final Migration MIGRATION_2_3 = new Migration(2, 3) { @Override public void migrate(SupportSQLiteDatabase database) { database.execSQL( "CREATE TABLE IF NOT EXISTS `book` (`uid` INTEGER PRIMARY KEY autoincrement, `name` TEXT , `userId` INTEGER, 'time' INTEGER)"); } }; }
Migrating使用过程中也有碰到一些坑,这里告诫大家一个经验Entity中能用Integer的时候不用int。
七、数据库信息的导出
Room也允许你会将你数据库的表信息导出为一个json文件。你应该在版本控制系统中保存该文件,该文件代表了你的数据库表历史记录,这样允许Room创建旧版本的数据库用于测试。
只需要在build.gradle文件中添加如下配置。编译的时候就会导出json文件。
android { ... defaultConfig { ... javaCompileOptions { annotationProcessorOptions { arguments = ["room.schemaLocation": "$projectDir/schemas".toString()] } } } // 用于测试 sourceSets { androidTest.assets.srcDirs += files("$projectDir/schemas".toString()) } }
json文件文件会导出在工程目录下的schemas文件夹下面。里面会有各个版本数据库的信息。