SAKA'S BLOG

安卓持久化数据存储框架Room的使用

Room持久话框架为开发者操作sqlite提供了一个中间层,操作类似于jpa,基于注解,反射。现在google强烈推荐使用Room开直接操作SQLite,不建议使用原来的SQLite工具。

Room简介

Room主要包含三个组件:Database、Entity和Dao,做过后台的人对这东西应该相当熟悉。同样的,这三个也都是注解。

  • Database:连接到你需要操作的数据库,里边有所有的表
  • Entity:代表了数据库中的某个表
  • DAO:操作数据库的方法

注意:被Database注解标记的类必须满足下面三个条件

  1. 一个继承自RoomDatabase的抽象类
  2. 必须包含这个数据库中的所有表,也就是标记了Entity注解的类
  3. 必须包含一个抽象方法,返回你要的Dao类,并且不能有任何参数

集成Room框架

集成room框架很简单,直接上代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
dependencies {
def room_version = "1.1.0"

implementation "android.arch.persistence.room:runtime:$room_version"
annotationProcessor "android.arch.persistence.room:compiler:$room_version"

// optional - RxJava support for Room
implementation "android.arch.persistence.room:rxjava2:$room_version"

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

// Test helpers
testImplementation "android.arch.persistence.room:testing:$room_version"
}

定义了一个变量指向Room的版本,修改起来更方便,同时上边代码集成了rxjava2、guava和测试包,可以选择性集成。

定义一个实体

这个类和我们平常的javabean类没什么区别,不同之处多了一些注解。同样,假如不提供getter和setter方法,则必须将变量设置为public,这样Room才能为每个变量生成对应的字段。

官方的一段代码:

1
2
3
4
5
6
7
8
9
10
11
@Entity
public class User {
@PrimaryKey
public int id;

public String firstName;
public String lastName;

@Ignore
Bitmap picture;
}

实体(Entity)

标记一个类为实体,这将会在数据库中创建一个表。该注解中包含的字段比较多,理解也较为简单:

tableName

字符串类型,用来定义表的名称,默认是空值,采用的是类的名称,设置值后选用设置的值。

@Entity{tableName="user"},创建表的时候将会将表名字设置为user。

indices

字符串数组类型,用来创建索引。

