Room实体与DAO:定义数据层的基础构件

离线优先架构里,数据层是核心。而Room,说白了就是Google官方帮我们封装好的SQLite工具包。你需要定义两个东西:实体(Entity) 和 DAO(数据访问对象)。一个负责表结构,一个负责增删改查。今天我带你手把手搭起来。

实体:把Kotlin对象变成数据库表

实体就是一个普通的数据类,加上@Entity注解。我习惯在实体类里只放字段和注解,不放业务逻辑。给你看个最简单的例子:

@Entity(tableName = "articles")
data class ArticleEntity(
    @PrimaryKey
    val id: Long,
    val title: String,
    val content: String,
    val publishedAt: Long
)

这里有几个关键点,我在项目中踩过坑,分享给你:

  • 主键必须指定:要么用@PrimaryKey,要么用autoGenerate = true。我遇到过有人忘记加,编译直接报错,一脸懵。
  • 表名可选:不写默认用类名,但建议显式写tableName,后续改类名不会影响已有数据。
  • 字段名:默认是变量名,想要不同列名加@ColumnInfo(name = "column_name")。
曾经踩过的坑:
我曾经在实体里混用了基本类型和可空类型,比如 val title: String? 和 val id: Long,结果Room生成的迁移脚本里把NOT NULL和NULLABLE搞混了,运行时直接Crash。所以尽量保持字段类型一致,如果需要可空,明确加问号。

复杂字段怎么办?类型转换器

数据库不支持List、Date这些类型。你写个val tags: List<String>,Room肯定报错。解决方案就是类型转换器。我通常这样做:

class Converters {
    @TypeConverter
    fun fromStringList(value: List<String>): String {
        return value.joinToString(",")
    }

    @TypeConverter
    fun toStringList(value: String): List<String> {
        return value.split(",").filter { it.isNotBlank() }
    }
}

然后把它注册到Database上:

@Database(entities = [ArticleEntity::class], version = 1)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
    abstract fun articleDao(): ArticleDao
}

转换器的原理就是序列化/反序列化。我个人习惯用JSON,比如Gson或Moshi,但为了简单,上面用了逗号分隔。不过要注意,List里如果有复杂对象,建议用JSON,否则容易解析出错。

DAO接口:写一个干净的CRUD

DAO别写成类,必须是接口或抽象类。每个方法对应一条SQL或者Room的快捷注解。我们来看个典型的DAO:

@Dao
interface ArticleDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertAll(articles: List<ArticleEntity>)

    @Query("SELECT * FROM articles ORDER BY publishedAt DESC")
    fun getAllArticles(): Flow<List<ArticleEntity>>

    @Query("SELECT * FROM articles WHERE id = :id")
    suspend fun getArticleById(id: Long): ArticleEntity?

    @Delete
    suspend fun delete(article: ArticleEntity)

    @Update
    suspend fun update(article: ArticleEntity)
}

你想想看,这里隐藏的细节其实不少:

  • suspend 关键字:Room在Kotlin协程里会自动切换到后台线程。千万别在DAO里用回调,那都是老古董了。
  • Flow 返回值:查询结果会变成响应式,数据变化后自动推送新数据。离线优先最爱的模式,不需要手动刷新。
  • onConflict = OnConflictStrategy.REPLACE:主键冲突时覆盖旧数据。本地缓存常用这个策略,不写的话默认ABORT,会抛异常。
我的推荐:
离线优先场景下,建议所有插入操作都加上REPLACE策略。一次性同步服务器数据时,你不想因为一条重复记录导致整个事务回滚吧?

查询的艺术:写好SELECT

Room支持几乎所有的SQLite语法。但有些坑得注意:

写法 说明 避坑
WHERE column = :param 绑定参数,防SQL注入 别用字符串拼接,Room会帮你处理
LIKE '%' || :query || '%' 模糊搜索 Room里不能用%:query%,必须用||拼接
IN (:ids) 集合查询 参数类型为List<Long>即可

举个例子,如果你要搜索文章标题:

@Query("SELECT * FROM articles WHERE title LIKE '%' || :query || '%'")
fun searchByTitle(query: String): Flow<List<ArticleEntity>>

注意返回类型用了Flow,搜索框输入时会自动触发查询,体验丝滑。我自己在项目里就是这样实现的,用户输入“Android”时,列表实时过滤,完全不用手动点击搜索按钮。

插入与事务:批量处理要小心

单条插入用@Insert没问题。但离线优先下经常要批量同步,我就直接传List。Room会自动开启事务,但如果数据量太大,可能ANR。我建议:

  • 小批量(少于100条):直接用insertAll
  • 大批量(上千条):手动配合@Transaction + suspend + 分批插入

手动事务写法:

@Transaction
suspend fun insertInBatches(articles: List<ArticleEntity>) {
    articles.chunked(200).forEach { batch ->
        insertAll(batch)
    }
}

为什么这样写?因为Room的@Insert默认也开启了事务,但内部是一次提交。拆成每200条一个小事务,既保证了原子性,又不会锁表太久。我曾经在一个项目中一次同步了5000条数据,直接用insertAll,结果UI卡了5秒……后来改成批量就好了。

核心总结:
Entity = 表结构 + 类型转换器
DAO = SQL注入安全的查询 + 协程/Flow的响应式返回
Database = 注册实体和DAO,管理数据库版本

整体关系用下面这张图可以看清楚。画的时候我特意把实体、DAO、Database分成三块,箭头表示依赖方向。你看,实体是DAO的操作对象,Database是DAO的持有者,三者构成了Room的核心三角。

Room 实体与 DAO 关系图 实体层 (Entity) ArticleEntity UserEntity CommentEntity @Entity + @PrimaryKey DAO 层 @Insert @Query + @Update + @Delete Flow / LiveData / suspend @Dao 注解 Database 层 @Database @TypeConverters 版本策略 / 迁移 abstract fun dao() 操作 注册 依赖(实体列表) 说明 实体定义表结构,DAO 提供操作接口,Database 管理整体生命周期。 三者通过 Room 编译时生成代码连接,无需手写 SQLiteOpenHelper。

你可以把上面这段代码复制到自己的项目里,替换掉包名和表名。我个人习惯把Entity、DAO、Database三个文件放在data/local包下,每种表一个Entity一个DAO,Database统一管理。下一阶段我们就会用Repository把这些组件串起来。不过,那是后面的事了,先把眼前的数据层搭稳。