上一篇说完了Android组件架构中的ViewModel和LiveData,地址:ViewModel和LiveData,两者配合使用确实比传统的app架构更加优雅,更加职责分离。而除了这种app架构,google还推出了一个关于数据持久化方案的组件Room。
何为Room?
Room是一套基于Sqlite数据库封装的一套框架组件,旨在简化Android中对数据库的操作。我们都知道Android原生的数据库是Sqlite,但是有一定的缺陷:
- api虽然很多,但是使用起来相对麻烦。
- 没有编译时的SQL检查机制,当表发生改变的时候,需要手动更新受影响的查询,耗费时间且容易出错。
- 对查询结果没有很好的封装,需要手动把查询结果转换成相应的对象。
Room可以大大简化我们对数据库的操作。类似的框架有GreenDao、OrmLite、Litepal等等,也都比较优秀,但Room毕竟是官方提供的框架,所以还是有必要学习一下的。
Room组件主要分为以下三个部分。
- @Entity注解,用来标注实体类,对应数据库中的表,实体类的字段对应数据库中的列,可以设置列名、索引、主键外键等等。
- @Dao注解,用来标注对数据库提供增删改查方法的接口,但不用提供实现,编译时会采用apt技术动态生成该接口的实现类。
- @Database注解,用来标注提供操作数据库对象的类。
说说具体使用,先跑起来,然后再深入,需要两张表。
- 商品分类表(category):包含的字段有:cid(分类id)、cname(分类名称)、cstatus(分类状态)。
- 商品表(product):包含的字段有:pid(商品id),pname(商品名称),pno(商品所属分类,依赖于分类表中的cid字段)
使用之前需要先添加依赖:
dependencies {
...
// 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'
}
1.新建实体类:
category:
@Entity(tableName = "category")
public class Category {
@ColumnInfo(name = "cid")
@PrimaryKey
public int cid;
@ColumnInfo(name = "cname")
public String cname;
@ColumnInfo(name = "cstatus")
public int cstatus;
}
product:
@Entity(tableName = "product")
public class Product {
@PrimaryKey
private int pid;
private String pname;
private int pno;
public int getPid() {
return pid;
}
public void setPid(int pid) {
this.pid = pid;
}
public String getPname() {
return pname;
}
public void setPname(String pname) {
this.pname = pname;
}
public int getPno() {
return pno;
}
public void setPno(int pno) {
this.pno = pno;
}
}
需要注意两点:
- 用@Entity标注实体类的时候,如果不指定表名,则默认为类名,sqlite中数据库大小写敏感;属性如果不指定列名,则列名是属性名,也可以在该属性上通过@ColumnInfo注解指定该属性对应的列名。
- 实体类的属性要么是public,要么是private,是private时需要提供get和set方法。
- 每个实体类至少指定一个主键。如果不指定主键,编译会报错。用@PrimaryKey标记属性即可。
2.新建Dao:
CategoryDao:
@Dao
public interface CategoryDao {
//插入
@Insert
void insert(Category... categories);
//删除
@Delete
void delete(Category category);
//修改
@Update
int update(Category... categories);
//查询
@Query("select * from category where cid = :cid")
List<Category> Category(int cid);
}
ProductDao:
@Dao
public interface ProductDao {
//插入
@Insert
void insert(Product... categories);
//删除
@Delete
void delete(Product category);
//修改
@Update
void update(Product... categories);
//查询
@Query("select * from product where pname = :pname")
List<Product> Category(int pname);
}
有以下几点需要注意:
1. insert的时候,返回值可以是void,也可以是long[]或者List,表示插入行的rowId数组,如果只插入一条,返回值还可以是Long。参数也不固定,可以是List,也可以是Category[]、可变参数、或者是一个Category对象,只要至少包含一个Category对象即可。
2. delete的时候,如果传入的参数是一个对象,则会根据该对象的主键去删除该行。
3. update时,Room会把对应的参数信息更新到数据库里面去(会根据参数里面的primary key做更新操作),返回修改的行数。
4. @Query不仅仅可以查询,同样可以实现增加、删除、修改,只不过用@Query标记方法时,需要提供sql语句。比如:delete from category where cid =:cid,cid是需要传入的参数。返回值的定义可参考以上三点。
3.新建DataBase:
新建xxDataBase类,该类需要继承自RoomDatabase,且必须提供一个0参数,返回值为xxDao的方法,该类必须是抽象的。
@Database(entities = {Category.class,Product.class},version = 1,exportSchema = false)
public abstract class MyDataBase extends RoomDatabase {
//返回CategoryDao
public abstract CategoryDao getCategoryDao();
//返回CategoryDao
public abstract ProductDao getProductDao();
}
需要用@Database注解注解该类,并指定其所拥有的表对应的实体类的Class数组,必须version指定数据库的版本号。exportSchema 先指定为false,否则会报错。
如何使用:
向category表中插入两条数据,product中插入四条数据:
public class MainActivity extends AppCompatActivity {
private MyDataBase myDataBase;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
new Thread(new Runnable() {
@Override
public void run() {
roomDemo();
}
}).start();
}
private void roomDemo() {
myDataBase = Room.databaseBuilder(this, MyDataBase.class, "MyDataBase").build();
Category category1 = new Category();
category1.cid = 1;
category1.cname = "手机数码";
category1.cstatus = 0;
Category category2 = new Category();
category2.cid = 2;
category2.cname = "家电家居";
category2.cstatus = 0;
Category category3 = new Category();
category3.cid = 3;
category3.cname = "服饰箱包";
category3.cstatus = 0;
myDataBase.getCategoryDao().insert(category1, category2,category3 );
}
}
用SQLiteStudio查看数据:
emmmmm~
好了,这只是简单的使用,下面来深入讲一讲各个注解的使用。需要注意的是,访问数据库需要在子线程中执行,在主线程中执行会提示如下错误:
4.深入@Entity注解。
看下@Entity的源码,看下该注解可以设置哪些值:
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.CLASS)
public @interface Entity {
String tableName() default "";
Index[] indices() default {};
boolean inheritSuperIndices() default false;
String[] primaryKeys() default {};
ForeignKey[] foreignKeys() default {};
}
这里删除了所有注释。tableName是设置表名,前面已经说过,下面说说剩下的值的作用。
1. indices
用于设置索引,具体用法,假设给Category的cid和cname设置索引。则:
@Entity(tableName = "category", indices = {@Index(value = {"cid", "cname"})})
public class Category {
@ColumnInfo(name = "cid")
@PrimaryKey
public int cid;
@ColumnInfo(name = "cname")
public String cname;
@ColumnInfo(name = "cstatus")
public int cstatus;
}
indices 是一个索引数组,所以可以设置不止一个索引。而索引用@Index标记。看下@Index:
@Target({})
@Retention(RetentionPolicy.CLASS)
public @interface Index {
String[] value();
String name() default "";
boolean unique() default false;
}
value用于指定索引的列名数组,name是指定索引的名称,unique是指定索引是唯一索引还是非唯一索引,唯一索引数据不允许出现相同的两列,就像主键一样,默认是非唯一索引。
- inheritSuperIndices
是否自动继承父类的索引。
3.primaryKeys
用于指定主键,上面我们在属性上方用@PrimaryKey去指定该属性为主键,而primaryKeys是@Entity注解中的值,同样可以用来指定主键,可以指定一个主键,也可以指定多个主键:
@Entity(tableName = "category",primaryKeys = {"cid","cname"})
public class Category {
@ColumnInfo(name = "cid")
@PrimaryKey
public int cid;
@ColumnInfo(name = "cname")
public String cname;
@ColumnInfo(name = "cstatus")
public int cstatus;
}
这里就指定了cid和cname为主键。
- foreignKeys
用于指定数据表的外键。用@ForeignKey定义外键,假设product的pno字段来源于category的cid字段,则cid字段就是product中pno的外键。则外键的定义如下:
@Entity(tableName = "category")
public class Category {
@ColumnInfo(name = "cid")
@PrimaryKey
public int cid;
@ColumnInfo(name = "cname")
public String cname;
@ColumnInfo(name = "cstatus")
public int cstatus;
}
@Entity(tableName = "product", foreignKeys = @ForeignKey(entity = Category.class,
parentColumns = "cid",
childColumns = "pno"))
public class Product {
@PrimaryKey
private int pid;
private String pname;
private int pno;
public int getPid() {
return pid;
}
public void setPid(int pid) {
this.pid = pid;
}
public String getPname() {
return pname;
}
public void setPname(String pname) {
this.pname = pname;
}
public int getPno() {
return pno;
}
public void setPno(int pno) {
this.pno = pno;
}
}
entity指定外键字段所在的表,parentColumns 指定外键字段,是个字符串数组,只有一个时可以省略大括号。childColumns 指定需要定义外键的字段。还有两个值onDelete和onUpdate分别指定当parent表(category)删除和更新的时候,child表(product)可以做的操作:有以下几个值:
int NO_ACTION = 1;//当parent中的key有依赖的时候禁止对parent做动作,做动作就会报错。
int RESTRICT = 2;//当parent中的key有依赖的时候禁止对parent做动作,做动作就会报错。
int SET_NULL = 3;//如果parent表中有更新或删除操作,child表中对应的字段置为null
int SET_DEFAULT = 4;//如果parent表中有更新或删除操作,child表中对应的字段置为默认值
int CASCADE = 5;如果parent表中有更新或删除操作,child表中会删除对应的记录。
默认是NO_ACTION。会去先检查child表和parent的外键约束。RESTRICT 和NO_ACTION 效果一样。这里验证下CASCADE ,category删除cid=2的记录,看看结果是报错还是product中pno=2的所有记录被删除:
程序没有报错,而且product中pno=2的所有记录都被删除了,说明是成立的。至于SET_NULL 和SET_DEFAULT 大家有兴趣可以自行验证。onUpdate和onDelete一样,这里就不再赘述了。
5.深入@Insert和@Update:
@Insert和@update是用来标注xxDao中增删改查的方法的。当然还有@Query和@Delete,但是这两个还有未挖掘的属性,所以单独拎出来说一下:
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.CLASS)
public @interface Insert {
/**
* What to do if a conflict happens.
* @see <a href="https://sqlite.org/lang_conflict.html">SQLite conflict documentation</a>
*
* @return How to handle conflicts. Defaults to {@link OnConflictStrategy#ABORT}.
*/
@OnConflictStrategy
int onConflict() default OnConflictStrategy.ABORT;
}
public @interface Update {
/**
* What to do if a conflict happens.
* @see <a href="https://sqlite.org/lang_conflict.html">SQLite conflict documentation</a>
*
* @return How to handle conflicts. Defaults to {@link OnConflictStrategy#ABORT}.
*/
@OnConflictStrategy
int onConflict() default OnConflictStrategy.ABORT;
}
可以看到,这两个注解都可以设置一个onConflict属性。表示插入或更新出现冲突时,比如category新增了一行已经存在的分类,数据库所采取的策略。有以下几种模式:
int REPLACE = 1;//冲突时取代旧数据同时继续事务。
int ROLLBACK = 2;//冲突时回滚事务。
int ABORT = 3;//冲突时终止事务。
int FAIL = 4;//冲突时事务失败。
int IGNORE = 5;//冲突时忽略冲突。
默认都是ABORT,回滚成冲突之前的样子。现在我们往category插入一天和cid=3一样的记录,结果程序虽然没有插入该条记录,但是也不会报错,而是把冲突忽略掉了。如果是默认的ABORT ,插入时程序则会报错。设置为REPLACE 时,发生冲突时,会把旧数据取代,解决冲突并继续事务,程序也不会报错。
6.深入@Query注解:
@Query不仅能用来查询,还能用来删除和修改,只需要写一个sql语句,并把所需要的参数传给方法即可。
查询所有分类信息:
@Query("select * from category")
List<Category> query(int cid);
根据cid查询:
@Query("select * from category where cid = :cid")
List<Category> query(int cid);
可以携带多个条件:
@Query("select * from category where cid = :cid and cname=:cname")
List<Category> query(int cid,String cname);
查询结果可以是List,也可以是Category[],甚至你确定只会有一条记录,还可以返回一个Category对象。
利用@Query注解更新数据:
@Query("update category set cname=:cname where cid=:cid")
int update(int cid,String cname);
update结果可以返回void,也可以返回int,表示受影响的行数。
还可以配合LiveData一起使用,如果对LiveDate还不了解的朋友,可以看看这一篇博客:LiveData和ViewModel使用。这里不再深究:
@Query("select * from category where cid = :cid")
LiveData<List<Category>> query(int cid);
还可以配合大名鼎鼎的RxJava使用:
@Query("select * from category where cid = :cid")
Flowable<List<Category>> query(int cid);
拿到 Flowable<List>对象后就可以调用RxJava的各种api了:
myDataBase.getCategoryDao()
.query(1)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(new Consumer<List<Category>>() {
@Override
public void accept(List<Category> entities) {
//TODO
}
});
7.深入@Database注解
@Database注解包含的属性:
- entities:数据库相关的所有Entity实体类,他们会映射成数据库中的表。
- version:数据库版本。
- exportSchema:默认true,也是建议传true,这样可以把Schema导出到一个文件夹里面。
在运行时,可以通过调用Room.databaseBuilder()或者Room.inMemoryDatabaseBuilder()两种方式获取RoomDatabase实例。但两者有些区别:
- Room.databaseBuilder():生成Database对象,并且创建一个存在文件系统中的数据库。
- Room.inMemoryDatabaseBuilder():生成Database对象并且创建一个存在内存中的数据库。当应用退出的时候(应用进程关闭)数据库也消失。
生成数据库对象需要比较大的开销,建议写成单例的形式或者在Application中进行初始化。
采用Room.databaseBuilder()生成数据库对象时,还有以下api可以调用:
/**
* 默认值是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);
8.数据库升级和数据迁移
数据库升级和数据的迁移需要用到Migration这个类,这个类中定义了我们我们版本升级时需要做的操作,然后再把它给数据库对象,由数据库对象来替我们完成版本的升级。举个例子?现在要在category表中新增一列描述信息列(cdesc)。假设数据库版本从1升级到2。
首先定义数据库升级所需要做的操作:
/**
* 数据库版本 1->2 category表新增了cdesc列
*/
static final Migration MIGRATION_1_2 = new Migration(1, 2) {
@Override
public void migrate(SupportSQLiteDatabase database) {
database.execSQL("ALTER TABLE User ADD COLUMN age varchar(100)");
}
};
定义完成后,需要在数据库初始化的时候将其配置给数据库对象:
myDataBase = Room.databaseBuilder(this, MyDataBase.class, "MyDataBase")
//配置数据库升级所要做的操作
.addMigrations(MIGRATION_1_2)
.build();
注意: Entity中能用Integer的时候不用int。表中的结构发生变化或者新增表时,版本号都需要增加,否则会直接cracsh;增加版本号的同时,也需要提供Migration对象,否则也会崩溃。
最开始我们说过,@Database的属性exportSchema,我们设置了false,但这是不被推荐的。其默认值是true,建议传true,但之前为了不报错,我们设置成了false。现在让他默认true。这样可以把Schema导出到一个文件夹里面。 表的信息会以json文件的形式存在,该文件代表了你的数据库表历史记录,这样允许Room创建旧版本的数据库用于测试。只需要在app的build.gradle中简单的配置即可:
android {
...
defaultConfig {
...
javaCompileOptions {
annotationProcessorOptions {
arguments = ["room.schemaLocation":
"$projectDir/schemas".toString()]
}
}
}
// 用于测试
sourceSets {
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
}
}
执行项目后,json文件就会被存储到如下路径:
关于Room的部分先写到这里,有未尽的欢迎各位指正,我会找时间加上,就酱,end~