Android ROOM 数据库高手秘籍

7,169 阅读8分钟

介绍

ROOM 是 Google Jetpak 全家桶之一、是一个 ORM (Object Relational Mapping) 对象关系映射数据库、其底层还是封装的 SQLite 的能力。释放开发者需要关注 SQL 语句注意力可以放在业务逻辑中,在 Java 领域还有 Hibernate、MyBatis 是类似的 ORM 数据库框架。

优势

ROOM 出来得不算早,在此之前 Android 也有一些数据库框架:

  • greendao

  • realm

  • litepal 就聊上面三个最有代表性的数据库框架吧:

  • greendao 是 room 之前用得最广泛的 ORM 数据库框架,不过官方目前已经不再积极维护(官方在推新品 ObjectBox ) 在 ROOM 出来以后据非官方数据统计多种场景下(插入、更新、删除),ROOM 性能上也只是和 greendao 不相上下,强得有限,毕竟底层都还是 Android 的 SQLite 只能通过包装层和生成语句去优化。抛开 greendao 和 ROOM 的性能来说,ROOM 还有其他的优势是我们值得去投入学习的原因

  • realm 不是基于 SQLite ,它自有数据库引擎,号称速度比 SQLite 快 10 倍。支持多种语言设备,支持内存数据库,但是目前在 Android 端来看,想要取代 SQLite 还有很长的路要走

  • litepal 是国内 Android 大拿、《第一行代码》作者郭霖开源维护的,支持国产开源作品从我做起,大家不妨去给个 star ,郭神对其还配备的非常详尽的中文文档和博客,也是我们非常好的学习对象。

综合所有的 Android 平台 ORM 数据库来看,ROOM 有优秀的效率、支持内存映射、支持与 LiveData 绑定并且最重要的是有着 Google Android 官方团队去维护,官方下场势必的会造成其他数据库的维护减少变慢,所以 ORM ROOM 是你值得投入学习的不二选择。

基础

依赖

def room_version = "2.3.0"
    implementation "androidx.room:room-runtime:$room_version"
    annotationProcessor "androidx.room:room-compiler:$room_version"

表定义 Entity

import androidx.room.ColumnInfo;
import androidx.room.Entity;
import androidx.room.PrimaryKey;
@Entity
public class Car {
    @PrimaryKey(autoGenerate = true) //可设置主键自增长
    public int uid;

    @ColumnInfo
    public String brand;

    @ColumnInfo
    public String displacement;

    public Car(String brand, String displacement) {
        this.brand = brand;
        this.displacement = displacement;
    }
}

操作接口定义 DAO

DAO (database access object)

@Dao
public interface CarDao {

    @Query("select * from car")
    List<Car> getAll();

    @Query("select * from car where uid in (:carIds) ")
    List<Car> getUserListByIds(int[] carIds);

    @Insert(onConflict = OnConflictStrategy.REPLACE) //遇到重复的插入则替换
    void insertCar(Car... Car);

    @Delete
    void delete(Car car);
}

数据库 Database

抽象类

@Database(entities = {Car.class},version = 1)
public abstract class AppDatabase extends RoomDatabase {
    public abstract CarDao carDao();
}

实例生成

db = Room.databaseBuilder(mContext.getApplicationContext(), AppDatabase.class, "KTV_DATA").build(); //全局应该单例
carDao = db.carDao();

拿到 dao 的接口实例后我们就可以对接口定义的增删改查等方法进行操作了,例如:

 carDao.insertCar(new Car("凯迪拉克", "2.0T"));

内存缓存数据库

Room.inMemoryDatabaseBuilder(context.getApplicationContext(),
                            AppDatabase.class)

Builder API

构造数据库之前 build 提供了很多功能的 API 给我们调用,其中包含一些相当重要的 API 。我们需要对其了解:

Room.databaseBuilder(context.getApplicationContext(),
                            AppDatabase.class, "ktv.db")
    // .xxx 
    // .xxx
    .build();

个人对其所有的 API 进行的自己理解的中文释义,如有不正确还请留言评论指正(API 基于 2.3.0 Version)

接口释义
addCallback数据库创建、打开、破坏性迁移的回调
addMigrations数据库迁移
addTypeConverter添加非基本数据类型的数据库转换器,例如 long 转 date
allowMainThreadQueries允许主线程访问数据库
createFromAsset().createFromFile().createXX允许通过其他方式文件、流预填充数据库
enableMultiInstanceInvalidation如果您的应用在多个进程中运行,请在数据库构建器调用中包含 enableMultiInstanceInvalidation()。这样,如果您在每个进程中都有一个 AppDatabase 实例,就可以在一个进程中使共享数据库文件失效,并且这种失效会自动传播到其他进程中的 AppDatabase 实例,默认不开启。
fallbackToDestructiveMigration不提供数据库迁移,又不想引发 crash 。但是注意数据会丢失删除数据库重建
fallbackToDestructiveMigrationOnDowngrade如果您仅在从较高数据库版本迁移到较低数据库版本时才希望 Room 回退到破坏性重新创建,请用该方法
openHelperFactory设置数据库的工厂类,如果没有设置。则默认使用内置的 FrameworkSQLiteOpenHelperFactory
setAutoCloseTimeout启用一个数据库打开后空闲没有使用资源的自动关闭策略
setJournalMode设置数据库的日志模式,如果使用内存数据库构建请忽略此值
setMultiInstanceInvalidationServiceIntent//TODO
setQueryCallback每当数据库执行查询操作时候回调,不建议在生产环境使用
setQueryExecutor设置查询的线程池一般不需要设置,会使用默认的 ArchTaskExecutor 内置线程池
setTransactionExecutor设置事务线程池一般不需要设置,会使用查询方法的默认线程池,详可见源码 RoomDatabase.java#build()

