先看结论与判断条件

  • 新装只验证最新 schema 创建路径,无法覆盖旧用户数据库、逐级 Migration、自动迁移输入和升级失败后的恢复。
  • 导出的 Room schema 历史要与源码、数据库名、version、实体和最终生成实现绑定,缺失旧 schema 时不能猜测迁移等价。
  • 迁移测试既要验证表、列、索引、外键和 identity hash,也要检查行数、主键、枚举、金额、时间和关联等业务不变量。
  • 真实升级链包括跨多个版本直升、逐级执行、安装更新身份、进程异常退出和再次打开,不能只运行单个 Migration 单元。
  • 原始包与加固包要使用同一旧库夹具、升级路径、设备和断言;新增失败才进入加固相关调查,共同失败属于基线问题。
  • 通过结论只覆盖列出的候选摘要、Room 版本、历史 schema、设备和路径,不能从单一数据库或一台设备外推全部用户。

新装成功不代表旧用户数据库能够升级

Room 在首次安装时直接按照当前实体与生成实现创建最新数据库,旧用户却携带早期 schema、数据形态和 SQLite 状态。只清数据安装加固包,最多证明 create path 可用,完全绕过 Migration、identity hash 校验、旧索引修订和跨版本数据转换。很多升级问题因此在测试环境消失,却在真实覆盖安装时首次出现。回归入口必须是历史版本创建出的数据库文件,而不是测试代码手写一个近似表结构。

升级链也不只是一条相邻版本路径。用户可能从较早版本直接更新到当前版本,Room 会按注册的迁移边组合路径;某些用户经历过中间版本并已获得数据修正,数据库内容与直接跨版本不同。测试计划应根据仍在支持范围内的历史版本,列出 direct upgrade、stepwise upgrade 和已知热修复分支。每条链都绑定相同最终版本,但旧库夹具和预期不变量不同。

加固后的风险点可能表现为生成实现不可达、反射或类名变化、Migration 注册缺失、JNI 依赖初始化异常,也可能只是原版本的迁移脚本本来就有问题。测试必须先在原始候选上跑同一夹具,记录共同失败,再对加固候选配对执行。加固包失败而原包通过只能说明新增回归,需要继续核对候选身份和稳定复现,不能直接宣称保护变换必然是根因。

Room 数据库创建与升级路径的差异
路径起点实际覆盖常见遗漏
新装无数据库文件最新 schema 创建所有历史 Migration
相邻升级上一版本旧库单条迁移边更早版本直升
跨版本直升历史旧库多条迁移组合中间版本数据修正
逐级升级历史旧库每版安装与打开直升路径差异
失败后重开中断或回滚状态恢复与幂等边界只看首次异常
加固候选升级同一历史夹具保护后最终实现与原包配对基线

导出 schema 历史并锁定数据库身份

Room 迁移依赖旧新 schema 的准确描述,自动迁移尤其需要导出的历史。`@Database` 声明实体、版本、自动迁移和 schema 导出行为,但注解本身不是最终运行证据。项目要把 schema JSON 作为受控构建产物提交,校验每个版本都存在且来自对应源码,并记录 Room 编译器与运行库版本。若历史文件丢失,不应从当前实体反推旧结构,因为默认值、索引名称和外键动作可能已经变化。

数据库身份不仅是 version。数据库文件名、applicationId、用户或账号分区、加密层、Room identity hash 和生成实现都参与实际打开流程。若测试用不同文件名或空白账号路径,可能没有打开真实用户库。夹具清单要写明数据库逻辑名、来源 App 版本、schema 版本、创建候选摘要、是否执行过中间热修复,以及脱敏数据集摘要。数据库内容不得包含真实账号、令牌或用户隐私。

加固策略范围与迁移正确性要分开。是否把 Room 生成代码纳入某种保护属于范围决策,本文只验证最终加固候选能否按既定升级契约工作。测试报告应引用最终 APK、相关 DEX 与 SO 摘要、签名证书摘要和 schema 历史摘要,避免把原始包生成实现或另一策略候选的通过结果贴给当前包。身份不完整时,应标记候选不可验证,而不是补写一个兼容结论。

