阅读 3970

Android从零开始搭建MVVM架构(4)————Room(从入门到进阶)

在真正接触并使用MVVM架构的时候,整个人都不好了。因为个人觉得,MVVM相对于MVC、MVP学习难度比较大,设计的知识点不是一点半点。所以想慢慢记录下自己的成长。如有错误希望指正。


从零开始搭建MVVM架构系列文章(持续更新):
Android从零开始搭建MVVM架构(1)————DataBinding
Android从零开始搭建MVVM架构(2)————ViewModel
Android从零开始搭建MVVM架构(3)————LiveData
Android从零开始搭建MVVM架构(4)————Room(从入门到进阶)
Android从零开始搭建MVVM架构(5)————Lifecycles
Android从零开始搭建MVVM架构(6)————使用玩Android API带你搭建MVVM框架(初级篇)
Android从零开始搭建MVVM架构(7) ———— 使用玩Android API带你搭建MVVM框架(终极篇)


还是那张图AAC(Android Architecture Components)

这篇我们讲Room,让我们了解和认识Room后,最终运用到我们的MVVM的项目中去。本文是自己的总结,如有错误,请指正


一、Room介绍和简单认识

简介
Room是google为了简化旧式的SQLite操作专门提供的一个覆盖SQLite抽象层框架库

作用
实现SQLite的增删改查(通过注解的方式实现增删改查,类似Retrofit。)


在使用Room,有4个模块:

  • Bean:实体类,表示数据库表的数据
  • Dao:数据操作类,包含用于访问数据库的方法
  • Database:数据库持有者 & 数据库版本管理者
  • Room:数据库的创建者 & 负责数据库版本更新的具体实现者

与greendao的区别(这里只是简单从表面看):同样基于ORM模式封装的数据库。而Room和其他ORM对比,具有编译时验证查询语句正常性,支持LiveData数据返回等优势。我们选择room,更多是因为对LiveData的完美支持。同时也支持RxJava,我们都知道数据库操作这些耗时操作都应该放在子线程里,所以配合RxJava和LiveData很完美了。因为他们都是异步的

    //添加Room的依赖
    implementation 'android.arch.persistence.room:runtime:2.1.4'
    annotationProcessor 'android.arch.persistence.room:compiler:2.1.4'
复制代码

二、Bean:实体类,表示数据库表的数据

意思就是我们要往数据库里建表、建字段。就是使用这个bean对象。首先介绍下注解

  • @Entity : 数据表的实体类。
  • @PrimaryKey : 每一个实体类都需要一个唯一的标识。
  • @ColumnInfo : 数据表中字段名称。
  • @Ignore : 标注不需要添加到数据表中的属性。
  • @Embedded : 实体类中引用其他实体类。
  • @ForeignKey : 外键约束。

这里我们建一个Person类(为了能保存数据,使数据持久化且Room必须能够对它进行操作,你可以用public修饰属性,或者你也可以设置成private,但必须提供set和get方法)。这里只是简单展示,后面详细讲解,觉得细节太多了

表名为person的表

@Entity
public class Person {
    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }
    
    @PrimaryKey(autoGenerate = true)
    @ColumnInfo(name = "uid")
    private int uid;

    private String name;
    private int age;

    @Ignore
    private int money;
    @Embedded
    private Address address;
    
    //...我用的是private,暂且去掉了set和get方法。便于读者理解
}

复制代码

Address类:
public class Address {
    private String city;
    private String street;
    //...省略部分代码,便于理解
}
复制代码

2.1、@Entity

用了@Entity标注的类,表示当前类的类名作为表名,这个类里面的所有属性,作为表里的字段。这里我们先只关注@Entity来讲,后面又很多细节,文章接下来都以这种讲解分格。更加直击重点

2.1.1、如果不想用类名作为表名,我们可以这样

//这样的话,我们的表名就变成了 other
@Entity(tableName = "other")
public class Person {
}
复制代码

2.1.2、@Entity里的复合主键

