Android Room 数据库升级

前言

总结 Android 使用 Room 保存数据后,需要更新表结构或者添加新的表时,如何正确的进行数据迁移,避免由于数据更新导致原有数据丢失或损坏。

Room

Room 标准实现

Room 的使用其实还是很符合数据保存的常规思维,从建立数据模型(表结构)、定义数据操作(CURD)到创建数据库,按照流程规范执行即可。

kotlin 复制代码
@Entity
data class Title constructor(val title: String, @PrimaryKey val id: Int)


@Dao
interface TitleDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun insertTitle(title: Title)

    @get:Query("select * from Title where id > 0")
    val titleLiveData: LiveData<Title?>
}

private const val DATA_BASE_NAME = "titles.db"
private const val DATA_BASE_VERSION = 1


@Database(entities = [Title::class], version = DATA_BASE_VERSION, exportSchema = false)
abstract class TitleDatabase : RoomDatabase() {
    abstract val titleDao: TitleDao
}

private lateinit var INSTANCE: TitleDatabase

fun getDatabase(context: Context): TitleDatabase {
    synchronized(TitleDatabase::class) {
        if (!::INSTANCE.isInitialized) {
            INSTANCE = Room.databaseBuilder(
                context.applicationContext, TitleDatabase::class.java, DATA_BASE_NAME
            ).fallbackToDestructiveMigration().build()
        }
    }
    return INSTANCE
}

通过合适的方式创建 TitleDataBase 的实例,然后通过调用 TitleDao.insert 方法就可以创建数据库,添加相应的表,并且插入数据。

通过表格可以看到初始数据库已经创建好了。

修改表结构

软件是需要不断迭代的,而在这个过程中必定会有新的需求诞生,为了满足需求往往需要对已有的内容进行变更。无论是因为事物本身的属性导致变化,还是有新的逻辑分支诞生。通过给对象新增字段往往是我们解决问题最常用的手段。没有人可以在一开始就把整个结构设计的天衣无缝。新增或删除字段,从 Room 持久化数据的角度出发其实就是修改表结构。

新增字段

对于新增字段这类最常见的需求,可以通过创建 Migration 来告知 Room 如何帮助我们进行迁移。 比如对 Title 表现在要新增 create_time 字段用于保存创建的时间。那么可以按如下方式进行修改

  1. 修改数据类
kotlin 复制代码
@Entity
data class Title constructor(val title: String, val createTime: String?, @PrimaryKey val id: Int)

这里需要注意的是,在使用 Kotlin 的时候,新增字段一定要定义为可空,否则会导致迁移失败,毕竟在原有的表中这个新增的列是不存在的,默认为空值。

  1. 添加自定义的 Migration
kotlin 复制代码
val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(database: SupportSQLiteDatabase) {
        database.execSQL("ALTER TABLE Title ADD COLUMN createTime TEXT")
    }
}
  • 这里其实就是通过继承 Migration 类,通过具体的 SQL 语句明确要执行的操作。这些操作往往都是 Room 无法自动执行的,一些简单的操作可以通过其他方式实现。
  • 这里注意的是, 给 Migration 传递的参数就是指从哪个版本迁移到那个版本,一定不要写错了。
  1. 将 Migration 添加到数据库的创建中。
kotlin 复制代码
fun getDatabase(context: Context): TitleDatabase {
    synchronized(TitleDatabase::class) {
        if (!::INSTANCE.isInitialized) {
            INSTANCE = Room.databaseBuilder(
                context.applicationContext, TitleDatabase::class.java, DATA_BASE_NAME
            ).addMigrations(MIGRATION_1_2)
                .fallbackToDestructiveMigration().build()
        }
    }
    return INSTANCE
}
  • 这一步最关键的就是 addMigrations ,顾名思义这个方法就是用来添加各类 Migration 的,参数是可变长的,因此后续的 Migration 直接补充在后面就可以。最后记得修改版本号,按照 Migration 的变更升级到相应的版本 。

做好这些迁移的准备工作之后,后续新插入的数据就可以按照新的结构插入,而原有的数据也不会受到影响。

