Room 的预填充数据库(pre-populated database)如何实现?

解读

国内面试中,这道题常被用来区分“只会写增删改查”与“真正做过存量数据迁移/离线包”的候选人。
面试官想确认三点:

  1. 是否知道 Room 2.2.0+ 官方提供的 createFromAsset()/createFromFile() API,而不是自己手动 copy;
  2. 是否理解预填充的时机(数据库首次创建时,且与当前版本号绑定),以及升级场景下如何“再填充”或“跳过”;
  3. 是否踩过国产 ROM 的坑:assets 大于 1 GB 无法压缩、微信/QQ 清理缓存导致文件丢失、多进程同时触发预填充造成 SQLiteBusyException 等。
    回答时先给“官方正统方案”,再补“国内实战踩坑”,容易拿到高分。

知识点

  1. RoomDatabase.Builder 的 createFromAsset(String assetPath)、createFromFile(File)、createFromInputStream(Supplier<InputStream>) 三个重载。
  2. 预填充只发生在“数据库文件不存在”且“最终版本号一致”时;若数据库已存在,Room 直接打开,不会再次覆盖。
  3. 若后续升级需要“再灌入”新数据,应结合 destructiveMigration 或自定义 Migration,在升级脚本里用 INSERT OR REPLACE INTO … SELECT … FROM new_table 方式合并,而不是重新 createFromAsset。
  4. 国内渠道包常把城市字典、离线地图等 100 MB+ 的 db 放在 assets,此时需在 build.gradle 中关闭压缩:aaptOptions { noCompress 'db' },否则系统会二次解压导致首次启动耗时 3-5 s。
  5. 多进程场景(推送、下载、主进程同时启动 Room)要在 RoomDatabase.Builder 上加 .enableMultiInstanceInvalidation() 并保证预填充文件只 copy 一次,否则容易报 SQLiteDatabaseLockedException。
  6. 合规角度,预填充数据若含用户隐私(如手机号段归属),需随隐私政策声明来源与用途,否则国内应用市场审核会被驳回。

答案

官方方案(Kotlin 示例):

  1. 将预置数据库 city.db 放到 src/main/assets/databases/city.db
  2. 在 Room 构建时调用:
Room.databaseBuilder(
        context.applicationContext,
        AppDatabase::class.java, 
        "app_city.db"
    )
    .createFromAsset("databases/city.db")   // 关键一行
    .fallbackToDestructiveMigration()       // 开发阶段方便,上线后改用 Migration
    .build()
  1. 首次访问数据库时,Room 会在 IO 线程把 assets 里文件拷贝到 /databases/ 目录,再打开;拷贝完成前,RoomDatabase.getOpenHelper().readableDatabase 会阻塞,因此建议结合 RoomDatabase.CallbackonCreate 回调里再插入业务默认值,保证事务一致性。

升级场景:
若 city.db 后续新增字段,需把新 db 命名为 city_v2.db,放在 assets,同时在 Migration 中把旧表数据迁移到新结构,而不是再次 createFromAsset,否则用户本地数据会被覆盖。

国内踩坑补充:

  • assets 文件大于 1 GB 时,部分国产 ROM 解压失败,改用 createFromFile(),把 db 放在 /sdcard/Android/data/<pkg>/files/ 目录,随 APK 下发,首次启动校验 MD5 后再拷贝。
  • 微信/QQ 清理缓存会删掉 /databases/ 外部文件,导致下次启动再次预填充;可在 Application#onCreate 中检测 databaseExists() 与文件 MD5,若被误删则走降级策略,提示用户“正在恢复数据”。
  • 多进程同时触发预填充时,用 FileLock 保证只有一个进程拷贝,其余进程阻塞等待,防止 SQLiteBusyException。

拓展思考

  1. 如何做到“增量预填充”?
    把字典表拆成“只读基础包 + 可更新增量包”,基础包用 createFromAsset,增量包用 WorkManager 后台下载,下载完成后在 RoomDatabase.Callback#onOpen 里执行 ATTACH DATABASE 'incremental.db' AS inc; INSERT OR REPLACE INTO main.city SELECT * FROM inc.city; DETACH inc;,实现热更新。

  2. 预填充数据库的加密场景:
    若整个 db 需要 SQLCipher 加密,官方 createFromAsset 不支持加密文件。解决思路:

  • 预置明文 db → 首次启动拷贝 → 用 SQLCipher 重新加密导出 → 删除明文 → 后续 Room 使用 SupportFactory 打开加密文件。
  • 或者预置已加密的 db,但把密码拆成服务端动态下发,防止硬编码被逆向。
  1. Jetpack Compose + Room 的离线优先架构:
    预填充数据作为“唯一可信源”,配合 Paging 3 RemoteMediator 实现“网络→DB→UI”单向数据流;当用户无网时直接读预置数据,有网时后台刷新,UI 层无感知,符合国内 5G 弱网场景体验要求。