在Person里,我们用@PrimaryKey(autoGenerate = true)标识uid为主键,且设置为自增长。设置为主键的字段不得为空也不允许有重复值。

复合主键:多字段主键则构成主键的多个字段的组合不得有重复(假如我们用name做主键,如果我们有2个name相同的数据一起插入,数据就会被覆盖掉。但是现实中真的有同名的人,是2条数据,这时候我们就要用name和出生日期作为复合主键也就是多个主键,主键都一致才会覆盖数据)

@Entity(primaryKeys = {"uid","name"})
public class Person {
}
复制代码

直接这样设置后,运行项目。这里有几点要注意的:

  • 首先会报错:You must annotate primary keys with @NonNull. "name" is nullable。所以要加上,
@Entity(primaryKeys = {"uid","name"})
public class Person {
    //name字段要用@NonNull标注
    @NonNull
    private String name;
}
复制代码

2.1.3、@Entity里的索引的使用

索引的使用(有单列索引和组合索引,还有索引的唯一性)

//单列索引          @Entity(indices = {@Index(value = "name")})
//单列索引唯一性    @Entity(indices = {@Index(value = "name", unique = true)})
//组合索引          @Entity(indices ={@Index(value = {"name","age"})})
//组合索引唯一性    @Entity(indices ={@Index(value = {"name","age"},unique = true)})
//当然可以混起来用 如下:
@Entity(indices ={@Index(value = "name"),@Index(value = {"name","age"},unique = true)})
public class Person {
    
}
复制代码
  • 数据库索引是用来提高数据库访问速度的,可以说单纯是优化的意思。我们加上索引后,之后的其他操作都没有变的
  • 如果加上唯一性有点类似主键,重复数据会报错,但是索引并不像主键那样,作为条件才能去覆盖数据
  • 插入数据的时候加上动作@Insert(onConflict = OnConflictStrategy.REPLACE)加上动作,他的意思是主键相同的话,旧数据会替换新数据。但如果我们主键不同,但加了索引唯一性的话,索引相同的话,这次插入则失败。相信这么说,应该明白了

2.1.4、@Entity里的外键约束

同样以之前的Person作为父类,我们再定一个衣服类Clothes。(这里先省略Dao,Database,Room步骤,后面会细讲)

Clothes:

@Entity(foreignKeys = @ForeignKey(entity = Person.class,parentColumns = "uid",childColumns = "father_id"))
public class Clothes {
    @PrimaryKey(autoGenerate = true)
    private int id;
    private String color;
    private int father_id;
    //...省略get和set
}
复制代码

好多人不知道外键约束是什么意思,这里我们先往里面插数据,然后我们看看db里的数据:

第一步:我们往Person里面插入2填数据
1、(uid =1 name = 岩浆 age =18)
2、(uid =2 name = 小学生 age=10);

第二部:我们往衣服里面插入3条数据
1、(id = 1 color = 红色 father_id = 1)
2、(id = 2 color = 黑色 father_id = 1)
3、(id = 3 color = 红色 father_id = 2)

这里其实显而易见,可以先认为,person岩浆有2件衣服,红色和黑色的衣服;person小学生有1件衣服,红色的衣服。我们看看表是怎么样的。意思就是用parentColumns = "uid"(person的uid字段)作为childColumns = "father_id"(clothes的father_id字段)。这里就相当于约束到了。先不急,我们看看2张表。

person表(后面会有教程,教你怎么看db数据库):

clothes表

那么为什么说是外键约束呢?当然这里有操作。如下:

@Entity(foreignKeys = @ForeignKey(onDelete = CASCADE,onUpdate = CASCADE,entity = Person.class,parentColumns = "uid",childColumns = "father_id"))
public class Clothes {
    
}
复制代码

这里我加了2个动作,在删除和更新的时候用了onDelete = CASCADE,onUpdate = CASCADE。这里动作有以下:

  • NO_ACTION:当person中的uid有变化的时候clothes的father_id不做任何动作
  • RESTRICT:当person中的uid在clothes里有依赖的时候禁止对person做动作,做动作就会报错。
  • SET_NULL:当person中的uid有变化的时候clothes的father_id会设置为NULL。
  • SET_DEFAULT:当person中的uid有变化的时候clothes的father_id会设置为默认值,我这里是int型,那么会设置为0
  • CASCADE:当person中的uid有变化的时候clothes的father_id跟着变化,假如我把uid = 1的数据删除,那么clothes表里,father_id = 1的都会被删除。