可以看到,新增字段之后的数据和旧的数据可以共存在一张表中。

exportSchema

在创建 DataBase 时,使用 @Database 这个注解时,有个 exportSchema 的参数,一般会设置为 false。在进行数据迁移的时候,建议将此参数设置为 true 。这样就可以导出每次迁移过程中的变化文件,方便在数据库迁移出现问题时进行回溯和排查。

同时需要配置导出的 json 文件的目录,按如下方式在 app build.gradle 文件中配置即可。

groovy 复制代码
class RoomSchemaArgProvider implements CommandLineArgumentProvider {

    @InputDirectory
    @PathSensitive(PathSensitivity.RELATIVE)
    File schemaDir

    RoomSchemaArgProvider(File schemaDir) {
        this.schemaDir = schemaDir
    }

    @Override
    Iterable<String> asArguments() {
        // Note: If you're using KSP, change the line below to return
        return ["room.schemaLocation=${schemaDir.path}".toString()]
    }
}

    ksp {
        arg(new RoomSchemaArgProvider(new File(projectDir, "schemas")))
    }

这里需要手动创建一下 schemas 这个文件夹。这样每次有不同版本的 migration 之时,就会在这个文件夹下一版本号为名称生成相应的 json 文件。

修改表、列的名称

有时候我们可以会有删除表、修改表名称、删除列和修改列名的诉求。这种一般来说是从代码维护的角度出发,产品和用户不会关心这些内容。对于这类简单的修改,可以借助 AutoMigrationSpec 来实现。比如想将刚才新增的 createTime 字段名修改为 create_time 。

  1. 创建要执行的操作
kotlin 复制代码
@RenameColumn(fromColumnName = "createTime", tableName = "Title", toColumnName = "create_time")
class ReNameColumnMigration : AutoMigrationSpec
  1. 将自定义的 AutoMigrationSpec 添加到 @DataBase
kotlin 复制代码
@Database(
    entities = [Title::class],
    version = DATA_BASE_VERSION,
    autoMigrations = [AutoMigration(2, 3, ReNameColumnMigration::class)],
    exportSchema = true
)
abstract class TitleDatabase : RoomDatabase() {
    abstract val titleDao: TitleDao
}

最后记得升级版本,重新运行之后,可以看到列名的修改已经成功了。

添加一张新的表

创建 DataBase 是非常耗费资源的,因此当有新的数据类型需要存储的时候,最好的选择就是用新的数据建一张表直接存储在已有的数据库中。这样全局只有一个 DataBase ,通过定义不同的接口访问不同的数据类型即可,而不是对于每一种数据类型创建一个数据库。

比如现在有 Image 对象需要存储在数据库中,可以按如下步骤进行。

  1. 创建对应的表
kotlin 复制代码
@Entity
data class Image constructor(
    val path: String, val format: String, val size: Int, @PrimaryKey val id: Int
)

这种情况一般是在现有类型的基础之上添加@Entity 即可。也可以根据实际需要忽略无需存储的字段,但是需要定义 PrimaryKey.

  1. 定义数据的操作
kotlin 复制代码
@Dao
interface ImageDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun saveImage(image: Image)
}

这里简单起见只定义一个插入操作。

  1. 定义 Migration
kotlin 复制代码
val MIGRATION_3_4 = object : Migration(3, 4) {
    override fun migrate(database: SupportSQLiteDatabase) {
        database.execSQL(
            "CREATE TABLE IF NOT EXISTS `Image` (`id` INTEGER, `path` TEXT,`format` TEXT, `size` INTEGER,PRIMARY KEY(`id`))"
        )
    }
}

由于之前的两次 Migration 版本号已经升级到了 3,这个 Migration 就是从 3 升级到 4 的操作。建议对于变化较小的升级,可以合并放在一个 Migration 中,而对于改动较大的 MIgration 还是每次只做一个改动。

这里通过继承 Migration 就是告知 Room 这次升级要做什么事情。这里一定要确保 SQL 书写的正确。一旦发生错误就有可能导致数据丢失或损坏。甚至在调试阶段,也只能通过清除数据的方式,从上一个版本重新做升级测试。

  1. Migration 添加到 DataBase 中
