Room是一个SQLite库
导入库
Room的组成
Room由三个部分组成:Database、Entity和Dao。
1. Database:使用@Database对一个继承于RoomDatabase
的抽象类进行注解,注解时需要指定所包含的表格(Entity)。Database类中需要定义抽象方法来获取数据库里的DAO(Data Access Object),Room框架会帮我们进行具体实现。运行时可以通过Room.databaseBuilder()
或者Room.inMemoryDatabaseBuilder()
方法获取到Database类的实例。
2. Entity:代表了数据库中的一个表格。Entity的每个成员变量都会被保存到数据库中,对于不添加到数据库中的成员变量,需要使用@Ignore进行注解。如果DAO类可以获取到每一个需要持久化的成员变量,则entity的构造函数就可以为空。构造函数可以使用全部或者部分的成员变量,只要类型和名称与成员变量匹配即可。
3. DAO:Data Access Object 数据操作对象,用来定义操作数据库的方法。带有@Database注解的类必须包括一个没有参数的抽象方法,该方法返回以@DAO注解的类。编译时会自动生成这个DAO类的实现。DAO类的意义在于将数据的操作与数据库的操作分离开,这样可以通过修改DAO实现同样的数据操作使用不同的数据库操作或其他操作实现。
使用流程就是通过@Database类获取@DAO对象,调用@DAO对象的方法去操作@Entity的数据。
示例
Entity类:
@Entity
public class User {
@PrimaryKey
private int uid;
@ColumnInfo(name = "first_name") // 可自定义列名
private String firstName;
@ColumnInfo(name = "last_name")
private String lastName;
// 此处省略没有写出getter和setter
}
DAO类:
@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);
}
Database类:
@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对象需要使用单例模式以减少系统开销。
Entities
当一个类添加了@Entity注解,并且在@Database注解类的entities属性中引用了,Room库就会在数据库中为这个entity创建一个表格。
entity中默认所有字段都会生成对应的数据列,可以使用@Ignore注解忽略不需要的字段。Entity的字段必须为Room提供获取路径,比如设为public或者使用getter和setter,getter和setter遵循Java Beans规范。
主键
每个entity必须定义至少一个主键,即使只有一个字段,也需要给这个字段加上@PrimaryKey注解。可以在@PrimaryKey注解中设置autoGenerate属性实现自动生成id。
@Entity(primaryKeys = {"firstName", "lastName"})
class User {
public String firstName;
public String lastName;
@Ignore
Bitmap picture;
}
Room默认使用entity的类名作为数据库表名,可以通过设置tableName属性修改,SQLite中的表名是区分大小写的。
@Entity(tableName = "users")
class User {
...
}
同样地,字段名默认作为数据列名,可通过@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;
}
索引和唯一性
为了加快查询,可以给某些字段设置索引。使用@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属性实现。以下实例代码实现了避免有两条记录包含一样的first_name和last_name值。
@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中采用Foreign Key来约束entity之间的关系。
假设有个新的entity叫做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;
}
ForeignKey用来设置引用对象更新时所进行的操作,比如可以在@ForeignKey注解中设置onDelete=CASCADE,这样当User表中某个对应记录被删除时,Book表的所有相关记录也会被删除掉。
对于@Insert(OnConflict=REPLACE)注解,SQLite是进行REMOVE和REPLACE操作,而不是UPDATE操作,这个可能影响到foreign key的约束。
内嵌对象
有时候可能需要把一个包含多个字段的entity或者POJO对象作为数据库逻辑层面的一个整体来看待,此时可以使用@Embedded注解,表示说要把这个字段在同一表内分解成子字段,@Embeded字段的查询还是跟普通字段的一样。比如说以下的User类包含一个Address类的字段,Address类包含多个子字段,使用@Embeded注解则会把所含子字段分解开储存到表里,即User表格包含id、firstName、street、state、city、post_code数据列。Embeded对象还可以继续内嵌。
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;
}
如果一个entity包含多个同种类型的embeded字段,则可以通过设置prefix属性实现每一列的唯一性。Room会把设置的prefix值添加到对应内嵌对象的列名前面。 比如有两个内嵌对象都是Address类型,表上就会有两个street列,此时可以添加prefix属性来区分这两个street列。
Data Access Objects(DAO)
封装操作数据库的方法。Room不允许在主线程操作数据库,因为可能导致UI阻塞住。在数据库的builder中调用allowMainThreadQueries()方法可以解除这个限制。返回LiveData或者RxJava的Flowable的异步操作不在此限制范围,因为此类操作在需要时会自动异步运行。
以下介绍一些常用的修饰方法的注解:
Insert
@Insert注解会把所有参数一次性插入到数据库当中:
@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方法会返回插入数据的rowId,根据参数个数种类不同返回为long或者long[]或者List< Long>。
Update
@Update注解用来修改entity,使用主键来区分不同的entity。注解的方法可以返回void,也可以返回int表示修改的记录数。
@Dao
public interface MyDao {
@Update
public void updateUsers(User... users);
}
Delete
@Delete注解用来删除entity,使用主键来确定目标entity。注解的方法可以返回void,也可以返回int表示删除的记录数。
Query
@Query注解的方法在编译时会进行检验,不用等到运行时才报异常。Room同时也会检验返回值,如果返回类型的字段与所查询的数据列名只有部分匹配,会发出警告。如果返回类型的字段与所查询的数据列名完全不匹配,则会报错。
一个简单的查询:
@Dao
public interface MyDao {
@Query("SELECT * FROM user")
public User[] loadAllUsers();
}
可以设置过滤条件,只返回符合条件的值:
@Dao
public interface MyDao {
@Query("SELECT * FROM user WHERE age > :minAge")
public User[] loadAllUsersOlderThan(int minAge);
}
编译时,会把注解参数中的:minAge与方法参数中的minAge绑定,即同名匹配,如果匹配失败就会编译报错。
还可以多个参数进行匹配:
@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);
}
注意,冒号后面必须紧跟参数名,中间不能有空格。大于小于号和冒号中间是有空格的。
WHERE 数据列名 > :参数名 大于
WHERE 数据列名 < :参数名 小于
WHERE 数据列名 BETWEEN :参数名 AND :参数名2 这个区间
WHERE 数据列名 LIKE :参数名 等于
WHERE 数据列名 IN (:集合参数名) 查询符合集合内指定字段值的记录
只返回部分列
大多数情况下不需要获取到一条记录的全部字段,只获取需要的部分字段可以节省资源,加快查询速度。
Room允许从查询方法返回任意的Java对象,只要所要查询的数据列名可以映射到这个自定义的Java对象即可。
比如说可以自定义一个只有firstName和lastName的类:
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();
}
这个自定义的POJO也可以使用@Embeded注解。
使用集合作为参数
有时候在运行时才能确定需要传递的查询参数个数,比如某个地区的某些指定用户,此时可以传递集合作为参数,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查询
查询时如果需要数据改变时自动更新UI,则可以返回LiveData,当数据库更新时,Room会自动更新LiveData。对于Room 1.0版本,使用所查询的数据库决定是否更新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的Publisher和Flowable。需要添加依赖android.arch.persistence.room:rxjava2
。
@Dao
public interface MyDao {
@Query("SELECT * from user where id = :id LIMIT 1")
public Flowable<User> loadUserById(int id);
}
直接获取Cursor
直接获取Cursor是不鼓励的,因为无法保证查询的记录是存在的,或者这个记录包含什么值。
@Dao
public interface MyDao {
@Query("SELECT * FROM user WHERE age > :minAge LIMIT 5")
public Cursor loadRawUsersOlderThan(int minAge);
}
查询多个表格
Room可以同时查询多个表格,如果返回的是可观测数据类型,比如Flowable或者LiveData,还可以观测多个表格的数据变化。
下列实例代码展示了从同时查询借书用户表和出借书籍表来合并成一个表的操作:
@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);
}
(根据观察,应该是从loan表的book_id对应的book表的id,然后user表的id对应到loan表的user_id,查询的条件是user.name等于参数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;
}
}
类型转换
Room支持存储基本数据类型及其包装类。如果需要保存自定义类型作为一个数据列,则可以使用TypeConverter把自定义类型转换为Room可以保存的已知类型。比如要保存Date实例,可以转换为Long:
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();
}
}
具体使用:
@Database(entities = {User.java}, version = 1)
@TypeConverters({Converters.class})
public abstract class AppDatabase extends RoomDatabase {
public abstract UserDao userDao();
}
entity的定义:
@Entity
public class User {
...
private Date birthday;
}
具体查询:
@Dao
public interface UserDao {
...
@Query("SELECT * FROM user WHERE birthday BETWEEN :from AND :to")
List<User> findUsersBornBetweenDates(Date from, Date to);
}
可以限制@TypeConverters的使用范围,比如单个entity、整个DAO类,或者DAO类的某个方法,具体查看@TypeConverters的注解说明
数据库升级
使用Migration类,指定了startVersion和endVersion。运行时Room会运行每个Migration类的migrate()方法,使用正确的顺序升级数据库到下一版。如果不提供Migration类,直接修改entity类的话,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会验证数据库的架构模式保证升级的正确性,如果出错会抛出异常。
测试升级
如果错误编写了升级语句,可能导致app崩溃,所以Room提供了testing这个工具来帮助测试,使用testing需要先导出数据库的架构。导入testing:android.arch.persistence.room:testing
,并且添加测试的架构目录
android {
...
sourceSets {
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
}
}
导出数据库架构
编译完成后,Room会把数据库架构导出为JSON文件,需要在build.gradle文件中设置注解处理器的room.schemaLocation属性来获取。
android {
...
defaultConfig {
...
javaCompileOptions {
annotationProcessorOptions {
arguments = ["room.schemaLocation":
"$projectDir/schemas".toString()]
}
}
}
}
导出的JSON文件包含了数据库的架构历史,应该保存到版本控制系统中,这样Room就可以用来创建数据库的旧版本进行测试。
testing库提供了MigrationTestHelper用来读取这些架构文件,以下为示例测试类
@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.
}
}
附录:为什么Room禁止entity间的对象引用
数据库映射成对象是一项常见做法,在服务端也是没有问题的,数据是懒加载的,需要获取的时候才去查询数据库。然而在客户端上,懒加载是不可行的,因为查询很可能发生在主线程,会阻塞UI。UI线程只有16ms的时间来更新界面,所以即使查询数据库只花费了5ms,还是可能无法及时绘制导致卡顿。然而,如果不使用懒加载,就得预先加载超出需求的数据,导致资源损耗。
ORM库一般把这个问题留给开发者决定,开发者又经常把数据模型同时分享在app和UI中,导致维护和调试困难。
比如说一个Book类,每个Book实例包含一个Author对象,试看以下语句:
authorNameTextView.setText(user.getAuthor().getName());
要获取到user对象需要查询一次Book表格,没有问题,但是后面的getName()查询了一次Author表格,而且是在主线程进行的,就可能导致UI卡顿。如果采用一来就查询Author,又会导致性能损耗。如果Author又引用了其他表格,那就更可怕了。
基于以上原因,Room禁止entity之间的相互引用。