1
@Entity{indeices={@index("name"),@index{value={"user","name"}}

定义了user和name字段最为该表的索引。index可以设置为是否唯一,unique=true,默认是false,需要手动设置。

inheritSuperIndices

boolean类型,默认值是false,设置为ture将会从父类继承索引字段作为自己的索引字段,同样这个字段会在子类中出现,假如父类中设置了inheritSuperIndices为false,同样会集成索引。
这个蛋疼的东西最好不要使用,否则会产生不可预知的异常。

primaryKeys

字符串数组,表的主键。

在这里定义的主键不嫩设置为自增。可以为空,但是必须在变量上设置@PrimaryKey。

foreignkey

ForeignKey数组类型,默认为空

1
2
3
4
5
6
7
8
9
10
11
12
@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;
}

上边是一个简单的例子,意思是将User实体中的id关联到Book实体中的user_id字段,这两个字段的值始终相同。

  1. entity—表示建立外键关系的表
  2. parentColumns—表示主表中的键
  3. childColumns—表示被关联的外键
  4. deferred—外键约束是否推迟到事务完成,默认是false
  5. onDelete和onUpdate–默认是NO_ACTION

这个操作共有五种方式,同样这也是外键最烦人的地方

  1. CASCADE 传播

操作父键将会影响子键。

onDelete(),删除父实体中的行将会自动删除子实体中所有的外键相同的行。
onUpdate(),更新父键中的值子实体中的键同样自动修改。

  1. NO_ACTION 不操作

当父键被修改或删除时,子键不执行任何动作。

  1. RESTRICT

禁止修改父键,这种约束是立即生效的,即使设置了deferred,当试图对父键修改时立即抛出错误。

  1. SET_NULL

父键被删除后所有的子键设置为NULL

  1. SET_DEFAULT

父键被删除后所有的子键设置为该列的默认值

主键(PrimaryKey)

标记该字段为该表的主键。

一个表中至少要有一个字段作为主键,获得主键有两种方式,一种就是通过父类中的继承来获得主键,一种是自己声明主键。键入同时设置了的话,子类中的主键将会覆盖父类中的主键。

假如把一个嵌套进来的类设置为主键,那么嵌套类中所有的字段(包括它自己的嵌套类)将作为复合主键使用。

该注解仅有一个字段。

autoGenerate

boolean类型,默认为false。

设置为true后,将会生成唯一的id。按照官方的说法,主键设置为自动生成必须设置为Integer类型,假如是int或者是long类型(包含TypeConvert转换的类型)插入时会设置为0,同样类型为Inter或者Long类型且未设置值的时候该字段会默认为未设置,但是我在实际使用的时候发现这个字段会自增,从1开始,应该是策略问题。

列(ColumnInfo)

标记该变量为表中的一个字段,实际上只要不标注Ignore注解,所有的字段都会被映射到表中作为列。

name

字符串类型,默认为变量名称。

设置该属性后,会一这个字符串作为表的列:@ColumnInfo(name="age")private String mAge,这样标注的注解最后会在表中以age列名存在,注意我们为列起了名称以后,必须使用表中的名称来做操作,比如在Entity中设置主键和索引,必须使用age,而不能使用mAge。

typeAffinity

这个字段和Enity中的foreignkey类似,是被注解标记的字段。

SQLiteTypeAffinity类型,默认值是UNSPECIFIED,这个字段用来在构建数据库的时候设置Sqlite中column的affinity类型,共有五种:UNSPECIFIED(根据类型自动填入),TEXT(String),INTEGER(integer||boolean),REAL(float||double),BLOB(二进制).

index

boolean类型,默认值是false。

设置本列为index

collation

Collation类型,默认值是UNSOECIFIED,这个字段用来在构建数据的时候设置顺序,共有五种类型:`UNSPECIFIED(未指定),BINARY(大小写敏感匹配),NOCASE(大小写不敏感匹配),TRIM(除去空格大小写敏感匹配),LOCALISED(系统匹配)。

忽略域(Ignore)

使用该注解将不想做该类中不做持久化数据存储的变量标记出来,在构建数据库的时候就不会包含该列。注意,想要忽略某个变量必须使用Ignore,没任何标注的变量会自动生成列。

嵌套类(Embedded)

用于嵌套类。假设两个类A、B,A中包含B,为A设置了Entity属性,则会自动包含b中的变量作为列。一个简单的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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;
}
prefix

字符串类型,默认为空。

这个字段允许开发者为被嵌套类的字段前加上一个前缀。

1
2
@Embedded(prefix="address_")
public Address address;

这样在生成数据库的时候所有Address中字段前边都会加上address_

上边的例子中User中嵌套了一个Address,则在生成uer表的时候会将Address中所有字段包含进来,最终含有id,first_name,street,state,city,post_code共六个字段。

操作(DAO)

DAO层是直接对数据库进行增删改查工作的中间层,我们主要的工作都在这层完成。DAO层可以设置为借口或者抽象类,系统会自动生成DAO的实现类,建议使用接口。

首先讲解一下关于SQLite冲突的一些事情,有四种情况会导致事务冲突:

  1. PRIMARY KEY

这是最常见的一种,主键冲突,SQLite建议主键不能为空,且不能重复,但是由于历史原因,主键可以为空,但是插入或者更新的时候主键重复将会产生事务冲突。

  1. UNIQUE

类似于主键,指示本列数据不能有重复,否则将会产生冲突。

  1. NOT NULL

当指定某一列的值为not null而在插入或更新时此列是null,则会产生冲突。

  1. CHECK

在有表达式类型的约束中,每次插入或者更新数据会自动检查表达式的结果是否违反了约束行为,假如是则会产生冲突

事务冲突的处理方式总共分为五种:

  1. Rollback

一个事务中可能包含多个语句,
当事务冲突时,当前语句会终止并回滚到事务发生之前的状态。假如当前没有事务,则结果与ABORT相同。

  1. Abort

终止当前事务中的语句,并将当前语句中的所有更改取消,但是当前事务中的已执行语句不会被取消,这点区别与Rollback。

  1. FAIL

这个相对于Abort更进一步,当前执行语句会终止但不会取消回到语句执行之前的状态,比如在执行100行的时候发生错误了,则前99行会保留,100行及之前的语句都会保留

  1. IGNORE

忽略当前错误并跳过执行后边的语句。

  1. REPLACE

假如是数据重复,删除之前存在冲突部分数据吗,然后执行该语句。假如是NULL冲突,则插入的数据会使用默认值,没有默认值的时候则会abort。
一个

增(insert)

标记了该注解的方法将会在系统自动生成的勒种实现该方法,并将所有的参数插入数据库。

1
2
3
4
5
6
7
8
9
10
11
@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);
}

onConflict

OnConflictStrategy类型,默认值是ABORT。

改(update)

将会使用主键来查找数据库中的数据并修改。

1
2
3
4
5
@Dao
public interface MyDao {
@Update
public void updateUsers(User... users);
}

冲突处理方式同Insert

删(Delete)

同样是使用主键来删除数据。

1
2
3
4
5
@Dao
public interface MyDao {
@Delete
public void deleteUsers(User... users);
}

查(Query)

查询方式是所有注解中最重要的一种,Room支持多种查询。

每一个查询方法都是在编译期确认的,假如有问题的话会直接报警告或者错误,而不会等到运行期。

简单查询
1
2
3
4
5
@Dao
public interface MyDao {
@Query("SELECT * FROM user")
public User[] loadAllUsers();
}

一种最简单的查询方式,假如表user不存在的话,则在编译器会直接报错。

带参数查询

直接上官方的例子,查询年龄大于某个值得所有人:

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

也可以同时传多个值:

1
2
3
4
5
6
7
8
9
@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);
}
返回部分列的数据

在某些时候我们不想要返回该表的所有数据,当然有一种比较笨的方式就是返回所有列的数据然后挑出自己想要的数据新建一个类,其实在Room中我们可以直接新建一个类只包含想要的字段,然后调用方法即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
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();
}

注意在NameTuple类上并没有标注Entity,Room不会创建这个表,这样我们就只挑出了user表中的两个字段数据作为一个list。

使用集合作为参数

有时候我们需要查询的数据并不确定,而是需要通过一个结合来判断是否在该集合中,这个时候就需要这种方法:

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

观察者方式查询

当查询时需要app的ui同步更新,则可以使用LiveData方式来查询:

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

这种方式必须集成LiveData库

rxJava方式查询

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

这种方式必须集成rxjava

直接获取指针

类似于我们原来使用的SQLite中的curse。

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

谷歌非常不推荐使用这种方式,除非是必须的情况,因为可能产生不可预料的错误

多表查询

join语句,用过的应该都很熟悉,将两个表中的数据部分合到一个类中。

1
2
3
4
5
6
7
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);
}

上边的例子中有三个表,book,loan和user,讲一下是什么意思(只是一个浅显的说明,但SQLlite不会这样查询)首先把book中所有的数据罗列出来,然后将loan表中的book_id字段添加到book数据,并且挑选出loan表中的book_id 字段与book表中id字段相同的数据,这个一个对应的关系,然后将user表中的id字段添加到book数据,并且只挑选出loan表中user_id和user表中id相同的数据,最后筛选出user表中name与参数相同的数据显示,也就是我们只查询了username这个人所有借书信息。

还有一种别名查询方式,用过的也都应该清楚

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@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;
}
}

转换器(typeConvert)

Room的转换器为我们提供了一种简单的数据转换方式,比如将枚举类型转换为int类型存储到数据库,将Date类型转换为Long类型存储到数据库。

1
2
3
4
5
6
7
8
9
10
11
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();
}
}

这是一个官方的例子,定义了两个方法,分别是data转long和long转date。
那么如何使用这个转换器呢?

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

这里是直接在database中定义的转换器,那么所有的Dao类和Entity类都会使用这个转换器。

定义在Dao类上的转换器作用与该类的所有方法。

定义在Entity类上的转换器作用于该类的所有变量

定义在一个POJO类上,这个POJO类的所有变量都会应用这个转换器

定义在Entity类的变量上,那这个变量将使用这个转换器

定义在Dao类的某个方法上,这个方法将会使用这个转换器

定义在Dao类的方法的某个参数上,那这个参数将 使用这个转换器

DataBase

前边讲过了几点关于database:

1
2
3
4
5
6
7
8
9
10
// User and Book are classes annotated with @Entity.
@Database(version = 1, entities = {User.class, Book.class})
abstract class AppDatabase extends RoomDatabase {
// BookDao is a class annotated with @Dao.
abstract public BookDao bookDao();
// UserDao is a class annotated with @Dao.
abstract public UserDao userDao();
// UserBookDao is a class annotated with @Dao.
abstract public UserBookDao userBookDao();
}
entities

Class数组

包含所有存在于该数据库中的表的类,必须标注了Entity

export

boolean类型,默认值是true

这个字段是用来指示是否将数据库实体导出,默认为true,但是假如没有在gradle中设置room.schemaLocation,则会编译时提示错误,但是编译会通过,设置导出路径的方法如下:

app目录下的build.gradle:

1
2
3
4
5
6
7
8
9
10
11
12
13
android {
...
defaultConfig {
...
javaCompileOptions {
annotationProcessorOptions {
arguments = ["room.schemaLocation": "$project.rootDir/schemas".toString()] }

}

}

}

这样在工程根目录下就生成了一个json文件。

version

int类型

用于指示数据库的版本信息,在数据库升级时会使用

迁移数据库

在数据库结构发生变化时,我们需要迁移数据库,来保证用户数据不丢失。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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");
}
};

这是一个简单的迁移方案,首先要定义一个抽象类的实现,从1到2添加了一个fruit表,从2到3为book表添加了pub_year列。

总结

使用Room来管理SQLite大大简化了一些数据操作,让我们有更多的精力去关注其他方面。

做好前边的工作,我们只需要创建数据库即可:

1
2
AppDatabase database =
Room.databaseBuilder(this, AppDatabase.class, "room.db").build();

然后滴啊用database的userDao()方法来执行增删改查工作。Room禁止开发者在主线程调用这些方法,除非设置了.allowMainThreadQueries()方法,但这并不值得鼓励,操作数据库最好在子线程中。