kotlin 复制代码
    synchronized(TitleDatabase::class) {
        if (!::INSTANCE.isInitialized) {
            INSTANCE = Room.databaseBuilder(
                context.applicationContext, TitleDatabase::class.java, DATA_BASE_NAME
            ).addMigrations(MIGRATION_1_2, MIGRATION_3_4).fallbackToDestructiveMigration().build()
        }
    }

最后将当前这次 Migration 添加到 Migrations 的列表中即可。重新运行就可以看到 Image 对应的新表已创建成功。

直接存储对象

可以看到对数据库已有类型的操作无论是增加字段还是新增表,整体还是比较繁琐的。同时这样的操作也是很危险,稍有不慎就会导致本地原有数据丢失或损坏,这样的体验对于用户来说是非常糟糕的。因此,我们可以考虑换一种思路,将对象整体存在数据库中,对象内部字段的变化对于数据来说是黑箱操作,这样数据类型本身的变化就和数据的迁移解耦了,甚至数据库都不用做升级了。

但是 Room 本身有不支持直接存储对象(实际上很多数据库都不支持),这里其实可以换个思路。数据库是支持 TEXT ,也就是 String 类型的。因此,只需要告知 Room 如何进行对象和 String 之间的转换即可。

kotlin 复制代码
data class Address(val code: String, val city: String)

@Entity
data class Image constructor(
    val path: String,
    val format: String,
    val size: Int,
    val address: Address?,
    @PrimaryKey val id: Int
)

假设现在 Image 需要存储 Address 字段。可以通过创建转换器,自定义从对象到 String 之间的转换规则

kotlin 复制代码
object AddressTypeConvert {

    @TypeConverter
    fun addressToString(address: Address): String {
        return JSONObject.toJSONString(address)
    }

    @TypeConverter
    fun stringToAddress(json: String): Address {
        return JSONObject.parseObject(json, Address::class.java)
    }
}

最后把这个转换器添加到 DataBase 上即可。

kotlin 复制代码
@TypeConverters(AddressTypeConvert::class)
abstract class TitleDatabase : RoomDatabase() {...}

这样类似于前面给 Title 新增字段的方式,添加一个新的 Migration 将 Address 当做 TEXT 类型添加到 Image 表中即可。

可以看到通过这种方式,Address 类后续的更新,就不需要再做数据库的升级了。将数据变化的实现又返回了传统的面向对象这一层,数据库纯粹做存储就好了,不再关心存储内容变化之后的数据迁移了。

参考文档

相关推荐
Hacker_LaoYi29 分钟前
【渗透技术总结】SQL手工注入总结
数据库·sql
岁月变迁呀31 分钟前
Redis梳理
数据库·redis·缓存
独行soc32 分钟前
#渗透测试#漏洞挖掘#红蓝攻防#护网#sql注入介绍06-基于子查询的SQL注入(Subquery-Based SQL Injection)
数据库·sql·安全·web安全·漏洞挖掘·hw
你的微笑,乱了夏天1 小时前
linux centos 7 安装 mongodb7
数据库·mongodb
工业甲酰苯胺1 小时前
分布式系统架构:服务容错
数据库·架构
拭心2 小时前
Google 提供的 Android 端上大模型组件:MediaPipe LLM 介绍
android
独行soc2 小时前
#渗透测试#漏洞挖掘#红蓝攻防#护网#sql注入介绍08-基于时间延迟的SQL注入(Time-Based SQL Injection)
数据库·sql·安全·渗透测试·漏洞挖掘
White_Mountain3 小时前
在Ubuntu中配置mysql,并允许外部访问数据库
数据库·mysql·ubuntu
Code apprenticeship3 小时前
怎么利用Redis实现延时队列?
数据库·redis·缓存
百度智能云技术站3 小时前
广告投放系统成本降低 70%+,基于 Redis 容量型数据库 PegaDB 的方案设计和业务实践
数据库·redis·oracle