现在是不是很清楚了。很多博客都带过。我也费力讲清楚了。给博主个赞把。文章demo没有做处理,在观察时,记得请按顺序观察。


2.2、@PrimaryKey

//省略部分代码,便于理解
public class Person {
    //person当然不需要符合主键,我们可以直接这样默认uid为主键
    //想要自增长那么这样@PrimaryKey(autoGenerate = true)
    @PrimaryKey
    private int uid;
    }
复制代码

2.3、@ColumnInfo

我们都知道,Person里的属性值名就是表里的字段名。假如不像用属性名当字段名,可以这样

//省略部分代码,便于理解
public class Person {
    //那么这个时候我的主键在表里的key就是uid_
    @ColumnInfo(name = "uid_")
    private int uid;
    }
复制代码

2.4、@Ignore

如果不想要属性值作为表里的字段,那么忽略掉

//省略部分代码,便于理解
public class Person {
    //让我们忽略调钱,人要钱干嘛。。
    @Ignore
    private int money;
    }
复制代码

2.5、@Embedded

实体类中引用其他实体类。这样的话Address里属性也成为了表person的字段。

//省略部分代码,便于理解
public class Person {
    @Embedded
    private Address address;
    }
复制代码

我们Address里有2个字段,city,street,所以我们的表也是

这里有个特殊的地方,比如说这个人很有钱(刚刚才忽略掉钱),有2个家,有2个Address类,那么怎么办呢,

 //@Embedded(prefix = "one"),这个是区分唯一性的,比如说一这个人可能有2个地址类似于tag,那么在数据表中就会以prefix+属性值命名
    @Embedded(prefix = "one")
    private Address address;
    @Embedded(prefix = "two")
    private Address address;
复制代码

三、Dao:数据操作类,包含用于访问数据库的方法

这里直接上代码,相关标注是:

  • @Dao : 标注数据库操作的类。
  • @Query : 包含所有Sqlite语句操作。
  • @Insert : 标注数据库的插入操作。
  • @Delete : 标注数据库的删除操作。
  • @Update : 标注数据库的更新操作。
@Dao
public interface PersonDao {
    //查询所有数据
    @Query("Select * from person")
    List<Person> getAll();

    //删除全部数据
    @Query("DELETE FROM person")
    void deleteAll();

    //一次插入单条数据 或 多条
//    @Insert(onConflict = OnConflictStrategy.REPLACE),这个是干嘛的呢,下面有详细教程
    @Insert
    void insert(Person... persons);

    //一次删除单条数据 或 多条
    @Delete
    void delete(Person... persons);

    //一次更新单条数据 或 多条
    @Update
    void update(Person... persons);

    //根据字段去查找数据
    @Query("SELECT * FROM person WHERE uid= :uid")
    Person getPersonByUid(int uid);

    //一次查找多个数据
    @Query("SELECT * FROM person WHERE uid IN (:userIds)")
    List<Person> loadAllByIds(List<Integer> userIds);

    //多个条件查找
    @Query("SELECT * FROM person WHERE name = :name AND age = :age")
    Person getPersonByNameage(String name, int age);
}

复制代码

这里唯一特殊的就是@Insert。其有一段介绍:对数据库设计时,不允许重复数据的出现。否则,必然造成大量的冗余数据。实际上,难免会碰到这个问题:冲突。当我们像数据库插入数据时,该数据已经存在了,必然造成了冲突。该冲突该怎么处理呢?在@Insert注解中有conflict用于解决插入数据冲突的问题,其默认值为OnConflictStrategy.ABORT。对于OnConflictStrategy而言,它封装了Room解决冲突的相关策略。

  • OnConflictStrategy.REPLACE:冲突策略是取代旧数据同时继续事务
  • OnConflictStrategy.ROLLBACK:冲突策略是回滚事务
  • OnConflictStrategy.ABORT:冲突策略是终止事务
  • OnConflictStrategy.FAIL:冲突策略是事务失败
  • OnConflictStrategy.IGNORE:冲突策略是忽略冲突

