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 类后续的更新,就不需要再做数据库的升级了。将数据变化的实现又返回了传统的面向对象这一层,数据库纯粹做存储就好了,不再关心存储内容变化之后的数据迁移了。

参考文档

相关推荐
这个DBA有点耶1 小时前
NULL不是空——数据库里最反直觉的设计,90%新人踩过的坑
数据库·mysql·代码规范
杉氧3 小时前
Navigation Compose 深度实践:如何优雅地串联起你的全栈 App?
android·架构·android jetpack
这个DBA有点耶3 小时前
AI写的SQL跑崩了生产库,这锅谁背?
数据库·人工智能·程序员
镜舟科技4 小时前
Databricks 再提 LTAP,AI 时代的数据底座为何重回大一统叙事?
数据库·架构·agent
Databend5 小时前
从湖仓升级为 Agent 时代的数据控制面,Snowflake 和 Databricks 有哪些布局
大数据·数据库·agent
雨白6 小时前
指针与数组的核心机制
android
ClouGence8 小时前
SQL Server CDC 能放到 Always On 备库读吗?一文讲透原理与实践
数据库·sql server
黄林晴11 小时前
Room 3.0 正式发布!包名彻底重构,KMP 成为核心主线
android·android jetpack
三少爷的鞋11 小时前
Kotlin 协程环境下的 DCL 懒加载:别把线程时代的经验直接搬过来
android
plainGeekDev12 小时前
Gson → kotlinx.serialization
android·java·kotlin