3、DAO:数据访问对象操作总结

DAO 是 Room 的数据入口,说白了就是你把 SQL 操作封装成接口方法的地方。我刚用 Room 时总觉得它跟传统的 SQLiteOpenHelper 差不多,后来才发现它藏了不少巧劲。

这一章我把四个核心操作——查询、插入、更新、删除——按我个人经验撸一遍。嗯,代码能直接复用。

3.1 四个操作的核心对比

Room 用注解标记方法类型,返回值也有讲究。我整理了一张常用对照表,你写的时候可以对着看:

操作 注解 推荐返回值 注意点
插入 @Insert long 或 List<Long> 冲突策略默认 ABORT
查询 @Query POJO、LiveData、Flow SQL 在编译期校验;响应式需返回 LiveData / Flow
更新 @Update int(受影响行数) 按主键匹配;默认更新全部字段
删除 @Delete int(受影响行数) 按主键匹配;删除不存在的记录返回 0

↓ 我个人习惯 ↓ 插入用 long 拿自增 ID,更新和删除用 int 判断是否成功。虽然 Room 也支持返回 void,但少了反馈调试时很麻烦。

3.2 插入:别被「冲突策略」坑了

@Insert 默认使用 OnConflictStrategy.ABORT,一旦主键冲突直接抛异常。我在项目里写过一段批量插入日志的功能,没设冲突策略,结果线上偶现崩溃……

改成 OnConflictStrategy.REPLACE 或 IGNORE 就能柔性处理:

@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertUser(user: User): Long

避坑指南: 如果你的实体类用 @PrimaryKey(autoGenerate = true),那么 REPLACE 会让自增 ID 跳跃。我建议配合 @ColumnInfo(defaultValue = "NULL") 显式控制。

3.3 查询:LiveData / Flow 还是挂起函数?

查询是 DAO 里最灵活的环节。你可以在 @Query 里写任意 SQL(包括 JOIN 和子查询)。关键问题是:返回方式怎么选?

  • 直接用 List 或 POJO: 同步调用,必须在子线程执行。适合一次性读取配置。
  • 用 LiveData / Flow: 自动响应数据库变化。我在做 Feed 流时用 Flow<List<FeedItem>>,数据一变界面马上刷新,不用手动通知。
  • 挂起函数(suspend): 配合协程,异步返回一次结果。适合网络数据缓存。

举个例子,一个带条件的响应式查询:

@Query("SELECT * FROM user WHERE age > :minAge ORDER BY name ASC")
fun getAdultUsers(minAge: Int): Flow<List<User>>

请注意:Room 编译期会检查 SQL 语法和表名。万一你改了实体字段忘了改 SQL……嗯,编译就报错,比运行时崩溃强一百倍。

经验之谈: 如果查询结果只显示一次(比如详情页),用 suspend 函数更轻量;如果结果需要动态更新,果断上 Flow。

3.4 更新:只改想要的字段

@Update 默认按主键匹配,然后 把所有非主键字段 更新为传入对象的当前值。这很危险——你只想改 nickname,结果 age 也被覆盖了。

解决方案有两种:

  1. 用 @Query 写部分更新:
@Query("UPDATE user SET nickname = :newName WHERE id = :userId")
fun updateNickname(userId: Long, newName: String): Int
  1. 在 Entity 里用 @ColumnInfo 设置默认值,然后传入完整对象(但我不推荐,容易漏字段)。

我记得有一个模块,同事用 @Update 传了一个只有主键和 nickname 的对象,其他字段全是 null,结果数据库里的 age 全变成了 0……那以后我规定团队一律用 @Query 做部分更新。

3.5 删除:传对象 vs 传主键

@Delete 的参数可以是实体对象(按主键删),也可以是主键值。我偏好传对象,因为 Room 会自动提取主键,少写一个 SQL:

@Delete
fun deleteUser(user: User): Int

但它只按主键匹配。如果你要按条件删除(比如删除所有未激活的用户),还是得写 @Query:

@Query("DELETE FROM user WHERE is_active = 0")
fun deleteInactiveUsers(): Int

返回值 int 代表删除了多少行。如果返回 0,要么没匹配到,要么数据库里本来就没有。我曾经在测试环境没插入数据就调了删除,看到返回 0 还以为代码写错了……其实正常。

3.6 事务:跨表操作要加 @Transaction

当你在一个 DAO 方法里先后做插入和更新,或者查询多个表,需要保证原子性。Room 用 @Transaction 注解:

@Transaction
fun insertUserWithOrder(user: User, order: Order) {
    insertUser(user)
    insertOrder(order)
}

注意:@Transaction 只能用在 DAO 的非抽象方法(用 default 或 companion object 实现)。如果用了挂起函数,方法体里必须调其他 suspend 方法,Room 会帮你管理事务。

我曾经…… 在事务里调用了一个网络请求,导致事务长时间不释放,数据库锁死。记住:事务里不要做 IO 或网络操作,只做数据库操作。

3.7 DAO 操作之间的关系

我画了一张关系图,帮你理清四个操作怎么协同:

Room DAO 操作关系图 DAO 接口层 @Insert @Query @Update @Delete 返回值特点 Insert: long / List<Long> Update/Delete: int (行数) 响应式支持 @Query 可返回 LiveData / Flow 只读操作自动刷新 事务 @Transaction 组合操作 保证原子性

3.8 避坑清单

  • 不要用 @Update 修改主键——Room 不会报错,但相当于插入新记录。
  • @Insert 传入 null 或空集合会怎样? 空集合没问题,但 null 会抛异常。我习惯加个安全判断。
  • @Query 里的表名写错——Room 编译期能校验,但如果你用了 RAWQUERY(Room 不推荐),就要自己负责了。
  • 批量操作性能:循环调 @Insert 效率低。用 @Insert 传 List 会走一次事务,快很多。

总结一句话: DAO 是业务代码和数据库之间的契约。把每个方法的输入输出想清楚,冲突策略、响应式能力、事务边界都摆到台面上,后面维护起来就轻松了。