Room 数据库
本教程共 100 篇 · 第 68 篇 · 更新于 2026-07-28 · 约 9 分钟阅读
68. Room 数据库
本节目标:学会用 Room 在 SQLite 之上快速搭一个本地数据库,掌握 Entity、DAO、Database 三件套,配合协程完成异步增删改查,并理解 Flow 监听和数据库迁移。
上一章我们手写过原生 SQLite,那种感觉你应该还记得——拼接 SQL 字符串、手动 moveToNext、一不小心列名拼错运行时才崩。Room 就是来治这个毛病的。
Room 是什么
打个比方,原生 SQLite 像你自己手写 SQL 操作 Excel 文件,每一步都得自己管;Room 像给你配了个秘书,你只管描述「我要一张用户表,字段是这些」,剩下的建表、增删改查、SQL 校验它都帮你搞定。
Room 是 Jetpack 里的持久化库,在 SQLite 上加了一层抽象,特点是:
- 编译期校验 SQL 语句,写错列名直接编译报错,不用等运行时崩。
- 用注解(
@Entity、@Dao、@Database)代替样板代码。 - 天然支持协程
suspend和Flow,跟现代 Android 开发无缝衔接。
官方明确推荐用 Room 替代直接操作 SQLite API,所以新项目几乎都选 Room。
三大件:Entity、DAO、Database
Room 就三个核心概念,记住这三个你就能上手:
- Entity(实体):一个
data class对应一张表,类里的属性就是列。 - DAO(数据访问对象):一个
interface,里面定义增删改查方法,方法上贴注解或写 SQL。 - Database(数据库):一个
abstract class,继承RoomDatabase,负责把 Entity 和 DAO 串起来。
下面我们就用一个「记事本」场景:存笔记(id、标题、内容、时间)来走一遍流程。
第一步:加依赖
打开 app/build.gradle.kts,加上 Room 依赖。Room 用 KSP(Kotlin Symbol Processing)做注解处理,比老的 kapt 快很多。
plugins {
id("com.google.devtools.ksp")
}
dependencies {
val roomVersion = "2.8.4"
implementation("androidx.room:room-runtime:$roomVersion")
implementation("androidx.room:room-ktx:$roomVersion") // 协程支持
ksp("androidx.room:room-compiler:$roomVersion")
}
NoteKSP 插件需要在项目级
build.gradle.kts或libs.versions.toml里声明,新版 Android Studio 模板默认就带。如果没装,在plugins {}里加id("com.google.devtools.ksp") version "x.x.x"。
Sync 一下,依赖就绪。
第二步:定义 Entity
一个笔记类,加 @Entity 注解,它就是一张表。
@Entity(tableName = "notes")
data class Note(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
@ColumnInfo(name = "title")
val title: String,
@ColumnInfo(name = "content")
val content: String,
@ColumnInfo(name = "created_at")
val createdAt: Long = System.currentTimeMillis()
)
几个要点:
tableName指定表名,不写默认用类名。@PrimaryKey标主键,autoGenerate = true表示自增。@ColumnInfo指定列名,不写默认用属性名。Kotlin 是驼峰,数据库习惯下划线,加这个注解更清晰。
Tip主键用
Long比Int安全,自增到上限的概率几乎为零。空表插入后用返回值能拿到新 id。
第三步:写 DAO
DAO 是个 interface,方法上贴注解告诉 Room 你要干啥。
@Dao
interface NoteDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(note: Note): Long
@Update
suspend fun update(note: Note)
@Delete
suspend fun delete(note: Note)
@Query("SELECT * FROM notes ORDER BY created_at DESC")
fun getAll(): Flow<List<Note>>
@Query("SELECT * FROM notes WHERE id = :id")
suspend fun getById(id: Long): Note?
@Query("SELECT * FROM notes WHERE title LIKE '%' || :keyword || '%'")
fun search(keyword: String): Flow<List<Note>>
}
看几个细节:
@Insert、@Update、@Delete不用写 SQL,传实体进去就行。OnConflictStrategy.REPLACE表示主键冲突就覆盖。@Query写原生 SQL,参数用:参数名绑定。编译期会校验表名、列名对不对,写错了直接红。suspend让方法能在协程里调用,自动切到 IO 线程。- 返回
Flow<List<Note>>是个杀手锏——表里数据一变,Flow 自动推新数据,UI 跟着重绘,不用手动刷新。
Warning
@Insert返回Long拿到新 id 这种写法很常用,但如果你传的是一个List,返回值就是List<Long>,类型要对上。
第四步:建 Database
数据库类是单例,整个应用一份。
@Database(entities = [Note::class], version = 1, exportSchema = false)
abstract class AppDatabase : RoomDatabase() {
abstract fun noteDao(): NoteDao
companion object {
@Volatile
private var INSTANCE: AppDatabase? = null
fun getInstance(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
INSTANCE ?: Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"app.db"
).build().also { INSTANCE = it }
}
}
}
}
要点:
entities列出所有表,version是数据库版本号(迁移要用到)。exportSchema = false关掉 schema 导出,正式项目建议开,方便追踪表结构变化。- 双检锁单例,
@Volatile防止多线程下重复创建。 - 数据库实例很贵,别每次操作都 new。
Tip单例参数用
context.applicationContext,避免 Activity 的 Context 把 Activity 拖住导致内存泄漏。
第五步:CRUD 实战
数据库类有了,在协程里就能用了。
class NoteRepository(private val dao: NoteDao) {
suspend fun addNote(title: String, content: String): Long {
return dao.insert(Note(title = title, content = content))
}
suspend fun updateNote(note: Note) {
dao.update(note)
}
suspend fun deleteNote(note: Note) {
dao.delete(note)
}
fun observeAll(): Flow<List<Note>> = dao.getAll()
fun search(keyword: String): Flow<List<Note>> = dao.search(keyword)
}
在 ViewModel 或 Compose 里用:
val scope = rememberCoroutineScope()
val db = AppDatabase.getInstance(context)
val dao = db.noteDao()
val repo = NoteRepository(dao)
// 插入
scope.launch {
repo.addNote("买菜", "西红柿、鸡蛋、牛肉")
}
// 观察
val notes by repo.observeAll().collectAsState(initial = emptyList())
LazyColumn {
items(notes) { note ->
Text("${note.title} - ${note.content}")
}
}
数据一插入,列表立马刷新,全程不用手动 notifyDataSetChanged。这就是 Flow 的魔力。
配合协程和 Flow
Room 对协程支持非常到位:
suspend方法:一次性操作,比如插入、更新、按 id 查询。Flow<T>方法:观察数据变化,表里数据一改,Flow 自动发新值。LiveData<T>方法:传统写法,能用但官方现在更推荐 Flow。
// 一次性
@Query("SELECT COUNT(*) FROM notes")
suspend fun count(): Int
// 观察单个值
@Query("SELECT * FROM notes WHERE id = :id")
fun observeById(id: Long): Flow<Note?>
NoteFlow 是「冷流」,要有观察者才会真正查询。在 Compose 里用
collectAsState()自动管理订阅生命周期,Composable 一离开就自动取消,不会泄漏。
关系查询
实际项目里表和表之间往往有关系。Room 支持一对一、一对多、多对多。
最常用的是 @Relation 配合 @Embedded。比如一篇笔记有多个标签:
@Entity(tableName = "tags")
data class Tag(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val name: String,
val noteId: Long // 外键关联 Note.id
)
data class NoteWithTags(
@Embedded val note: Note,
@Relation(
parentColumn = "id",
entityColumn = "noteId"
)
val tags: List<Tag>
)
DAO 里这样查:
@Transaction
@Query("SELECT * FROM notes")
fun getNotesWithTags(): Flow<List<NoteWithTags>>
@Transaction 不能少——Room 要在一个事务里查主表再查关联表,否则会多次查库性能差。
Warning
@Relation只能用来「读」,不能用来「写」。插入时要分别 insert Note 和 Tag,不能直接 insert NoteWithTags。
数据库迁移
应用上线后表结构要改怎么办?比如给 notes 加个 color 字段。这时候就要迁移。
第一步,改 Entity:
@Entity(tableName = "notes")
data class Note(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val title: String,
val content: String,
val createdAt: Long = System.currentTimeMillis(),
val color: Int = 0xFFFFFF // 新加的字段
)
第二步,version 加 1,写 Migration:
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE notes ADD COLUMN color INTEGER NOT NULL DEFAULT 16777215")
}
}
第三步,注册到 builder:
Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"app.db"
)
.addMigrations(MIGRATION_1_2)
.build()
Tip开发期可以用
.fallbackToDestructiveMigration()让迁移失败时直接删库重建,省事但会丢数据,正式版千万别用。
常见坑
我踩过几个,提醒你别再踩:
- 主线程操作数据库会崩。Room 默认禁止主线程读写,要么
suspend,要么手动allowMainThreadQueries()(不推荐)。 - Flow 不收集不触发。忘了
collectAsState就看不到数据,新手常以为没插进去。 @Relation写入要拆开。一次 insert 关联对象会报错。- 迁移和 Entity 不一致会崩。改了字段忘了加 Migration,或者 SQL 里类型写错,运行时直接
IllegalStateException。 - schema 导出。开了
exportSchema = true要在build.gradle配room.schemaLocation,否则编译报错。
小结
Room 把 SQLite 包了一层优雅的外壳,三个注解 @Entity / @Dao / @Database 撑起整个体系。配合协程的 suspend 和 Flow,异步读写和数据观察一行代码搞定。表结构变了用 Migration 平滑升级,老数据不丢。
下一章开始我们进入网络编程,先搞清楚 Android 联网的基本规矩,再用 Retrofit 把后端接口拉过来。