进阶

实现类生成

room 在我们定义 entity dao 和 database 的时候有很多注解,这些注解是为了帮我们生成代码的,包括一些表创建和入门成本更高的 sql 语句等。生成的位置在 app/build/generated/ap_generated_source/debug(release)/out/your packagename/,我们可以查看这些实现类的代码,以及在里面进行 debug 断点调试。

数据库位置

如果在创建数据库的时候未指定具体位置生成的位置则是在 data/data/包名/database 下,如果需要指定额外的位置则在上文数据库构建的时候传数据库名前面带上你需要指定的路径。

数据库导出与查看

我们已经知道了数据库所在手机文件系统的路径。如果我们想查看数据库方式有很多,例如:

image.png

或者在 AS 中装插件支持查看,这里介绍我常用的一种方式。将文件导出到电脑(Save as 或者 adb 命令导出) 然后 sqlitebrowser.org/dl/ 查看器去查看表内数据,例如下所示:

image.png

这是一款完全免费的数据库工具,除了查看功能还可以执行 SQL 语句

image.png 方便我们测试

数据库加密

SQLCipher

ROOM 提供了对外自定义数据库加密入口如 : github.com/xmaihh/SQLC…

ROOM&LiveData

作为 Jetpack 全家桶之一 ROOM 与 Livedata 也有结合用法:


@Dao
public interface CarDao {

    @Query("select * from car")
    LiveData<List<Car>> getAll();// 观察全部数据

    @Query("select * from car where uid = :carId")
    LiveData<Car> getCarById(int carId);//观察某一个数据

添加观察

carDao.getAll().observe(SecondActivity.this, new Observer<List<Car>>() {
    @Override
    public void onChanged(List<Car> cars) {
        Log.e("onChanged", "size : " + cars.size());
        for (Car car : cars) {
            Log.e("zxm", "uid : " + car.uid + " brand : " + car.brand + " displacement :" + car.displacement);
        }
    }
});
carDao.getCarById(1637).observe(SecondActivity.this, new Observer<Car>() {
    @Override
    public void onChanged(Car car) {
        Log.e("zxm", "car : " + car.uid + " " + car.brand + " " + car.displacement);
    }
});

第一次添加观察者的时候会收到一次数据 onChanged 的回调,其次数据库中的 Car 表的数据发生变化都会收到 onChanged 的观察回调,但是这里我们需要注意一个细节,也是很多博客以及文章过没有提及到的,如果你的 @insert 注解是使用的 replace 策略(OnConflictStrategy.REPLACE),这样插入重复的数据也会导致事务的产生旧数据被替换插入 onChanged 也因为重复数据的插入频繁回调!

常见报错

1

image.png

解决:注解处理器未提供输出路径 gradle

    defaultConfig {
        //指定room.schemaLocation生成的文件路径
        javaCompileOptions {
            annotationProcessorOptions {
                arguments = ["room.schemaLocation": "$projectDir/schemas".toString()]
            }
        }
    }

2

image.png

解决:不能在主线程访问数据库,进行线程管理切换

3

image.png

数据插入时已存在,事务中断

@Insert(onConflict = OnConflictStrategy.REPLACE)

@Insert(onConflict = OnConflictStrategy.IGNORE)

4

image.png

修改了数据库或者表结构未升级数据库版本 解决:升级数据库版本号

@Database(entities = {Car.class},version = 2)

5

image.png

升级了数据版本号但是未写迁移方法 Migration

解决:写数据库迁移方法 (详情见后文迁移内容)

6

image.png

数据库创建表语句有问题

database.execSQL("create table if not exists cat ('id' INTEGER NOT NULL, `name` TEXT, `onwerId` TEXT)");

数据库迁移

数据库迁移分为数据库升级、降级、表结构变更上述操作在新旧 apk 覆盖安装的时候极易造成程序崩溃,对于数据库的迁移我们是必须要掌握的

迁移的暴力美学

对于客户端如果你想简单粗暴的解决迁移容易带来的 crash 、又不是特别在意客户端旧的数据库数据那么它来了 fallbackToDestructiveMigration。

Room.databaseBuilder(context.getApplicationContext(),
        AppDatabase.class, "xxx.db")
        .fallbackToDestructiveMigration()
        .build();

发生数据库迁移变更时如果启动了破坏性迁移策略侧会删除旧的数据重新创建。

常规的迁移方式:

修改注解

假设我将某个主键的自增长策略修改为了非自增长,这个时候如果不进行迁移操作会产生 crash,解决:

1 修改 @database 主键 version 版本号 +1

2

Room.databaseBuilder(context.getApplicationContext(),
        AppDatabase.class, "xxx.db")
        .addMigrations(migration_1to_2)
        .build();
               
public static Migration migration_1to_2 = new Migration(1, 2) {
    @Override
    public void migrate(@NonNull @NotNull SupportSQLiteDatabase database) {
        //user 主键自增长修改为不自增长
    }
};

migrate 空实现即可

新增表

先把 @Entity、和 @Dao 定义出来

@Entity
public class Cat {
    @PrimaryKey
    public int id;

