为什么用 Room
Room 是 Google 官方的 SQLite 抽象层:编译期校验 SQL、返回类型安全、和 LiveData / Flow 天然集成。相比手写 SQLiteOpenHelper,Room 把「表」变成 Kotlin 类,把「查询」变成接口方法。
写错一个表名或列名,Room 在编译期就告诉你,而不是运行到一半才崩。
依赖只需引入 room-runtime、room-compiler 和 room-ktx。数据库一旦建好,增删改查都收敛到 DAO 里,业务层再也不用碰 SQL 字符串。
工程上还建议把数据库实例做成单例,用 Application 上下文初始化,避免 Activity 重建导致连接反复开关;涉及加密需求时,可以配合 SQLCipher 的 Room 版本,在打开数据库时传入密钥,让落盘数据默认处于加密状态。
实体与类型转换器
实体类用 @Entity 标注,主键用 @PrimaryKey,字段即列。遇到列表、枚举这类 SQLite 存不了的类型,用 TypeConverter 转换。
@Entity(tableName = "notes")
data class Note(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val title: String,
val content: String,
val tags: List<String>,
val updatedAt: Long
)
class Converters {
@TypeConverter
fun fromTags(tags: List<String>): String = tags.joinToString(",")
@TypeConverter
fun toTags(value: String): List<String> = value.split(",")
}
转换器注册到 @Database 的 converters 数组里。字段增减要配迁移,后面专门讲。实体字段别放计算属性,那是 DAO 查询该做的事。
DAO 与查询
DAO 是数据访问的唯一入口:@Insert、@Update、@Delete 自动生成实现,复杂查询用 @Query 写 SQL。Room 会在编译期把 SQL 和表结构对一遍。
@Dao
interface NoteDao {
@Insert
suspend fun insert(note: Note): Long
@Query("SELECT * FROM notes ORDER BY updatedAt DESC")
fun observeAll(): Flow<List<Note>>
@Query("SELECT * FROM notes WHERE id = :id")
suspend fun findById(id: Long): Note?
@Query("DELETE FROM notes WHERE id = :id")
suspend fun deleteById(id: Long)
}
返回值用 Flow 而不是普通 List,数据一变 UI 自动更新。写 @Query 时 SQL 按表名列名写,别按 Kotlin 属性名,二者默认相同、改名后就分开了。
关系与关联查询
一对一、一对多关系用 @Relation 表达。父实体加一个 List 子实体字段,Room 自动做关联查询;涉及多条语句用 @Transaction 保证一致性。
data class NoteWithTags(
@Embedded val note: Note,
@Relation(parentColumn = "id", entityColumn = "noteId")
val tags: List<Tag>
)
@Transaction
@Query("SELECT * FROM notes ORDER BY updatedAt DESC")
suspend fun getNotesWithTags(): List<NoteWithTags>
@Embedded 把另一张表或值对象铺进结果,@Relation 负责拉取子表。关系别建太深,两层以内最清晰,超过两层拆成多次查询反而更好读。
关于关系的使用,还有几个工程上的细节值得展开。首先,@Relation 查询在底层会生成两条 SQL,先用父表查询再取子表数据,所以在列表页一次性加载大量带标签的数据时,要考虑数据量是否可控;数据量较大时,建议改成分页加载或单独查询标签表,再在内存里按主键组装。其次,@Embedded 适合把经纬度、货币金额这类值对象直接平铺进实体,避免为它们单独建表,但要注意字段名前缀冲突,必要时用 prefix 参数区分。第三,关系的删除要显式处理,Room 不会自动级联删除,删除父记录后子表会留下孤儿数据,最好在 DAO 的删除方法里配合事务一起清理。把这些细节想清楚,关系模型才能在真实业务里稳定运行。
数据库迁移
版本升级时表结构会变,Room 用 Migration 对象描述从旧版本到新版本的 SQL 变化。漏写迁移,旧用户升级后直接崩。
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE notes ADD COLUMN isPinned INTEGER NOT NULL DEFAULT 0")
}
}
Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
.addMigrations(MIGRATION_1_2)
.build()
跨大版本升级时,Room 会从起始版本逐个执行迁移链。新建表用 CREATE TABLE IF NOT EXISTS,重命名列先建新列再复制数据,复杂场景用 fallbackToDestructiveMigration 兜底但要慎用,它会丢数据。
Flow 与协程集成
Room 的 suspend 查询自动切到 IO 线程,Flow 查询会在表数据变化时自动重新发射。结合 StateFlow 和 ViewModel,一套响应式数据链路就成型了。
class NoteViewModel(private val dao: NoteDao) : ViewModel() {
val notes: StateFlow<List<Note>> = dao.observeAll()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
fun add(title: String, content: String) {
viewModelScope.launch {
dao.insert(Note(title = title, content = content, updatedAt = System.currentTimeMillis()))
}
}
}
stateIn 的 WhileSubscribed 避免没人观察时白干活。批量写入用 withTransaction 包起来,要么全成要么全不成。
小结
Room 让数据库开发回归 Kotlin:实体表、DAO 查询、转换器处理特殊类型,Flow 提供响应式更新,Migration 管好升级。记住三条:类型转换器配 @Database 注册、关系查询用 @Transaction、版本升级必须写 Migration。把数据层收敛进 Repository,业务代码就不脏。数据层稳了,上面的 UI 和逻辑才有底气。