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