Room 迁移所需的身份材料
材料绑定对象用途缺失风险
schema JSON数据库版本核对旧新结构自动迁移无法可靠生成
数据库夹具摘要历史 App 候选证明旧库来源手写结构与真实库不符
Room 依赖版本构建候选解释生成与校验行为跨版本行为混用
APK 与签名摘要安装更新链绑定最终候选报告与包错配
DEX 与 SO 摘要保护策略候选绑定代码与 Native 依赖借用其他候选回执
数据集摘要测试夹具固定业务输入断言无法复现

用真实旧库夹具覆盖直升和逐级迁移

最可靠的旧库夹具由历史发布候选在受控设备上创建:安装旧版本,执行确定的脱敏业务步骤,正常关闭数据库,再复制允许导出的测试数据库和旁路文件。若项目使用 WAL,需要在归档前执行受控 checkpoint 或完整保存相关文件,不能只复制主库得到不一致快照。夹具随后计算摘要、只读存储,并附创建步骤与预期数据,不允许测试执行过程中修改原件。

每个起始版本至少跑两种链。直升链把旧库放入应用数据目录后直接安装当前加固候选并首次打开;逐级链按真实中间版本依次安装和打开,执行必要的数据写入,再到当前候选。两条链最终 schema 可能相同,但数据修正规则、默认值和触发时机可能不同。若支持降级或回滚,也要明确是平台允许的产品契约还是禁止路径,不能私自使用 destructive migration 掩盖不支持的版本关系。

MigrationTestHelper 适合在 instrumented test 中创建指定版本数据库并运行迁移校验,但手工 fixture 仍有价值,因为它包含历史 SQLite 行为、数据与可能的边界状态。二者分工是:schema 级测试快速覆盖每条迁移边,真实夹具验证用户升级链和业务不变量。只有 helper 通过不能证明 DAO 查询、加密封装、文件权限或进程恢复正确;只有手工设备冒烟也难以定位哪条 schema 边失效。

旧库夹具与升级链设计
测试资产生成方式适合验证必要回执
版本 schemaRoom 导出表列索引与迁移边源码和依赖摘要
最小旧库MigrationTestHelper单条迁移结构起止版本与断言
真实旧库夹具历史候选创建数据与文件状态创建候选和夹具摘要
直升链旧版到当前版多边组合安装与首次打开时间线
逐级链逐个版本安装中间数据修正每阶段 schema 与数据摘要
失败恢复夹具受控中断后保留事务回滚与重开中断点和退出原因

Schema 校验之外还要定义业务数据不变量

Room 的结构校验可以发现表、列、类型、默认值、外键和索引不匹配,但结构正确不代表业务数据正确。迁移脚本可能把金额单位换错、丢失枚举、重复生成主键、把 UTC 时间当成本地时间,或者在重建表时漏掉某类行。每个夹具应有一份独立不变量清单,描述迁移前后必须保持的计数、唯一性、关联、值域和转换公式,并由 SQL 与 DAO 两个视角核对。

不变量应选择脱敏且能发现实际错误的数据,而不是只插入一行普通记录。可以覆盖空值与默认值、边界长度、多个外键关系、已删除标记、排序稳定性、旧枚举映射和历史格式。测试数据不应包含真实用户内容,也不要依赖当前时间或随机数造成不稳定。若某字段允许语义变化,应把旧值到新值的明确规则写进断言,而不是看到结果不同就人工判定“看起来合理”。

DAO 回归在数据库成功打开后执行。它验证生成查询、事务边界、类型转换器和实体映射是否仍符合业务契约。迁移测试与 DAO 测试失败要分别记录:前者说明数据库无法达到目标结构,后者可能是查询或映射问题。加固后若只在 DAO 层失败,应核对最终生成实现与保护配置,但仍不能从失败位置直接推导根因。修复后两层都要重跑,防止只绕过结构校验。

Room 升级后的断言层级
层级检查对象示例不变量失败含义
Schema表、列、类型目标定义完全匹配迁移结构不一致
索引与外键唯一性和关联约束索引存在且外键有效查询或完整性风险
行级数据数量与主键记录不丢失不重复复制或过滤错误
字段语义枚举、金额、时间按版本规则转换业务含义被改变
DAO 映射查询与类型转换返回对象符合契约生成或映射异常
事务行为批量写入与回滚失败不留部分状态原子性被破坏

迁移失败、进程退出与再次打开必须单独回归

