首页 / Android 入门教程 / Room 数据库

Android 入门教程

Room 数据库

本教程共 100 篇 · 第 68 篇 · 更新于 2026-07-28 · 约 9 分钟阅读

AndroidAndroid 入门教程Room数据库SQLiteJetpack协程Flow

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)代替样板代码。
  • 天然支持协程 suspendFlow,跟现代 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")
}
Note

KSP 插件需要在项目级 build.gradle.ktslibs.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

主键用 LongInt 安全,自增到上限的概率几乎为零。空表插入后用返回值能拿到新 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?>
Note

Flow 是「冷流」,要有观察者才会真正查询。在 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() 让迁移失败时直接删库重建,省事但会丢数据,正式版千万别用。

常见坑

我踩过几个,提醒你别再踩:

  1. 主线程操作数据库会崩。Room 默认禁止主线程读写,要么 suspend,要么手动 allowMainThreadQueries()(不推荐)。
  2. Flow 不收集不触发。忘了 collectAsState 就看不到数据,新手常以为没插进去。
  3. @Relation 写入要拆开。一次 insert 关联对象会报错。
  4. 迁移和 Entity 不一致会崩。改了字段忘了加 Migration,或者 SQL 里类型写错,运行时直接 IllegalStateException
  5. schema 导出。开了 exportSchema = true 要在 build.gradleroom.schemaLocation,否则编译报错。

小结

Room 把 SQLite 包了一层优雅的外壳,三个注解 @Entity / @Dao / @Database 撑起整个体系。配合协程的 suspendFlow,异步读写和数据观察一行代码搞定。表结构变了用 Migration 平滑升级,老数据不丢。

下一章开始我们进入网络编程,先搞清楚 Android 联网的基本规矩,再用 Retrofit 把后端接口拉过来。