数据访问对象(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 设计应该包含什么?

我把核心要素画成了一张图,方便你理解整体结构。

DAO设计核心要素 @Dao 接口 操作注解 @Insert / @Update / @Delete / @Query 返回类型 Flow / LiveData / suspend / 即时值 特殊处理 @Transaction / @RawQuery 设计原则 • 每个表一个 DAO 接口,保持高内聚 • 查询返回 Flow 用于响应式 UI,避免 LiveData 的 observe 生命周期绑定 • 多步写入操作必须加 @Transaction,防止中间态污染 DAO → Room 编译期生成实现类 → 数据操作

实战中的几个“血泪”经验

最后分享三个我实际踩过的坑,希望能帮你省点调试时间。

  1. 表名大小写敏感:我曾在 SQL 里写 FROM Users,但实体类叫 users 表(全小写)。Room 编译过了,运行时报 no such table。后来才知道,Room 在编译时会把实体类的表名转为小写,但 SQL 里的字符串不会帮你转,强烈建议统一用小写表名。
  2. 参数名不要带下划线前缀:比如 :age 正常,但 :_age 会触发 Room 编译 bug(某个旧版 Room 的遗留问题)。我现在全用驼峰命名参数。
  3. @Query 支持返回 Cursor,但我建议能不用就不用。Cursor 索引容易写错,而且不是类型安全的。直接用 List<Entity> 多爽,Room 自动帮你映射。

📌 核心总结:DAO 是 Room 和业务层之间的桥。设计时把 返回类型 和 事务边界 想清楚,大部分坑都能提前避开。我的原则是:单表查询用 Flow,多表写入用 @Transaction,参数绑定永远用冒号加变量名。记住这三点,你的 DAO 层会很干净。