迁移通常在数据库首次打开时执行,失败可能发生在建表、复制数据、创建索引或 identity 校验阶段。测试不能只捕获异常后删除数据库重试,因为真实用户数据不会自动重生。应保留失败后的文件副本,检查 SQLite 事务是否回滚、版本号是否保持可解释状态、下次打开是否重复执行安全,以及应用是否向用户返回明确失败而非进入循环崩溃。任何 fallbackToDestructiveMigration 都要作为明确产品决策审计,不能作为测试便捷开关。

受控异常退出可以验证中断边界,但不得通过破坏真实用户数据库完成。测试夹具中可在指定迁移阶段触发进程终止或抛出受控异常,再由下一次测试进程打开同一副本。ApplicationExitInfo 能补充进程退出原因和相关 trace,但必须绑定候选、时间窗、进程名与测试步骤。平台没有返回所需信息时,记录未取得并依赖本地事务与文件回执,不要虚构 Native tombstone 或 ANR 结论。

重开测试要区分失败可重试和数据已损坏。若事务完整回滚,修复候选可能重新执行迁移;若旧脚本在事务外修改了旁路文件或远端状态,仅重跑 SQL 无法恢复一致性。项目应把数据库文件、偏好配置、缓存索引和必要的业务状态纳入升级契约。本文不提供业务级回滚承诺,只有目标 App 的实现和项目证据才能决定是否允许重试、恢复备份或阻断启动。

迁移失败后的恢复检查
失败点应检查下次打开禁止捷径
建表前version 与旧表未变可重新开始直接删库
数据复制中事务完整回滚不出现部分新表只看异常类型
索引创建时表数据与 schema 状态按同一路径重试跳过索引断言
identity 校验导出 schema 与实现修正候选后再开关闭校验
进程异常退出退出原因和文件摘要新进程复核状态跨候选借 trace
旁路状态已改文件与业务状态一致性执行专用补偿假设 SQL 回滚覆盖全部

覆盖安装还要满足 applicationId、签名与版本身份

Room 迁移只能在系统把新包识别为原应用更新后触发。applicationId 变化会成为另一应用,签名不兼容会阻止覆盖安装,versionCode 不符合更新条件也无法模拟真实升级。测试计划因此要先验证安装身份,再讨论数据库。原始历史包与当前加固候选的包名、签名证书或受支持轮换证明、版本关系都要记录;通过 adb 强制替换或手工搬数据库可能绕过平台条件,只能作为局部诊断。

签名变化还会影响依赖签名权限、KeyStore、ContentProvider 访问和服务端校验,这些问题可能在数据库打开前就使测试失败。差分报告应把 install_failure、startup_failure、migration_failure 与 dao_failure 分开。若加固候选无法覆盖安装,就没有发生 Room 迁移,不能把安装错误归入 schema 回归。修复签名或版本身份后,应从干净的历史安装状态重新执行完整升级,而不是沿用被人工修改的数据目录。

真实升级测试应尽量通过用户会经历的安装通道和包格式执行,同时保留可控实验路径用于定位。每条结果写明 APK 或从 AAB 生成的 split 集合、安装器、签名摘要、versionCode 和数据保留状态。平台允许更新只证明安装身份满足条件,不证明迁移业务规则、DAO 或加固兼容。两种证据必须在报告中保持独立,避免“能安装”被误读成“升级成功”。

Android 覆盖安装与 Room 迁移的前置门禁
前置条件验证材料失败表现结论边界
applicationId 一致manifest 与已安装包安装成另一应用不会触发原库升级
签名兼容证书摘要或轮换证明系统拒绝更新不是 Room 失败
versionCode 可更新两版包元数据降级或覆盖被拒需重建正确候选
用户数据保留安装器与数据目录回执旧库消失新装结果不可替代
split 集合完整安装包清单类或资源缺失先修安装候选
首次打开路径一致启动步骤与账号状态打开错误数据库迁移结果不可比

原始包与加固包必须共享夹具、矩阵和断言

为了判断加固是否引入 Room 升级回归,应把同一只读旧库夹具复制成两个工作副本,分别交给原始候选和加固候选。两侧使用相同设备型号、系统、ABI、locale、安装身份、测试步骤和数据不变量。每次执行前校验夹具摘要,执行后保存目标数据库摘要、schema 导出、断言结果与退出记录。直接让两份包依次修改同一数据库会使第二次测试继承第一次结果,失去独立性。

结果分类沿用差分原则:原始通过、加固失败是新增失败;两侧失败是共同失败;原始失败、加固通过只记录原包独有失败;缺少一侧或夹具摘要不同则不可比。新增失败再进入稳定复现、最小保护变量和反向验证。共同失败可能是历史 migration 本身错误,应该修复业务基线后两侧重跑,不能为了让加固报告变绿而删除用例。