这里比如在插入的时候我们加上了OnConflictStrategy.REPLACE,那么往已经有uid=1的person表里再插入uid =1的person数据,那么新数据会覆盖就数据。如果我们什么都不加,那么久是默认的OnConflictStrategy.ABORT,重复上面的动作,你会发现,程序崩溃了。也就是上面说的终止事务。其他大家可以自己试试


四、Database:数据库持有者 & 数据库版本管理者

直接上代码

//注解指定了database的表映射实体数据以及版本等信息(后面会详细讲解版本升级)
@Database(entities = {Person.class, Clothes.class}, version = 1)
public abstract class AppDataBase extends RoomDatabase {
    public abstract PersonDao getPersonDao();
    
    public abstract ClothesDao getClothesDao();
}
复制代码

五、Room:数据库的创建者 & 负责数据库版本更新的具体实现者

Room创建我们的AppDataBase,我们把它封装成单例,省的每次都去执行一遍,耗性能

public class DBInstance {
    //private static final String DB_NAME = "/sdcard/LianSou/room_test.db";
    private static final String DB_NAME = "room_test";
    public static AppDataBase appDataBase;
    public static AppDataBase getInstance(){
        if(appDataBase==null){
            synchronized (DBInstance.class){
                if(appDataBase==null){
                    appDataBase = Room.databaseBuilder(MyApplication.getInstance(),AppDataBase.class, DB_NAME)
                            //下面注释表示允许主线程进行数据库操作,但是不推荐这样做。
                            //我这里是为了Demo展示,稍后会结束和LiveData和RxJava的使用
                            .allowMainThreadQueries()
                            .build();
                }
            }
        }
        return appDataBase;
    }
}
复制代码

做完这一切,那么我们的准备工作就做完了。让我们来插入一条数据

                Person person_ = new Person("Room", 18);
                DBInstance.getInstance().getPersonDao().insert(person_);
复制代码

5.1、额外知识点

这里怎么查看db数据呢?首先我们把db文件存在手机内存里,记得打开存储权限,就是在上面代码里指定路径

private static final String DB_NAME = "/sdcard/LianSou/room_test.db";
插入数据后,就会在手机内存卡生成db文件。

拿到db文件,怎么办呢。用插件!!Database Navigator,插件教程


六、数据库版本升级

这里的意思比如我已经往person表存里数据。但是我要增加字段,或者是增加索引。如果你直接写上去,你会发现,你再使用数据库的时候,会直接崩溃。怎么办呢,用过greendao的人都知道,我们要升级数据库版本

@Entity
public class Person {
    //...省略部分代码,便于理解。
    //这里给Person加上一个儿子
    
}

复制代码

然后来到我们的Database类里,把版本信息改下,并增添一个Migration 类,告诉Room是哪张表改了什么东西

//修改版本信息为2
@Database(entities = {Person.class, Clothes.class}, version = 2)
public abstract class AppDataBase extends RoomDatabase {
    public abstract PersonDao getPersonDao();

    public abstract ClothesDao getClothesDao();

    //数据库变动添加Migration,简白的而说就是版本1到版本2改了什么东西
    public static final Migration MIGRATION_1_2 = new Migration(1, 2) {
        @Override
        public void migrate(SupportSQLiteDatabase database) {
            //告诉person表,增添一个String类型的字段 son
            database.execSQL("ALTER TABLE person ADD COLUMN son TEXT");
        }
    };
}
复制代码

关于版本更新的execSQL里的用法,可以参考Room升级。也可以自行度娘,网上很多


最后来到我们的Room里:

