看版本页的时候,Room 3.0.0-alpha 的 changelog 已经排了一长串。我第一反应是:2.x 不是还在稳定迭代吗,怎么突然跳版本号?后来把架构文档和示例工程翻了一遍才明白,这一跳不是拍脑袋——它把 Room 从“Android 专属的数据库封装库”直接改造成了“以 SQLite Driver 为边界、可以跑在 commonMain 里的跨平台数据库组件”。这波改动,确实狠。
先给不熟悉的朋友补个背景。Room 是 Android Jetpack 里的数据库组件,核心价值是把 SQLite 的模板代码收起来:你只需要定义实体类、DAO 接口和 Database 抽象类,它在编译期帮你生成实现,还会在编译时校验 SQL 语句里的表名、字段名有没有写错。很多团队从 GreenDAO、原生 SQLiteOpenHelper 迁到 Room,就是为了少写样板代码、多一层编译期保护。可 Room 2.x 一直有个结构性限制——它绑定在 Android Framework 上,数据库访问代码没法放进纯 Kotlin 跨平台模块里复用。Room 3.0 解决的就是这件事。
这篇不是官方 Release Notes 的翻译,是我拿真实项目做了升级踩坑之后的记录,适合已经在用 Room 2.x、正在纠结要不要升 3.0 的人,也适合准备开新项目、在犹豫要不要选 Room 的人。
1. Room 3.0 到底在“狠”什么:一次迟到的跨平台重构
1.1 为什么版本号非跳一个大的不可
过去 Room 2.x 的架构,底层站的是 Android 平台自己的 SQLite 封装。你用 Room.databaseBuilder(context, AppDatabase.class, "app.db") 拿到数据库后,真正干活的是 SupportSQLiteDatabase、SQLiteOpenHelper 这一套东西。它们很成熟,但问题很直接:这套 API 从 java 到 import 都带着 android.* 的烙印,数据访问层只要写成这种风格,就永远没法脱离 Android 工程单独编译。
前几年 KMP 开始普及的时候,大家很快就发现一个尴尬局面:业务逻辑、网络层、数据模型层都能共享到 iOS、桌面端,唯独数据库层被卡住。你说用 SQLDelight 或其他方案也行,但项目里大量现成的 DAO、TypeConverter、Migration 都是按 Room 的语义写的,迁移成本不是小数目。Room 3.0 就是冲着这个痛点来的。它把底层的 Android SQLite 封装替换成一套统一的 SqliteDriver 接口,Runtime 和 DAO 代码不再依赖 Android Framework,于是 @Database、@Dao 这些核心抽象可以放进 commonMain,Android 和 iOS 共用同一套数据访问层。
从产品视角看,这等于把 Jetpack 组件里最重的一颗钉子拔掉了,业务代码真正具备了一次编写、多端运行的可行性。
1.2 拆掉的底层:SupportSQLite 时代的终结
Room 2.x 时代,数据库的创建本质上是走 SupportSQLiteOpenHelper。如果没接触过这一层,可以把 Room 想象成一个装修公司:它帮你设计好房间布局,但你最后住进去的房子,用的是开发商统一提供的毛坯房。Room 3.0 不再依赖开发商给的毛坯,而是自己抽象出一套建筑标准接口,再让不同的施工单位各自实现。这套接口就是 SQLite Driver。
这个拆分的意义不只是“能跑在 iOS 上”。从工程角度讲,Driver 层把数据库实现完全隔离之后,你可以在 JVM 环境跑单元测试,不依赖 Android 设备或模拟器;可以换一个自带特定版本 SQLite 的驱动,解决系统 SQLite 版本碎片化问题;甚至可以在不修改 DAO 代码的前提下,把底层驱动换成更合适的实现。
很多人在 2.x 时代养成的习惯是“无脑信任系统自带 SQLite”,但到了 Room 3.0,这个问题必须想清楚:你的业务对 SQLite 版本、扩展函数、并发模型有依赖吗?驱动的选择直接决定了上层行为。这恐怕才是升级时最需要花心思的地方。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. SQLite Driver 抽象:驱动选择是升级时最容易出错的环节
2.1 Driver 到底长什么样,它解决了什么
Driver 层诞生之前,Room 和 SQLite 之间是“硬编码”的关系。Android 上就用 SQLiteOpenHelper,别的平台想跑同一套 Room 代码,没有任何入口。3.0 的做法是先定义一套纯接口来描述数据库连接生命周期和读写能力,然后由不同平台提供自己的驱动实现。
这个设计和 JDBC 的思路很像:JDBC 定义了一套接口,MySQL 驱动、PostgreSQL 驱动各实现各的,业务代码只需要面向接口写 SQL 逻辑。Room 3.0 的 SqliteDriver 就是这个角色。你在 commonMain 里不关心具体用的是哪个实现,只需要在 Android 端创建数据库的时候把合适的驱动塞进去。
换句话讲,Room 3.0 尝试摆脱的,是“同一个数据库框架在不同平台上各自为政”的状态。它要让你写的 DAO、Repository、Migration 都能复用,真正要适配的只剩底层 driver。
2.2 Android 上两种典型驱动怎么选
在 Android 端,最常见的两个选择是系统框架驱动和捆绑驱动。它们之间的差异,用一个表格看更清楚。
| 对比维度 | 系统框架驱动 | 捆绑驱动 |
|---|---|---|
| SQLite 版本 | 取决于设备系统版本 | 应用自带统一版本 |
| 包体积影响 | 几乎无新增 | 会增加若干 MB |
| 扩展能力边界 | 只能使用系统支持的 SQLite 功能 | 可控性更高,版本行为一致 |
| 适用场景 | 老项目、系统级依赖简单 | 需要稳定行为、跨端一致 |
如果你的 App 长期只跑 Android,且对 SQLite 版本没有特殊要求,系统框架驱动已经够用。但如果你准备在 KMP 多端复用同一套 DAO,或者你的团队已经体会到“不同手机系统 SQLite 方言不一致”的痛,捆绑驱动更合适。它的核心优势是让所有平台使用同一个 SQLite 实现,schema 行为、内置函数、FTS5 这些特性就不会因为设备差异出现诡异的不一致。
我在一个实际项目里选择了捆绑驱动,原因是之前遇到过 Android 老设备上某个 SQLite 扩展函数不可用的问题。换了捆绑驱动之后,至少数据库行为能稳定复现,测试和线上不会再出现“设备差异导致 SQL 执行结果不同”的情况。
2.3 初始化代码的变化:不再只有 Room.databaseBuilder
Room 3.0 跨平台之后,最直观的变化发生在数据库实例的创建方式。2.x 时代我们写的是:
kotlin复制val db = Room.databaseBuilder(
context,
AppDatabase::class.java,
"demo.db"
).build()
而在 KMP 结构下,commonMain 里没有 Context,也没有 Android Framework,创建方式会变成先得到一个 Driver,再交给通用的 RoomDatabase.Builder:
kotlin复制// androidMain 中负责提供 Driver
val driver = AndroidSqliteDriver(
schema = AppDatabase.Schema,
context = context,
name = "demo.db"
)
// commonMain 中真正构建数据库
val db: AppDatabase = RoomDatabase.Builder<AppDatabase>(driver)
.build()
这里出现的 AppDatabase.Schema 是 Room Gradle Plugin 为数据库类自动生成的 schema 引用,它在编译期由注解处理器计算好,生成代码时和表的版本控制绑定在一起。第一次看到这个写法可能会觉得陌生,但它的好处是:Driver 的创建和 Database 的构建彻底分层了。以后想换驱动类型,不用动业务代码,只要换掉 Driver 的构造方式。
顺带提醒一句,即使你现在不写 KMP,只是把纯 Android 项目升级到 Room 3.0,上面的结构也应该尽早引入。因为迁移跨平台不是某一天突然发生的,把初始化隔离好了,后续接 iOS、桌面端都只是增加驱动实现的问题。
3. 迁移清单:把 2.x 工程推向 Room 3.0 的全过程
3.1 Gradle 依赖和插件的变化
Room 3.0 对构建脚本的改动比代码改动更大。首先,androidx.room 这个 Gradle Plugin 已经不仅仅是注解处理器的包装,它开始承担 schema 目录管理、多平台数据库元信息生成等工作。如果你原本依赖 KSP 去跑 room-compiler,升级后的模块配置会像下面这样:
kotlin复制plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("com.google.devtools.ksp")
id("androidx.room")
}
dependencies {
implementation("androidx.room:room-runtime:3.0.0-alphaXX")
ksp("androidx.room:room-compiler:3.0.0-alphaXX")
}
room {
schemaDirectory("$projectDir/schemas")
}
一个容易忽略的点是 room-ktx。从 Room 2.6 开始,协程和 Flow 相关支持已经合并进 room-runtime,不再需要单独引入 androidx.room:room-ktx。如果在升级后还留着旧的 room-ktx,依赖解析时很可能产生版本冲突或重复类问题。正确操作是删掉它,把版本统一收敛到 room-runtime 上。
如果你是标准 KMP 工程,还要在 commonMain 里统一引入 Room 依赖,并在各平台 source set 里补充对应的 SQLite Driver 依赖。这里没有特别通用的“一行配置”,因为不同项目用到的 target 集合不一样,但原则是把 Driver 放平台相关层,把 DAO 和实体放到共享层。
3.2 schema 导出与迁移校验策略
Room 的 schema 导出功能以前一直被很多人忽视。@Database 注解里有 exportSchema = true,开启后编译器会导出数据库版本的 JSON 描述文件。Room 2.x 支持这个,但 3.0 里的地位完全不同。因为跨平台之后,多个平台共享同一份 schema 文件,它会成为团队之间、平台之间最重要的契约。
建议你在根模块下建一个 schemas 目录,保持文件提交到版本库,并且千万别把它加进 .gitignore。database 版本从 1 升到 2 时,schemas 里会出现 2.json,Migration 的测试会直接依赖这些文件。Room 官方架构里那把“数据库版本靠人肉同步”的锁,在这里才算是真正补上。
3.3 升级后第一波编译报错的处置
我实际升级时,遇到的第一波报错基本都是旧 API 从 Android 依赖里露出来的问题。报错信息通常长这样:某个 DAO 方法里用了 Cursor,或者某个 TypeConverter 的签名里带了 android.database.Cursor,而对应的注解处理器已经不保证生成代码兼容 Android 专属类型。
解决办法是分两步走。第一步,把 DAO 返回类型里出现的 Cursor 换掉。Room 本身支持 List<T>、Flow<T>、StateFlow 这类不依赖 Android 的类型,除非你是特殊场景非要直接拿 Cursor,否则完全可以用更安全的数据结构替代。第二步,把 TypeConverter 里的 ContentValues、Cursor 依赖剔除干净,改成普通数据类的读写。
这类报错不是 Room 3.0 故意为难你,而是它已经开始用 KMP 的编译标准来约束上层的 API 边界了。一旦把这些 Android 类型清理干净,数据层代码就真的能在 commonMain 里编译通过。
4. 跨平台后,DAO 写法和能力边界有了变化
4.1 commonMain 里能放哪些内容
升级完成后,你会发现自己终于可以把实体类、DAO、Database 定义一股脑搬到 commonMain 里。比如下面这段代码:
kotlin复制@Entity(tableName = "user")
data class User(
@PrimaryKey val id: Long,
val name: String
)
@Dao
interface UserDao {
@Query("SELECT * FROM user WHERE id = :id")
fun observeUser(id: Long): Flow<User?>
@Insert
suspend fun insert(user: User)
@Transaction
suspend fun insertUsers(users: List<User>) {
users.forEach { insert(it) }
}
}
@Database(entities = [User::class], version = 1, exportSchema = true)
abstract class AppDatabase : RoomDatabase() {
abstract fun userDao(): UserDao
}
这段代码在 Room 2.x 里也是合法的,但它只能被 Android 模块编译。到了 Room 3.0,它可以进入共享模块,iOS 端通过 Driver 的适配拿到同一个数据访问层。对多端产品来说,这意味着操作数据库的逻辑、事务处理、数据迁移都只需要写一遍。
4.2 SQL 功能边界:不是所有 SQLite 特性都跨平台一致
跨平台之后最容易让人踩坑的地方,是 SQLite 的功能边界。SQLite 本身是 C 库,但 Android 系统内置版本、iOS 系统内置版本、桌面端捆绑版本之间,对 FTS5、JSON1、窗口函数、用户自定义函数等特性的支持并不完全一致。
比如你在 DAO 里写了一个用到 SQLite JSON 函数的 @Query,在 Android 新设备上编译期校验能过、运行也正常,但同样的 SQL 放到旧设备、或者放到 iOS 驱动上,可能跑出完全不同的结果。原因不是 Room 本身不支持,而是底层 SQLite 版本和编译选项不一致。
想彻底摆脱这类问题,最稳妥的方案是在所有平台使用捆绑驱动,让 SQLite 版本统一。但即便版本统一,平台之间的 C 库调用方式仍可能不同,所以涉及底层扩展的时候,仍然需要保留一个风险意识:先在目标平台上执行一遍,再放给用户。
4.3 外键、事务和线程模型的差异
Room 2.x 在 Android 上有自己的一套事务和线程调度逻辑,到 KMP 场景下这些逻辑会尽量保持语义一致,但底层行为仍然受平台影响。
外键约束是一个典型例子。Android 的 SQLite 默认不开启外键,Room 提供了对应的设置项。到了 iOS 或桌面端,驱动层的默认开关未必一致。如果你在实体里定义了外键关联,且依赖数据库层强制约束,迁移到新驱动后一定要显式确认外键行为是否还符合预期,不要用“应该没问题”来打发。
线程模型上的差异同样要重视。Android 系统 SQLite 默认面向多线程使用做了不少适配,而其他平台上如果驱动没有做好多线程配置,你自己又没有限制调用线程,很容易出现数据库锁异常。我的建议是:跨平台版本里仍然沿用 Room 一贯的最佳实践——不要在主线程访问数据库,把 DAO 调用放到 repository 层统一管控,避免多线程并发打开数据库。
5. 这些坑我替你先踩过了:Room 3.0 实测记录
5.1 schema 目录要固定,别被清理脚本误伤
我第一个踩的坑就出在 schemas 目录上。项目里原本有个清理无效文件的脚本,对“看起来没参与编译”的目录会定期删,结果它把 schemas 目录当成缓存清掉了。Room 3.0 编译时找不到 schema 文件,会重新生成,但如果此时某个数据库已经做过一次版本升级,新生成的 schema 文件里就没有历史版本记录,后续写 Migration 会非常痛苦。
这个问题的根因是团队没有把 schema 文件当作正式产物。我后来把 schema 目录定义为只读资源,在 CI 里增加了校验:如果数据库实体改动之后 schema 文件没有同步更新,直接判定构建失败。这样数据库结构变更再也不会被某个人悄悄地改掉。
5.2 KSP 和 Kotlin 版本必须对齐
Room 3.0 仍然依赖注解处理器生成代码,KMP 场景下基本都走 KSP。KSP 的版本和 Kotlin 编译器版本是强绑定的,版本对不上时会出现类似“Internal error in KSP”这类很难阅读的错误提示。
解决方式没有捷径,把项目里的 Kotlin 版本固定到某个稳定版本,同时使用与该版本配套的 KSP 插件。升级 Room 大版本时,不要只升级房间相关的依赖,要一并检查 Kotlin、KSP、AGP 的版本矩阵是否匹配。我见过太多项目 Room 升上去了,Kotlin 还在旧版本,结果编译错误满天飞,最后还误以为 Room 3.0 不稳定。
5.3 包名和生成实现类的对应关系
Room 的注解处理器会生成 AppDatabase_Impl 这类实现类,通常放在和抽象类相同的包名目录下。KMP 工程里,如果同一份实体文件被多个 target 同时编译,不同 target 生成的产品路径可能不太一样,偶尔会出现改完实体类之后 IDE 还在用旧缓存的情况。
碰到这种情况,先执行一次干净的 Build,再让 IDE 重建索引。如果类找不到的报错反复出现,重点检查 commonMain 的目录结构是否符合 KMP 约定,以及实体类是否真的被标注成了 expect/actual 需要的形态。大多数情况下,问题是构建目录弄脏了,不是 Room 本身的问题。
5.4 用 JVM target 提前做数据层自测
Room 3.0 跨平台之后,有一个立竿见影的好处是可以在纯 JVM target 上启动数据层单元测试。原先在 Android 上跑数据库测试要依赖 Robolectric 或者 Instrumentation,慢且麻烦。现在 JVM 上可以直接使用桌面驱动或捆绑驱动初始化数据库,跑 DAO 和 Migration 的测试。
我在项目里是把数据库相关测试全部放到了共享模块的 JVM target 下,CI 里只跑这一部分。测试速度比原来模拟器流水线快了一倍不止,而且因为不依赖 Android Framework,定位问题要省心很多。建议所有准备升级到 Room 3.0 的团队都搭上这个能力,它带来的长期收益可能比 Room 本身还大。
6. 要不要升级:我个人的判断和建议
6.1 什么样的项目不要急着动
如果你的项目是纯 Android,未来一年也没有任何跨平台计划,那真没必要赶这个版本。Room 2.x 在 Android 上已经很成熟,系统框架驱动的路径也经过多年验证,升级到 3.0 主要增加了构建脚本和 schema 管理的复杂度,业务上的收益不明显。这种场景下,安稳等 3.0 正式版发布、社区踩坑笔记再多一些再升,是更理性的选择。
这里说的“不要急”不是否定 Room 3.0,而是说升级的收益和成本要对得起当前项目的实际情况。Android-only 的项目里,Room 3.0 的最大卖点——跨平台复用——根本用不上,那你还得额外承担新版本的适配成本,不划算。
6.2 什么样的项目适合现在上车
反过来讲,下面几种项目是可以考虑现在就上 Room 3.0 的:
- 新启动的项目,产品路线图里已经有考虑未来可能包含 iOS 或桌面端。
- 现有的 KMP 项目还在用 SQLDelight,但团队对 Room 的语义更熟悉,想统一技术栈。
- 纯 Android 项目但数据层模块分层已经做得很干净,升级只涉及构建脚本和少数初始化代码。
- 团队有配套的 CI,能够承受 alpha/beta 阶段偶发的 API 微调。
新项目尤其建议直接从 Room 3.0 起步。大家都吃过老项目欠技术债的苦,数据库层是迁移成本很高的部分,与其等以后业务量大了再重建,不如一开始就按跨平台的边界设计好模块依赖关系。
6.3 迁移时的模块边界设计
如果你决定上车,我的核心建议是:无论如何都要把数据库初始化隔离出来。在项目里抽一个专门的 database 模块,模块内部维护实体、DAO、Database、Migration,对外只暴露 repository 接口。这样以后无论 Room 版本怎么升级、Driver 怎么换,影响范围都锁在模块内部,不会污染到上层业务。
另一个值得做的工作是建立 schema 评审机制。每一次数据库结构变更都要带上 schema JSON 的 diff,代码评审的人除了看实体类改动,还要看 schema 文件的变化是否符合预期。这比让业务方直接看 SQL DDL 容易理解得多。实测下来,这个习惯帮助团队避免了好几次误删字段引发的线上问题。
总的来说,Room 3.0 这波改得确实很深,但它并没有把 Room 的用法推翻重来。DAO、实体、注解、编译期校验这些核心体验都保留了,真正变化的是底座。只要你把 Driver 这件事想清楚,把 schema 管起来,升级过程并没有想象中那么可怕。等正式版发布后,我还会把打包体积和具体性能数据再测一遍,到时候继续分享。