设备矩阵至少覆盖仍有用户的起始 schema、目标 API 与 ABI,以及数据库层使用的关键平台能力。单一设备通过不代表全部厂商文件系统、SQLite 版本和进程行为。安全发布还要保留来源、构建、验证与变更证据,这与 NIST SSDF 的组织级要求方向一致,但框架本身不会替项目选择迁移用例,也不证明任何加固产品具备特定能力。

  • 旧库夹具为只读母本,两侧测试分别复制独立工作副本
  • 原始包和加固包绑定同一源码、Room 版本与业务断言
  • 每次执行前后记录夹具、APK、DEX、SO 与数据库摘要
  • 新增失败、共同失败、原包独有失败和不可比项分别输出
  • 起始 schema、API、ABI 与真实用户升级路径进入矩阵
  • 单一设备回执不外推为全部版本和厂商兼容

用 MigrationTestHelper 验证逐级 schema 与数据不变量

下面的 Kotlin 示例展示一条脱敏迁移测试。`runFixture` 接收外部 fixtureName,先校验名称,再用 MigrationTestHelper 创建版本一数据库,写入不含用户信息的两条记录,依次执行 MIGRATION_1_2 与 MIGRATION_2_3,并要求目标 schema 校验通过。随后查询表、唯一索引和业务字段,确认行数、主键、状态映射与金额单位不变量。任一输入或断言失败都会使测试失败。

代码是最小公开示例,不包含真实实体、密钥、加密数据库、账号或保护配置。生产项目应为每个支持起始版本保留 schema 和夹具,并把自动迁移、手工迁移、失败回滚、异常退出和 DAO 回归拆成明确用例。`runMigrationsAndValidate` 验证声明的 schema 路径,但不能替代覆盖安装、签名身份、真实旧库和业务层查询。代码中的表名与数据仅用于说明断言结构。

准备 Room 升级评估时,可整理实际历史包与当前加固包、签名与 versionCode、Room 版本、schema 历史、migration 列表、脱敏旧库夹具、数据不变量、失败回执和设备矩阵,再从御盾中央平台提交申请。可同时参阅本站的加固前后差分回归方法,把共同失败与新增失败分开。没有当前候选和真实设备回执时,不宣称数据无损、升级兼容、性能改善或问题已修复。

  • fixtureName 等外部夹具标识先经过严格格式校验
  • MigrationTestHelper 按声明的每条迁移边到达目标版本
  • 目标表、列、索引与 identity schema 通过严格校验
  • 行数、主键、状态映射与金额等业务不变量单独断言
  • 测试数据库不含真实账号、令牌、密钥或用户内容
  • 设备覆盖安装与失败恢复继续使用同一不变量清单
Room 逐版本迁移与业务不变量测试
@RunWith(AndroidJUnit4::class)
+class RoomMigrationRegressionTest {
+    private val helper = MigrationTestHelper(
+        InstrumentationRegistry.getInstrumentation(),
+        AppDatabase::class.java,
+        emptyList(),
+        FrameworkSQLiteOpenHelperFactory()
+    )
+
+    @Test
+    fun migrationPreservesSchemaAndData() {
+        runFixture("public_fixture_v1")
+    }
+
+    private fun runFixture(fixtureName: String) {
+        require(fixtureName.matches(Regex("^[a-z0-9_]{1,40}$"))) {
+            "fixture name is invalid"
+        }
+        val databaseName = "migration_${fixtureName}.db"
+        helper.createDatabase(databaseName, 1).apply {
+            execSQL("INSERT INTO account(id, state, cents) VALUES(1, 'open', 1200)")
+            execSQL("INSERT INTO account(id, state, cents) VALUES(2, 'closed', 300)")
+            close()
+        }
+
+        helper.runMigrationsAndValidate(
+            databaseName,
+            3,
+            true,
+            MIGRATION_1_2,
+            MIGRATION_2_3
+        ).use { database ->
+            database.query("SELECT COUNT(*), SUM(cents) FROM account").use { cursor ->
+                check(cursor.moveToFirst()) { "aggregate row is missing" }
+                check(cursor.getInt(0) == 2) { "row count changed" }
+                check(cursor.getLong(1) == 1500L) { "amount invariant changed" }
+            }
+            database.query("SELECT state FROM account ORDER BY id").use { cursor ->
+                check(cursor.moveToFirst() && cursor.getString(0) == "active")
+                check(cursor.moveToNext() && cursor.getString(0) == "archived")
+            }
+            database.query(
+                "SELECT COUNT(*) FROM sqlite_master " +
+                    "WHERE type='index' AND name='index_account_state'"
+            ).use { cursor ->
+                check(cursor.moveToFirst() && cursor.getInt(0) == 1) {
+                    "required index is missing"
+                }
+            }
+        }
+    }
+}