public class DBInstance {
//    private static final String DB_NAME = "/sdcard/LianSou/room_test.db";
    private static final String DB_NAME = "room_test";
    public static AppDataBase appDataBase;
    public static AppDataBase getInstance(){
        if(appDataBase==null){
            synchronized (DBInstance.class){
                if(appDataBase==null){
                    return Room.databaseBuilder(MyApplication.getInstance(),AppDataBase.class, DB_NAME)
                            .allowMainThreadQueries()
                            //加上版本升级信息
                            .addMigrations(AppDataBase.MIGRATION_1_2)
                            .build();
                }
            }
        }
        return appDataBase;
    }
}
复制代码

做完以上操作后,我们来运行下项目看看。成功,打开数据看看(本文demo里,我把升级代码注释了,想测试的可自行打开):


七、Room 结合RxJava使用(需要先了解RxJava的使用)

首先看我们DBInstance里的Room创建我们的AppDataBase,这句代码

//下面注释表示允许主线程进行数据库操作,但是不推荐这样做。
.allowMainThreadQueries()
复制代码

我们,把这句代码注释掉,其他不变,运行下代码,看看。结果会报错,报错信息如下

Caused by: java.lang.IllegalStateException: Cannot access database on the main thread since it may potentially lock the UI for a long period of time.


这个时候,我们来结合RxJava来使用下,这样数据操作可以放在子线程,回调可以切换到主线程更改UI。首先是引入我们的依赖

    implementation 'android.arch.persistence.room:rxjava2:2.1.4'
    //下面这个是配合rxjava使用的
    implementation 'io.reactivex.rxjava2:rxandroid:2.0.2'
复制代码

这里需要注意2点:

1、在数据库执行@Insert、@Delete、@Update操作时候可以使用(注意是可以使用)RxJava里的类型有:Completable,Single,Maybe

2、在执行@Query操作时,可以返回的类型有:Single,Maybe,Observable,Flowable
这里需要注意:

  • 你想一次性查询就用: Single,Maybe;这样的话,如果查询数据库之后数据库有改变时,后面不会有任何事务。
  • 如果你是想观察数据库: Observable,Flowable。那么当已经查询数据了,如果之后数据还有改变,那么将自动执行Observable,Flowable里观察的代码。意思就是对数据可持续观察,实时显示数据库中最新的数据

这里可能大家对Single,Maybe,Completable,Observable,Flowable不大了解,这里做个简单介绍:

1、Completable:只有onComplete和onError方法,即只有“完成”和“错误”两种状态,不会返回具体的结果。

2、Single:其回调为onSuccess和onError,查询成功会在onSuccess中返回结果,需要注意的是,如果未查询到结果,即查询结果为空,会直接走onError回调,抛出EmptyResultSetException异常。

3、Maybe:其回调为onSuccess,onError,onComplete,查询成功,如果有数据,会先回调onSuccess再回调onComplete,如果没有数据,则会直接回调onComplete。

4、Flowable/Observable:这是返回一个可观察的对象,查询的部分有变化时,都会回调它的onNext方法,没有数据变化的话,不回调。直到Rx流断开。


这里为了demo的清晰化,我们再建一个Dog表。这里申明一点,在数据库执行这些操作的时候@Insert、@Delete、@Update,不能直接把返回类型写成RxJava返回,类型,不然会直接报

错误: Methods annotated with @Insert can return either void, long, Long, long[], Long[] or List.

所以现在好多网上关于这部分,也没有讲清楚。如果有清楚的同学请指正。请看Dao类:

@Dao
public interface DogDao {
    //返回值是插入成功的行id
    @Insert
    List<Long> insert(Dog... dogs);

    @Delete
    void delete(Dog... dogs);

    //返回删除的行id
    @Delete
    int delete(Dog dog);


    @Update
    void update(Dog... dogs);

    @Update
    int update(Dog dog);


    //查询所有对象 且 观察数据。用背压Flowable可以实现,如果需要一次性查询,可以用别的类型
    @Query("Select * from dog")
    Flowable<List<Dog>> getAll();


