数据访问对象(DAO)设计
DAO,说白了就是 Room 暴露给你的操作数据库的接口。你不需要写 SQLiteOpenHelper 那些又长又臭的样板代码,只需要定义一个接口,加几个注解,Room 编译期自动给你生成实现类。我刚开始接触 Room 时,觉得这简直是解放生产力——不用再手写 ContentValues 和 Cursor 了。
但设计 DAO 不是把 SQL 扔进去就完事。它涉及返回类型怎么选、事务怎么处理、参数怎么传。搞不好,性能问题、编译错误、运行时异常都会找上门来。这一章,咱们就把它掰开揉碎,讲清楚。
DAO 的基本构成:一个接口,三个要素
一个标准的 Room DAO 接口大概长这样:
@Dao
interface UserDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(user: User)
@Query("SELECT * FROM users WHERE uid = :uid")
suspend fun getUser(uid: Int): User?
@Query("SELECT * FROM users")
fun getAllUsers(): Flow<List<User>>
}
你会发现,核心就三样东西:
- @Dao 注解——告诉 Room 这是一个数据访问对象
- 增删改查注解(@Insert, @Update, @Delete, @Query)——指定操作类型
- 方法返回类型——决定调用方怎么拿数据、什么时候拿
我个人习惯把每个表的 DAO 单独放在一个文件里,名字就叫 XxxDao。这样项目大了也好找,结构清晰。
返回类型选不对,坑就来了
先列一张表,看看 Room 给你提供了哪些返回类型,分别适合什么场景。
| 返回类型 | 特点 | 适合场景 |
|---|---|---|
即时值(如 User, List<User>) |
同步返回,必须配合 suspend 或 Rx/协程 | 一次查询,不关心后续变化 |
LiveData<T> |
观察者模式,自动刷新 UI | Android 传统 View 体系,生命周期感知 |
Flow<T> |
协程响应式流,冷流支持变换 | Compose + MVVM,需要集合操作符 |
RxJava (Observable/Flowable/Single) |
背压、线程调度丰富 | Rx 技术栈遗留项目 |
我在项目中吃过一次亏:有个查询返回了 List<User> 但不是 suspend 的,当时忘了切线程,直接在主线程调用了。Room 编译没报错,运行时直接崩了——Cannot access database on the main thread。嗯,这里要注意:非 suspend 的同步查询,Room 默认不帮你切线程,必须自己处理。
后来我的经验是:能用 Flow 就用 Flow。特别是 Compose 场景,Flow 配合 collectAsState 简直是天生一对,数据更新自动触发重组,不需要手动管生命周期。
💡 小提示:如果你的查询结果永远不会变(比如查一次配置常量),用 suspend fun 返回即时值就够了,没必要上 Flow,省掉协程上下文开销。
@Transaction:多表操作别漏了这个注解
Room 默认每个 DAO 方法是原子执行的。但如果你在一个方法里做了两次操作——比如先删除旧数据再插入新数据——这两步之间可能会被其他线程插队,导致数据不一致。
解法很简单:加 @Transaction 注解。我踩过坑的一个例子:
@Transaction
suspend fun replaceAllUsers(users: List<User>) {
deleteAll()
insertAll(users)
}
不加 @Transaction 的话,deleteAll 做完后、insertAll 执行前,如果另一个线程读取 users 表,会读到空表,UI 出现一闪而过的空白。我曾经在一个同步任务线上看到这个闪白,排查了半天才发现是事务没包起来。
⚠️ 避坑指南:我曾经在同一个 DAO 里用 @Transaction 加上 @Query 操作多个表,结果 Room 编译时报错,说事务里只能有一个被 @Query 标注的方法。后来一查文档才明白:@Transaction 和 @Query 一起用,必须搭配返回 List<...> 且查询涉及多表,否则直接用 suspend 包住两个 DAO 方法调用即可。
@Query 的灵活参数:动态过滤、IN、模糊查询
写 @Query 时,参数绑定用 :paramName 就行了。但有几个小细节容易踩雷。
参数名必须与方法参数名一致
举个例子:
@Query("SELECT * FROM users WHERE age > :minAge")
fun getOlderThan(minAge: Int): Flow<List<User>>
如果你把参数名写成 min_age,Room 会报编译错误——它找不到对应的绑定变量。我见过有人因为重构改参数名没同步改 SQL 里的冒号变量,查了半天才发现。
IN 子句传入集合
有时候需要查多个 uid:
@Query("SELECT * FROM users WHERE uid IN (:uidList)")
fun getUsersByIds(uidList: List<Int>): Flow<List<User>>
Room 会自动把 List 展开成逗号分隔的占位符。这个特性很好用,我经常拿它做批量操作。
模糊搜索用 LIKE 加 '||'
@Query("SELECT * FROM users WHERE name LIKE '%' || :keyword || '%'")
fun searchByName(keyword: String): Flow<List<User>>
注意拼接方式:SQLite 里用 || 做字符串连接。有的同学写成 LIKE '%:keyword%',以为 Room 会自动替换,结果查出来永远是空——Room 只替换 : 绑定的变量,不会帮你解析引号里的东西。
一个完整的 DAO 设计应该包含什么?
我把核心要素画成了一张图,方便你理解整体结构。
实战中的几个“血泪”经验
最后分享三个我实际踩过的坑,希望能帮你省点调试时间。
- 表名大小写敏感:我曾在 SQL 里写
FROM Users,但实体类叫users表(全小写)。Room 编译过了,运行时报no such table。后来才知道,Room 在编译时会把实体类的表名转为小写,但 SQL 里的字符串不会帮你转,强烈建议统一用小写表名。 - 参数名不要带下划线前缀:比如
:age正常,但:_age会触发 Room 编译 bug(某个旧版 Room 的遗留问题)。我现在全用驼峰命名参数。 - @Query 支持返回 Cursor,但我建议能不用就不用。Cursor 索引容易写错,而且不是类型安全的。直接用
List<Entity>多爽,Room 自动帮你映射。
📌 核心总结:DAO 是 Room 和业务层之间的桥。设计时把 返回类型 和 事务边界 想清楚,大部分坑都能提前避开。我的原则是:单表查询用 Flow,多表写入用 @Transaction,参数绑定永远用冒号加变量名。记住这三点,你的 DAO 层会很干净。