这篇内容源于我自己在几个中大型 Android 项目里沉淀下来的 Room 使用心得。标题虽然是持久化层实践但我想先说一句Room 并不是一个换了就不用管 SQL的万能库它更像是在 SQLite 之上补了一层安全网和脚手架用好了确实省心用不好反而会给你带来一种明明编译过了为什么运行崩了的错觉。这篇博文主要围绕实体设计、DAO 查询、数据库迁移、并发事务、类型转换器和编译期配置这几个方向展开适合那些已经在项目里接入了 Room但想在细节上做得更稳的开发者也适合准备从 SQLiteOpenHelper 或其它 ORM 迁移过来的团队参考。我会尽量把每个注意点背后的为什么讲清楚而不是单纯罗列文档结论毕竟持久化层一旦上了生产环境出问题的时候基本就是线上事故级别。1. Room 选型时容易忽略的底层差异1.1 编译期校验才是 Room 最大的价值很多人第一次认识 Room看到的是注解少、API 友好、和 LiveData 配合顺畅但真正让我确定把项目往 Room 上迁移的是它在编译期生成的代码。一般的 ORM 让你写实体类、写 DAO 接口运行的时候通过反射或者动态代理去实现 SQL 逻辑一旦表结构或者查询语句里的字段名写错了通常要等到跑起来、执行到那一条语句的时候才会炸。Room 的选择完全不同它会在编译阶段读取你的实体注解和 DAO 方法然后生成一套具体实现类任何表名、字段名、查询 SQL 的语法错误、返回类型与查询列不匹配都会在编译期暴露出来。举一个很实际的例子你运行一个SELECT * FROM users但是实体里根本没有users这张表或者你在 SQL 里写了WHERE age :age但方法参数里没有age这个变量传统 ORM 可能运行到那一步才报错而 Room 在gradle assembleDebug的时候就停下了。我宁愿在编译时多花几秒也不愿意在用户手机上看那行 SQLiteException。这个校验能力还延伸到 SQL 语句本身。Room 不是简单地把你写的字符串拼接起来执行而是通过 SQLite 的解析器做了一层词法分析所以警惕性弱的IS NULL判断、列名拼写错误、多余的分号这类问题都能被提前拦下。这也是我想给刚开始用 Room 的团队说的一句话不要把它当成注解框架它本质是一个编译期帮你生成 SQLite 访问层的代码生成器。1.2 你不需要 Room 的时候也不是所有项目都应该无脑上 Room。如果你的应用只是存几个键值对用 SharedPreferences 或者 DataStore 就足够了如果你们团队对原生 SQL 非常熟而且业务场景里有大量复杂的多表 JOIN、递归查询、动态数据库 schema那么直接拿 SQLiteOpenHelper 配合 SQLDelight 之类工具可能更顺手。Room 毕竟在 SQLite 外面包了一层它会按照实体映射和注解规则来组织查询过分复杂的 SQL 塞进来之后一方面难以阅读另一方面 TypeConverter 和实体类会让代码变得拧巴。还有一类情况我见过好几次项目本身是跨平台的之后有计划要上 iOS那么优先考虑 SQLite 驱动的共享层或者是直接上不依赖 Android 生命周期组件的方案会比在 Android 层扎进 Room 更合适。Room 绑定 Android SDK也支持 Kotlin Multiplatform 的新版本还在演进取舍要看团队长期方向不是看谁红。1.3 Room 与 Realm、GreenDAO 的取舍思路早期我做过一个 GreenDAO 项目那时候 GreenDAO 的代码生成机制在 Android 生态里是主流方案但它对 Kotlin 协程的支持、对 LiveData 和 Flow 的原生集成都不如 Room 来得自然。Realm 则是另一条路线它干脆不基于 SQLite性能表现有好有坏最大的问题是数据库文件格式私有、表结构变更和云端同步的坑非常深适合产品形态非常固定、不太需要和既有服务端关系型数据库直接对齐的场景。Room 留下来了的原因很简单它就是 SQLite 的面向对象封装语法还是 SQL数据文件直接可以被第三方工具打开团队里任何一个懂数据库的成员都能看懂底层结构。就算之后某一天你们要替换掉 Room底层数据导出、迁移、清洗都还是 SQLite 那一套逻辑并不会被锁死在某一个私有方案里。选型的时候考虑逃离成本的人往往比只看接入成本的人走得更远。2. DAO 层设计返回类型、查询粒度与接口语义2.1 返回值优先用挂起函数还是 Flow这是 DAO 接口设计里争议最大的一个点没有一个绝对答案但我在项目里逐渐形成了自己的习惯。如果是一次性读取比如进入详情页需要拿某一条订单数据我会直接写挂起函数Dao interface OrderDao { Query(SELECT * FROM orders WHERE id :id) suspend fun getOrderById(id: Long): Order? }挂起函数的好处非常明显Room 内部会把查询分发到后台查询执行器你不必手动包一层withContext(Dispatchers.IO)调用方只要在协程作用域里就能安全地拿到结果。它适合读一次、用一次的场景页面加载完成后数据基本不再变化或者你打算配合下拉刷新重新触发加载。如果是列表页、或者数据可能被其它屏幕修改后需要自动刷新那就返回FlowListTDao interface MessageDao { Query(SELECT * FROM messages WHERE chatId :chatId ORDER BY createdAt DESC) fun observeMessages(chatId: Long): FlowListMessage }Flow会在表数据发生增删改之后自动重新查询并向下游发送新结果这相当于给你的 UI 层做了一层轻量级订阅。你不需要在每次插入之后手动发布事件因为 Room 的 InvalidationTracker 已经监听了相关表的更新。这是很多项目从其它 ORM 迁过来后感受最明显的一个差异点数据刷新成本断崖式下降。不过要小心Flow的每一个发射都会触发数据库查询如果查询本身很重表格行数很大UI 一频繁刷新开销会放大。所以返回Flow的查询SQL 一定要写得收敛能用索引就用索引能限制条数就限制条数不要把一个几万行的全表扫描挂在 Flow 上。LiveData 的用法和 Flow 类似但只在 Android 生命周期场景里有意义现在新项目基本都用 Flow我这里不再展开。2.2 DAO 方法不要贪多也不要怕重复我在不少 code review 里看到这样的 DAO一个方法里用Query写了特别复杂的 SQL然后又抽象出一个通用的动态筛选方法把所有可能的参数都列进去最终导致 SQL 长得没法维护。Room 的哲学更适合走方法即意图的路线每个方法名对应一个具体的业务查询方法多了不要紧关键是一个方法只干一件事。举个反例Query(SELECT * FROM products WHERE categoryId :categoryId AND name LIKE % || :keyword || % ORDER BY price) suspend fun searchProducts(categoryId: Long?, keyword: String?): ListProduct这个查询看起来没毛病但categoryId和keyword都可以为空一旦业务上要求只按价格筛选、只搜索全站这个 SQL 会因为参数为空产生完全不同的行为而且条件拼接在字符串里非常难读。Room 允许你用COALESCE或者在 SQL 里做空值判断但代价是查询计划可能没法走索引全表扫描风险升高。我更推荐的做法是拆开几个专用方法Query(SELECT * FROM products WHERE categoryId :categoryId ORDER BY price) suspend fun getProductsByCategory(categoryId: Long): ListProduct Query(SELECT * FROM products WHERE name LIKE % || :keyword || % ORDER BY price) suspend fun searchProductsByKeyword(keyword: String): ListProduct在调用层再根据参数分支走不同方法。有些人会觉得这样代码冗余但数据库查询本身就是行为密集型代码语义清晰比行数少更重要。而且 Room 的 DAO 接口方法很适合复用UI 层组合起来非常自然。2.3 避免把大量数据一次性拉进内存Room 的查询结果默认会一次性实例化到内存里这和 SQLite 的游标逐行读取并不一样。如果你的业务需要加载一张大表的全部数据比如几千行甚至上万行的日志记录直接返回ListEntity内存开销会很大而且 Kotlin 的 List 没有懒加载特性。面对这种场景我通常有两种方案。一种是使用LIMIT做分页配合Flow或者直接传页码另一种是考虑使用 Column 子集映射只查询需要的字段把一个轻量 POJO 作为返回类型而不是把整个实体类全部映射出来。尤其对于列表展示很多详情字段在滚动时根本用不到强行全表加载害处极大。如果只是偶尔需要一次全表计算比如统计数据总和、行数那直接写聚合查询返回Int或者Long就好不要让 Room 把明细数据全部实例化之后再在 Kotlin 里求和。3. 数据库迁移的坑远比你想的多3.1 从版本 1 开始就要导出 Schema很多团队最初接入 Room 的时候只知道写一个实体、建一个 DAO、跑通功能就算完事完全不碰 schema 导出。结果几个月后表结构要变才惊觉没有任何历史 schema 文件迁移测试根本无从下手。Room 官方其实很早就建议你在编译期把数据库 schema 导出成 JSON 文件并纳入版本控制。配置 Room 导出的方式很简单如果用 KSP在build.gradle.kts里这样写ksp { arg(room.schemaLocation, $projectDir/schemas) arg(room.incremental, true) }如果用 kapt则是kapt { arguments { arg(room.schemaLocation, $projectDir/schemas) arg(room.incremental, true) } }配置完成之后构建一次会生成1.json、2.json这样的文件里面详细记录了当前版本的表结构、索引、外键约束。我强烈建议你把schemas目录提交到 Git并且在后续升级中持续保留。这些 JSON 文件是迁移测试的数据基础没有它们你的 Migration 正确性几乎只能靠线上用户去验证。还有一个容易被忽略的细节room.incremental开启后Room 在编译时会依赖上次生成的 schema 文件做增量处理。如果你经常改动实体类又不导出新 schema构建系统会在升级时给出警告甚至导致增量编译失效。保持导出习惯不只是为了迁移也是为了构建效率。3.2 手写 Migration 的常见失误数据库从版本 1 升到版本 2经常碰到的情况是新增一张表或者给旧表增加一个字段。增表比较简单val MIGRATION_1_2 object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL(CREATE TABLE IF NOT EXISTS category (id INTEGER NOT NULL, name TEXT NOT NULL, PRIMARY KEY(id))) } }加字段的时候有一个几乎人人都踩过的坑新增的字段如果声明为NOT NULL却忘记给默认值那么对旧数据表执行ALTER TABLE ... ADD COLUMN会直接失败因为 SQLite 要求新增非空字段必须提供默认值。比如实体类里写的是ColumnInfo(name age) val age: Int,如果直接执行ALTER TABLE users ADD COLUMN age INTEGER NOT NULL在已有数据行的情况下会报错。正确的写法是给一个默认值ALTER TABLE users ADD COLUMN age INTEGER NOT NULL DEFAULT 0然后在实体类里把age也设置默认值为 0这样历史数据自动补齐新插入的数据也符合预期。另一个常见失误是删字段。SQLite 对ALTER TABLE DROP COLUMN的支持很晚才出现早期版本如果你要删除一个列标准流程是建新表、拷贝数据、删旧表、重命名新表。Room 的 Migration 里你必须手动完成这一整套操作它不会帮你自动化。很多人在实体类里去掉了某个字段却忘了写 Migration最后旧版本升级上来直接崩溃错误提示还是 SQLite 的table ... has no column named ...。3.3 那些想直接使用 fallbackToDestructiveMigration 的人fallbackToDestructiveMigration()看着很省事因为只要数据库版本对不上Room 就会帮你把所有表删除重建。它确实能保证永远不会崩溃但代价是用户设备上的全部持久化数据被清空。如果一个 App 的本地数据只是缓存清空之后从服务端重新拉取无伤大雅那 Destructive 迁移可以做为一种应急兜底。但如果本地有用户未上传的笔记、未同步的收藏、或者需要离线访问的核心数据千万不能用这种方式处理。我在实际项目里见过因为线上数据丢失被投诉到应用商店评分崩掉的案例不是玩笑。如果你真的想保留一点兜底能力同时又不想让数据彻底消失可以考虑在覆写数据库之前先导出一份备份文件或者把旧数据同步到服务端之后再触发重建。这个额外成本换来的安全边际在持久化层非常值得。3.4 用测试守住每一次迁移Room 官方提供了一套迁移测试的支持库配合androidx.room:room-testing使用。你只需要写成这样RunWith(AndroidJUnit4::class) class MigrationTest { Test fun migrate1To2_containsCategoryData() { MigrationTestHelper( InstrumentationRegistry.getInstrumentation(), AppDatabase::class.java, emptyList(), FrameworkSQLiteOpenHelperFactory() ).apply { createDatabase(TEST_DB_NAME, 1).apply { execSQL(INSERT INTO users (id, name) VALUES (1, tom)) close() } runMigrationsAndValidate(TEST_DB_NAME, 2, true, MIGRATION_1_2) } } }这套测试能从1.json自动构建版本 1 的数据库插入种子数据后执行迁移再和数据表结构对比、验证数据是否保留。这里要注意MigrationTestHelper需要你把导出的 schema 放进androidTest的 assets 目录或者在测试代码里通过MigrationTestHelper构造函数配置的 schema 路径。如果之前没有导出过 schema这个测试基本写不了所以 3.1 里说的导出习惯到这里会体现价值。我通常在每次新增迁移时至少补三个用例空表迁移、有旧数据迁移、从低版本跨多个版本迁移。跨版本这个尤其值得测因为用户可能停留在很久以前的版本Room 会要求你依次执行中间所有 Migration而不是一步跳到底。如果没有MIGRATION_1_2、MIGRATION_2_3只有 1 直接升 3 的迁移Room 找不到路径就会抛IllegalStateException。4. 并发与事务线程安全不是加了 suspend 就万事大吉4.1 Room 的 suspend DAO 到底在哪里执行这是团队里经常被误解的一个点。Room 提供的挂起 DAO 函数确实会在后台执行但它内部的调度并不等同于Dispatchers.IO。Room 会使用 ArchTaskExecutor 里面的 IO 线程池来执行查询你可以通过RoomDatabase.getQueryExecutor()拿到这个执行器。因此理论上你不必操心主线程问题但仍要记住如果同时发起多次查询它们会进入同一个查询执行器排队。这就带来一个隐患某些新手会在 DAO 方法里写慢查询然后再用async并发触发多个。表面看是异步了实际上所有查询都串行排队页面容易卡顿但又不是主线程卡死那种明显崩溃。定位这种问题通常比较费劲最好一开始就在设计查询时控制耗时别把一些复杂的 JOIN、子查询写进 DAO 的高频路径里。如果需要真正的并行写操作Room 也支持多个连接但事务默认是串行的。我的经验是优先设计好调用链路避免同一个数据库被大量并发写请求打满比折腾连接池配置有效得多。4.2 事务内不能随意挂起尤其不要做网络请求Room 支持把多个数据库操作放进一个事务Transaction suspend fun updateOrderAndLog(order: Order, log: LogEntity) { orderDao.update(order) logDao.insert(log) }这个写法本身没问题Room 会保证两个操作在同一个数据库事务里要么都成功要么都回滚。但有一种反模式很危险就是在Transaction方法内部去执行网络请求或者耗时 IOTransaction suspend fun syncOrder(order: Order) { val remoteResult api.sync(order) // 大忌 orderDao.update(order.copy(synced remoteResult.success)) logDao.insert(...) }表面上编译能通过运行也不一定立刻报错但事务会一直持有数据库锁直到网络请求完成。网络慢一点、超时时间长一点其它所有写操作都会被阻塞UI 上表现为数据一直不刷新严重时直接触发SQLiteDatabaseLockedException。正确的做法是先把网络请求放到事务外拿到结果之后再进入一个短暂的事务去更新本地数据库。事务本身应该是短小精悍的任何等待外部资源的操作都不应该出现在事务边界之内。我甚至会在 code review 里明确禁止在标注了Transaction的方法里调用任何 suspend 的非数据库操作不是因为 Room 不允许而是因为数据库锁与会话时间很容易引发连锁故障。4.3 处理并发更新时的乐观锁思路多设备或者多页面同时更新同一条数据时数据库最后写入的覆盖前一次写入经常造成业务数据丢失。Room 本身没有内置乐观锁但你可以通过字段控制。比较实用的做法是增加一个版本号字段更新时在 SQL 里带上条件Query(UPDATE orders SET status :status, version version 1 WHERE id :id AND version :expectedVersion) suspend fun updateWithVersion(id: Long, status: String, expectedVersion: Int): Int返回值是受影响的行数如果为 0说明版本已被其他事务改过可以在上层提示用户刷新重试。这种方案不需要引入复杂的分布式锁对大部分单机数据库场景已经够用。注意实体类里要相应维护version字段并且在读取的时候一并取出。5. 实体设计中的隐性成本TypeConverter、Embedded 与关系映射5.1 TypeConverter 是最容易被滥用的功能TypeConverter 可以把自定义类型转换为 SQLite 支持的存储类型听起来很方便但每一次转换都会带来序列化和反序列化的开销。早期项目里有人喜欢把 List 直接存成一个 JSON 字符串于是写了一个 Gson 转 String 的 TypeConverterclass StringListConverter { TypeConverter fun fromString(value: String): ListString gson.fromJson(value, object : TypeTokenListString() {}.type) TypeConverter fun toString(value: ListString): String gson.toJson(value) }短期看开发效率很高但等到要按列表中的某个元素去查询时就会发现 SQLite 里存的是一整段字符串压根没法建索引也没法高效过滤。每次查询要么全表拉出来在 Kotlin 里过滤要么使用LIKE碰运气性能差到让人怀疑人生。我的建议是如果某个自定义类型的字段在未来有查询、排序、关联的需求一定要拆成独立的关联表而不是序列化成一锅粥。如果只是展示用途比如存一个图片地址的列表JSON 字符串转换可以接受但必须意识到它无法参与 SQL 条件筛选。还有一个隐蔽的坑自定义类型如果可能为 null一定要在转换器里处理 null 的情况。如果你只定义了fromString(String)和toString(List)当数据库字段为 NULL 时Room 读取时会尝试调用fromString(null)如果函数参数类型是非空 String就会在运行时抛出类型转换异常。老实给转换器声明String?和空安全处理能省去不少排查时间。5.2 用 Embedded 代替大量重复列实体里如果有一组字段总是成对出现比如地址中的省份、城市、详情可以直接把它们放进一个嵌入类data class Address( val province: String?, val city: String?, val detail: String? ) Entity data class Shop( PrimaryKey val id: Long, Embedded val address: Address? )Room 在生成表结构时会把Address的字段展开成province、city、detail三列查询的时候直接拿到一个完整的Address对象。这种写法既保持面向对象建模又不必维护大量冗余的转换代码。但要注意Embedded的字段名冲突问题。比如Shop里自己也有一个name字段而Address里也定义了nameRoom 编译期会直接报错。解决办法是设置前缀Embedded(prefix addr_) val address: Address这样生成的列名是addr_province、addr_city、addr_detail。设计了前缀之后要特别留意升级迁移如果你在版本 2 才新增了Embedded(prefix addr_)那么老表里并没有这些列必须写迁移补齐。5.3 关于 Relation你可能误解了 N1网上有不少文章一提到 ORM 就说N1 查询Room 的Relation实际上已经做了一层优化。当你在一个父实体列表查询中声明了RelationRoom 会先查出父表数据再根据父表主键集合生成一条IN查询去加载子表然后自动关联映射。也就是说你写了一个看似懒加载的关系Room 内部会一次批量加载并不会每条父记录各查一次子表。这确实比 GreenDAO 时代的懒加载策略好很多但代价仍然存在假如父表查出来 1000 条子表也一次性加载 1000 条对象如果业务里根本用不到那么多内存开销依然很大。对于移动端有限的资源我不建议把列表页的每次查询都连带把所有Relation子集合全部加载出来。列表页尽量只映射一个轻量 POJO进详情页再单独查询完整数据是性价比更高的方案。5.4 外键约束开还是不开Room 支持在Entity上用foreignKeys声明外键Entity( foreignKeys [ ForeignKey( entity Order::class, parentColumns [id], childColumns [orderId], onDelete ForeignKey.CASCADE ) ] ) data class OrderItem( PrimaryKey val id: Long, val orderId: Long, val productName: String )开启外键约束数据库层面的完整性由 SQLite 保证删除订单时会自动级联删除明细。这套机制是可靠的但有一个容易被忽视的问题Room 的实体关联不是强链接你在 Kotlin 里通过Relation拿子表必须手动保证外键字段的有效性。如果在插入订单明细时传入了不存在的orderIdSQLite 会直接抛出外键约束异常这个异常往往在运行时才出现。在原型阶段很多人干脆不声明外键省得麻烦。但我还是建议核心业务表开启外键因为持久化层的数据完整性问题如果不在数据库上设防就必须在业务代码的三四个工具类上设防后者非常不可靠。6. KSP 与构建配置的选择直接影响开发效率6.1 尽量使用 KSP而不是 kaptRoom 从 2.6.0 开始对 KSP 的支持已经非常成熟如果项目使用的是 Kotlin 1.9直接切换到 KSP 是明确的性价比提升。kapt 的本质是在 Java Annotation Processor 和 Kotlin 之间做一层桥接这个桥接过程非常消耗编译时间很多大型项目全量编译一半的时间都花在 kapt 上。KSP 则直接参与 Kotlin 编译生成代码的性能显著更高。我接手过一个旧项目把 kapt 换掉之后一次 Debug 编译时间从接近三分钟降到了四十秒。这个体感差异极大对日常开发频率和心情都有正面影响。如果你的项目还在用 kapt我说的话可能就是你下一个迭代周期最值得做的事。不过要注意从 kapt 切到 KSP 后Room 的注解处理参数配置位置会变化迁移的时候照着 3.1 里的配置重写一遍就好。还有一点KSP 版本必须和 Kotlin 版本匹配否则构建会直接报错这一般写在 KSP 插件文档里配 Gradle 时容易踩。6.2 多模块下的 schema 路径配置项目一旦模块化Room 可能出现在:data、:database、:cache等多个模块里。每个模块都要配置独立的 schemaLocation否则多个模块生成的 JSON 文件会互相覆盖迁移测试也拿不到正确文件。我的做法是在每个包含Database的模块下都建立一个schemas目录并且把模块名加进目录路径里比如app/schemas/databaseModule。这样如果某个模块删掉重建项目历史里仍然能找到可用的 schema 文件。测试类里对应的 schema 路径也要保持相同规则否则MigrationTestHelper找不到匹配版本。6.3 Room 2.6 之后的版本变化新版本带来的 KMP 支持、增量注解处理改进值得你持续关注但不要盲目追新。Room 的单测和迁移测试链路最好和应用核心版本绑定短时间内不要频繁升级。我在项目里一般会等新版本发布两个 patch 之后在测试环境跑过一轮核心流程再合入配合 GraalVM 之类的优化反而没太多用处。另外Room 的数据库创建工厂支持Bundle传递 SQLite 配置这在多进程访问同一数据库时会变得关键。如果你的 App 需要多个进程写同一个数据库一定提前想好处理方案否则偶尔出现的database is locked会非常恶心。7. 几个容易在 review 阶段被漏掉的边界条件7.1 空字段和空表条件下的查询行为Room 对空字段的处理相对严谨但业务代码很容易忽略null的情况。比如用Relation关联子表如果父表记录没有子记录Room 返回的List是空数组还是null和你的声明方式有关。如果你用ListItem来接收通常是空数组如果子表关联的数据在数据库中确实不存在一些旧版本可能返回null导致 NPE 风险。最保险的方式是统一在 DAO 方法上声明非空返回类型并在上层使用前判空兜底不要让一个空数据场景把列表页干崩。7.2 复合索引和查询计划的验证Room 支持在Entity里声明indicesEntity( indices [ Index(value [categoryId, createdAt]), Index(value [name], unique true) ] )索引不是越多越好每一行插入、更新时都要同步维护索引结构。我的原则是先确认业务真正的高频查询路径再针对这些路径建立复合索引索引字段的顺序要和 WHERE 条件的顺序一致否则查询优化器可能不会命中。如果某个索引对查询毫无帮助它只会拖慢写操作建议及时删掉。验证索引是否生效最直接的方法是用EXPLAIN QUERY PLAN命令看 SQLite 的查询计划。Room 的 DAO 里不方便直接跑这个命令但你可以通过 Android Studio 自带的 Database Inspector 连接数据库执行那条 SQL 的实际执行路径一清二楚。真正优化过的查询和拍脑袋建的索引效果差距远超想象。7.3 自定义数据库路径和备份Room 默认数据库文件存放在应用私有目录这本身已经安全但如果要做云备份或迁移到新设备你需要拿到文件路径。RoomDatabase 的openHelper可以通过getReadableDatabase().path拿到或者直接配置Room.databaseBuilder(context, AppDatabase::class.java, my_db) .setJournalMode(JournalMode.WRITE_AHEAD_LOGGING) .build()WAL 模式在提高并发读写能力的同时会产生-wal和-shm附属文件。做文件备份时不要只拷贝主数据库文件否则会漏掉尚未合并到主文件里的数据变更。正确的做法是让数据库先执行一次 checkpoint或者在备份前将单条写入连接关闭。这个小细节在本地数据需要全量迁移到新手机时特别明显很多人就是备份了一下结果数据少了一截。8. 我踩过坑之后留下的几条操作习惯说了这么多原理和设计取舍最后分享几条我现在写 Room 项目时默认遵守的习惯。第一每次改动实体类第一件事就是看编译期生成的 schema 变化并且马上补对应版本的 Migration。不要等所有功能都做完再统一补因为那时候你已经忘了哪些字段是哪一轮变更加进来的。配合 Git 的历史记录逐版本核对 schema 文件数据库结构才可能一直可控。第二DAO 里凡是要被列表页高频调用的查询我会刻意控制返回字段数量。能映射一个轻量ListItem就绝不去触碰完整实体尤其是那些存了 JSON 大字段的表。等到详情页再查完整实体从 List 转详情也就是一次主键查询的开销。第三把 Room 的 Flow 查询理解成数据源而不是一次性接口。当你在 ViewModel 里用stateIn做冷流转热流、把数据库流绑定到 UI 生命周期时一定记得控制流的作用域。千万不要在每次重组时都去新建一个数据库 Flow否则会引发重复注册和资源泄漏。第四数据库文件的隐私和合规也要纳入考虑。用户数据存到了本地意味着你有责任在用户注销账号或清除数据时把相关表完整删除账目、日志、缓存全部清干净。Room 的 API 在删除数据上通常很直接但级联和关联表之间的一致性需要自己测试验证。Room 这套持久化层方案真正的学习曲线并不在于注解怎么写而在于每一条 SQL、每一次表结构变更、每一个事务边界背后你是否清楚 SQLite 在这台设备上到底在做什么。保持对底层行为的敬畏数据层才会成为项目里最值得信赖的一环。