    //删除全部数据
    @Query("DELETE FROM dog")
    void deleteAll();

    
    //根据字段去查找数据
    @Query("SELECT * FROM dog WHERE id= :id")
    Single<Dog> getDogById(int id);

}
复制代码

让我们在代码里,用可观察的背压,去实时查询我们的全部dog。这里只要调用一次,之后数据有更新的时候,会自动走这个观察者回调。

DBInstance.getInstance().getDogDao().getAll().subscribeOn(Schedulers.io())
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe(new Consumer<List<Dog>>() {
                    @Override
                    public void accept(List<Dog> dogs) throws Exception {
                        binding.txtAll.setText("当前狗狗总数" + dogs.size());
                    }
                });
复制代码

那很多人问了。@Insert、@Delete、@Update这些该怎么办。很多博客都是把返回值写在Dao里。真实运行起来,直接报错。所以这里要在代码中使用RxJava。用于项目的时候最好封装起来。比如用Single插入数据:(这里用哪个类型呢,完全根据你的需求而定,比如插入数据后,我要知道插入的行id的是多少,就不能用Completable,因为他没有返回值,这个还是灵活运用的)

Single.fromCallable(new Callable<List<Long>>() {
                    @Override
                    public List<Long> call() throws Exception {

                        Dog dog = new Dog();
                        return DBInstance.getInstance().getDogDao().insert(dog);
                    }
                }).subscribeOn(Schedulers.io())
                        .observeOn(AndroidSchedulers.mainThread())
                        .subscribe(new SingleObserver<List<Long>>() {
                            @Override
                            public void onSubscribe(Disposable d) {


                            }
                            //一次插入多条数据,返回的行id的集合
                            @Override
                            public void onSuccess(List<Long> o) {
                                for (Long data : o) {
                                    LogUtils.i("使用Single插入数据", "onSuccess ==> " + data);
                                }

                            }

                            @Override
                            public void onError(Throwable e) {
                                LogUtils.i("使用Single插入数据", "onError");

                            }
                        });
复制代码

如果你不需要观察者回调,可以直接。

Single.fromCallable(new Callable<List<Long>>() {
                    @Override
                    public List<Long> call() throws Exception {

                        Dog dog = new Dog();
                        return DBInstance.getInstance().getDogDao().insert(dog);
                    }
                }).subscribeOn(Schedulers.io())
                        .subscribe();
复制代码

效果如下(查询一次后,更新数据库,都是得到数据库里最新数据):


七、Room 结合 LiveData使用

这里我们在DogDao中添加LiveData的返回值,(查询范围id里dog的值)

    @Query("SELECT * FROM dog WHERE id>= :minId AND id<= :maxId")
    LiveData<List<Dog>> getToLiveData(int minId, int maxId);
复制代码

Activity里的代码:

 DBInstance.getInstance().getDogDao().getToLiveData(2, 12).observe(this, new Observer<List<Dog>>() {
                    @Override
                    public void onChanged(List<Dog> dogs) {
                        ToastUtils.showToast("查出来的当前值 ==> " + dogs.size());
                    }
                });
复制代码

还记得我们之前讲的LiveData吗。这个时候,LiveData跟随生命周期的。onChanged只会在激活状态下回调,如果销毁了,那么将会取消观察者。


八、Dao中一些数据库指令

@Query("SELECT * FROM TalkListBean WHERE father_id= :father_id ORDER BY isTop DESC,time  DESC limit 0,4")
Flowable<List<TalkListBean>> sortBydDesc(int father_id);

// 这句话的意思是 在TalkListBean表中,找到father_id相同数据(比如聊天列表里,找到与张三全部聊天的人);
// 后面ORDER BY是排序,先根据isTop排序,然后根据时间排序,无论你根据哪个字段类型排序,它都有一定的排序规则;
// DESC是倒序排序,ASC是正序排序;
// 再后面limit 0,4:0表示查询数据的起始位置,4表示pageSize。
复制代码

至此这里对简单的Room介绍完了。不得不说网上很多资料很无脑,到处是官网译文。本文是作者自己的理解,如有错误请指正。看到这里请给我点个赞吧

本文demo地址