    @ColumnInfo
    public String name;

    @ColumnInfo
    public String ownerId;

    public Cat(int id, String name, String ownerId) {
        this.id = id;
        this.name = name;
        this.ownerId = ownerId;
    }
}
@Dao
public interface CatDao {
    @Query("select * from cat")
    List<Cat> getAllCats();

    @Insert(onConflict = OnConflictStrategy.IGNORE)
    void insert(Cat... cats);
}

@Database 的 version 版本号 +1

最重点的来了,写数据库迁移语句

    public static Migration migration_3to_4 = new Migration(3, 4) {
        @Override
        public void migrate(@NonNull @NotNull SupportSQLiteDatabase database) {
            //新增 cat 表
            database.execSQL("CREATE TABLE IF NOT EXISTS `Cat` (`id` INTEGER NOT NULL, `name` TEXT, `ownerId` TEXT, PRIMARY KEY(`id`))");
//            database.execSQL("CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT, `ownerId` TEXT, PRIMARY KEY(`id`))");
        }
    };

这个步骤笔者卡了一会,因为 SQL 语句写得不对导致各种崩溃,这里一个标点符号或者单词字母大小写错误都会崩溃。这个场景下可以先在本地数据库工具或者在线 SQL 跑通了再去尝试,我们来验收下这个表有没有添加成功:

image.png

可以看到上图我们的 cat 表是成功添加了,这里再分享一个小技巧。我们配置的注解json生成的文件里面能看到一些 SQL 语句例如创建:

image.png

新增字段

我们尝试增加一个品类的字段 category

@Entity
public class Cat {
    @PrimaryKey
    public int id;

    @ColumnInfo
    public String name;

    @ColumnInfo
    public String ownerId;

    public String category;
}    

常规的 version + 1 和 加迁移 migration 方法。我们试试空实现会产生问题否

public static Migration migration_4to_5 = new Migration(4, 5) {
    @Override
    public void migrate(@NonNull @NotNull SupportSQLiteDatabase database) {
        //新增 cat 表中的 category 字段
    }
};

image.png

仔细看报错还是能发现预期(Expected) 和找到 (Found)的差异区别:

public static Migration migration_4to_5 = new Migration(4, 5) {
    @Override
    public void migrate(@NonNull @NotNull SupportSQLiteDatabase database) {
        //新增 cat 表中的 category 字段
        database.execSQL("ALTER TABLE `Cat` ADD COLUMN `category` TEXT");
    }
};

image.png 我们成功看到品类字段被成功数据库升级

修改表字段类型

常用的 新增表 和 新增字段 已经能够满足我们数据库升级的绝大多数场景,但是还有一种场景是修改表的字段类型。这种场景一般出现在例如我们的主键 id 初始定义的是 int 类型,但是每次插入的时候还需要做一下转化为了方法想变更成 String 类型,ROOM没有提供直接的方法,比较麻烦的需要创建临时的表做下数据转化:

//  创建新的临时表
    database.execSQL( "CREATE TABLE users_new (userid TEXT, username TEXT, last_update INTEGER, PRIMARY KEY(userid))" );
   // 复制数据
    database.execSQL( "INSERT INTO users_new (userid, username, last_update) SELECT userid, username, last_update FROM users" );
	// 删除表结构
    database.execSQL( "DROP TABLE users" );
	// 临时表名称更改
    database.execSQL( "ALTER TABLE users_new RENAME TO users" );

所以我们在设计表结构的时候一定要提前考虑好。

降级策略

数据库降级的情况比较少,场景如下: A 设备上有个 apk 包里面的数据库 version 是 3。这个时候有个版本号为 2 的 apk 安装到 A 设备上。这个时候如果没有做降级策略则会产生崩溃。

ROOM 有提供一个接口:fallbackToDestructiveMigrationOnDowngrade 来避免这种降级的迁移问题,毕竟客户端的数据库大多数就是一些数据缓存,相比于崩溃缓存的数据丢失也不是那么不可接受

其他

关于 ROOM 和数据库,一个篇章肯定不够。ROOM还有很多需要我们去学习的东西,例如索引;对象关系一对一、一对多、多对多;ROOM的注解处理器与 Generate ; ROOM 的源码分析(三个顶层接口设计与 OpenHelper/SQLStatement/SQLDatabase)我们下回再探!

参考