事实依据与适用边界

以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。

本文判断事实或工程依据适用限制
Room 迁移应保留 schema 历史、注册完整版本路径并执行迁移测试。Room database migrations 说明自动与手工迁移、schema 导出和 MigrationTestHelper 的使用。schema 路径通过不证明 DAO 业务规则、真实旧库数据或加固候选运行等价。
Room 数据库版本、实体、自动迁移和 schema 导出由 Database 注解元数据声明。Room Database API 描述 `@Database` 的 entities、version、autoMigrations 与 exportSchema。注解声明不能替代最终生成实现、安装身份和设备数据库的回归。
真实覆盖安装需要保持 applicationId、兼容签名或轮换证明,并满足 versionCode 条件。How Android app updates work 说明 Android 与 Google Play 更新识别和签名版本要求。平台接受更新只证明安装身份条件,不证明 Room migration、DAO 或数据语义正确。
依赖 Android 文件系统、SQLite、组件和进程行为的迁移路径应在设备端运行。Android instrumented tests 说明在 Android 设备或模拟设备上执行依赖平台能力的测试。单一设备和单次通过不能覆盖全部 API、ABI、厂商 SQLite 与用户升级链。
异常退出回归可以用系统退出记录补充退出原因和相关诊断材料。Android ApplicationExitInfo 描述历史退出原因、ANR trace 和部分平台版本的 Native 诊断内容。退出记录必须与候选、进程、时间窗和测试步骤匹配,不能跨构建借用。
安全发布过程应保留来源、构建、验证与变更证据,并管理供应链风险。NIST SP 800-218 SSDF 给出组织级安全软件开发实践和证据管理方向。SSDF 不定义 Room 测试用例,也不证明任何具体 App 加固产品的功能或兼容性。
旧库夹具应由历史候选生成并以只读母本为两份候选分别复制。工程判断:独立副本和夹具摘要可避免手写 schema 偏差及前一次执行污染后一次结果。夹具仍只覆盖其创建步骤与数据形态,不能代表所有真实用户数据库。
迁移完成后必须同时检查 schema 与业务数据不变量。工程判断:结构匹配无法发现金额、枚举、时间、主键与关联语义在数据转换中的错误。不变量需要由目标 App 业务定义,公开示例不能替代项目规则和真实回执。

工程常见问题

加固包清数据后能够启动,是否说明 Room 迁移兼容?

不能。清数据走的是最新 schema 创建路径,没有执行旧版本 Migration、identity 校验和历史数据转换。必须从真实旧库夹具覆盖安装并打开。

只有最近一个版本的旧库需要测试吗?

不够。用户可能从仍受支持的更早版本直升,也可能逐级更新。应按真实版本分布覆盖起始 schema、直升链和中间修正路径。

MigrationTestHelper 通过是否代表业务数据不会丢失?

不代表。它能严格验证目标 schema,还需单独断言行数、主键、枚举、金额、时间、外键和 DAO 结果,并用真实历史夹具补充。

迁移失败后可以直接使用 destructive migration 吗?

不能把它当测试捷径。删除重建会丢失旧数据,只有产品明确允许且完成风险评审时才能采用;默认应验证事务回滚、重开与受控修复。

加固包迁移失败能否直接归因到 VMP 或代码保护?

不能。先确认原始包在同夹具、设备和断言下通过,再稳定复现新增失败,核对候选身份并缩小唯一保护变量后才能形成受限归因。

Room 迁移通过一台设备后是否可以全量发布?

不能仅凭这一项决定。结论只覆盖该 API、ABI、SQLite、起始 schema 和候选,还需按真实用户范围完成设备矩阵、异常恢复和业务路径验证。

想用自己的 App 验证?

提交候选包、目标系统和关键业务路径,申请御盾 PoC 与兼容性评估。

继续阅读: App 加固 PoC 如何形成发布结论