Room 数据库基础:引入、Entity 定义、数据库创建与初始化

做离线优先的 App,本地持久化是躲不开的一环。你想想看,网络一断,用户打开 App 什么都看不到,体验直接就崩了。我早年用 SQLite 写过不少本地存储,那感觉就像是自己动手搭积木,拼完了还得自己操心崩溃、线程、类型转换……每次都累得够呛。后来 Room 出来,我才算真正解放了。

说白了,Room 就是 Google 在 SQLite 外面包了一层类型安全的 ORM 框架。它帮你把 SQL 映射成 Kotlin 对象,编译时就能检查 SQL 语法错误,还能自动处理线程切换和 LiveData/Flow 的观察。嗯,这三章我们就把它撸清楚。

为什么选择 Room?

你可能要问:直接用 SQLiteOpenHelper 有什么不好?我的回答是:能省事干嘛不省。Room 带来的好处非常实在:

  • 编译时验证:SQL 写错了,编译期报错,不用等到运行时崩溃。
  • 与 Kotlin 协程、Flow 天然整合:不用自己搞 AsyncTask 或 Handler,直接返回 Flow 就能做响应式观察。
  • 自动迁移:写个 Migration 类,Room 帮你执行 ALTER TABLE,不用手写一堆升级脚本。
  • 减少样板代码:Entity 写好后,DAO 只用写接口,Room 自动生成实现。

我个人习惯在大型项目里必用 Room,小型工具类 App 反而会酌情考虑直接 SQLite。但如果你要看的是离线优先架构,那 Room 几乎是标准答案。

另外,Room 背后是 Jetpack 的一员,官方长期维护,社区成熟度也很高。你想想看,踩过的坑大多都有人替你趟过了。

添加 Room 依赖

首先在 build.gradle.kts(或 .gradle)里加上 KSP 插件和 Room 库。我用的是 KSP 而不是 kapt,因为 KSP 速度更快,而且 Room 官方已经优先推荐 KSP。

// 项目级 build.gradle.kts
plugins {
    id("com.google.devtools.ksp") version "2.0.21-1.0.28" apply false
}

// 模块级 build.gradle.kts
plugins {
    id("com.google.devtools.ksp")
}

dependencies {
    val roomVersion = "2.6.1"

    implementation("androidx.room:room-runtime:$roomVersion")
    annotationProcessor("androidx.room:room-compiler:$roomVersion")
    // 如果用 Kotlin,用 KSP:
    ksp("androidx.room:room-compiler:$roomVersion")
    
    // 协程支持(可选但推荐)
    implementation("androidx.room:room-ktx:$roomVersion")
}

注意:room-compiler 必须用 ksp 或 kapt 声明,否则编译时提示找不到生成代码。我曾经遇到一次忘记加,结果 DAO 实现类全给我报红色,浪费了半天排查。

定义 Entity:一张用户表

Entity 就是数据库表的映射类。每个属性对应一列,用注解告诉 Room 表名、主键、列名等。来看一个最简单的 User Entity:

import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity(tableName = "users")
data class User(
    @PrimaryKey(autoGenerate = true)
    val id: Long = 0,
    val name: String,
    val email: String,
    val age: Int
)

这里有几个关键点:

  • @Entity(tableName = "users") – 表名叫 users,不写默认用类名 User。
  • @PrimaryKey(autoGenerate = true) – 主键自增,插入时传 0,Room 自动分配。
  • 其他普通属性默认就用字段名作列名,比如 name 列对应 name 字段。
  • 如果你需要自定义列名,加 @ColumnInfo(name = "user_name") 之类。

我在项目中遇到过一个坑:千万不要把 data class 的主构造函数参数设成可变类型 var 并期望 Room 自动更新。Room 的 Entity 属性应该是不可变的(用 val),更新时通过 DAO 执行 SQL 来改,而不是直接改对象字段。否则容易引发数据不一致。

创建 DAO(数据访问对象)

DAO 用接口定义,上面写 SQL。Room 编译时生成实现。这里先给个基础版:

import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query

@Dao
interface UserDao {

    @Insert
    suspend fun insert(user: User)

    @Query("SELECT * FROM users ORDER BY id DESC")
    fun getAllUsers(): kotlinx.coroutines.flow.Flow<List<User>>

    @Query("DELETE FROM users")
    suspend fun deleteAll()
}

你想想看,直接写 SQL 字符串确实有点不舒服,但 Room 编译时会检查语法,所以其实挺安全。我个人建议把所有查询语句都放 DAO 里,不要散落在 ViewModel 或 Repository 中,方便维护。

创建 Database 类

这是入口。需要继承 RoomDatabase,声明 Entity 列表、版本号,并暴露 DAO 的抽象方法。

import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import android.content.Context

@Database(
    entities = [User::class],
    version = 1,
    exportSchema = true
)
abstract class AppDatabase : RoomDatabase() {

    abstract fun userDao(): UserDao

    companion object {
        @Volatile
        private var INSTANCE: AppDatabase? = null

        fun getDatabase(context: Context): AppDatabase {
            return INSTANCE ?: synchronized(this) {
                val instance = Room.databaseBuilder(
                    context.applicationContext,
                    AppDatabase::class.java,
                    "app_database"
                ).build()
                INSTANCE = instance
                instance
            }
        }
    }
}

这里几个关键点:

  • @Database 注解里的 entities 写所有 Entity 的 KClass,有几个写几个。
  • version 初始为 1,以后升级要加 Migration。
  • exportSchema = true 会生成 Schema 文件用于版本对比,建议开启。
  • 单例模式用 synchronized 和 @Volatile 保证线程安全。
  • 用 context.applicationContext 避免内存泄漏。

我曾经在一个多模块项目里忘了加 synchronized,结果并发请求时创建了两个实例,导致数据错乱。排查起来非常痛苦。所以单例的写法一定按官方模板走。

初始化时机:在 Application 中创建

为了让整个 App 都能访问数据库,一般是在 Application.onCreate() 里初始化,然后通过依赖注入(如 Hilt)或全局单例获取。如果你不想引入 DI 框架,可以像上面那样写一个静态方法,在 Activity 或 ViewModel 里调用。

不过要注意 Room 的初始化是同步的,如果你在主线程调用 getDatabase(),可能会造成 ANR。Room 默认不允许在主线程操作数据库(除非你调用 .allowMainThreadQueries(),但千万别在生产环境这么做)。所以建议在 onCreate 里用协程异步初始化,或者利用 Hilt 的 @Singleton 懒加载。

最佳实践:在 Application 里用一个协程 Scope 去初始化 Room,同时把初始化结果暴露为 Deferred,其他组件等着就行。这样既不会卡主线程,又保证了数据库可用。

数据库版本管理与迁移

这个章节先简单提一下:当你修改 Entity 结构(比如新增字段),必须升级数据库版本,并编写 Migration 对象,否则 Room 会抛异常。例如:

val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(database: SupportSQLiteDatabase) {
        database.execSQL("ALTER TABLE users ADD COLUMN avatar TEXT DEFAULT NULL")
    }
}

然后在 .databaseBuilder() 里加上 .addMigrations(MIGRATION_1_2)。这后面章节会详细讲,先记住有这回事就好。

本章知识体系速览

Room 数据库核心知识结构 Room 数据库核心组件关系 Application / App AppDatabase UserDao (接口) User Entity OrderDao (接口) Order Entity 底层 SQLite 数据库引擎 箭头 = 依赖 / 生成关系

这张图清晰地展示了 Room 的层次:App 持有 Database 单例,Database 暴露 DAO 接口,DAO 操作 Entity,最终落地到 SQLite 文件。你想想看,每一层解耦都很干净。

Room 核心注解速查表

注解 作用 常用参数
@Entity标记数据类为表tableName, indices, foreignKeys
@PrimaryKey指定主键autoGenerate, entityId
@ColumnInfo自定义列名、类型name, typeAffinity
@Dao标记数据访问接口—
@Insert插入方法onConflict 策略
@Query自定义 SQL 查询SQL 语句字符串
@Database声明数据库类entities, version, exportSchema

现在你已经完成了 Room 的引入、Entity 定义、Database 创建与初始化。下一件事就是写 DAO 方法,然后在 ViewModel 里使用。但那是后面章节的内容,这里先巩固好基础。

我个人建议你动手新建一个 Android 项目,照着上面的代码敲一遍。遇到编译问题时,重点检查 KSP 是否配置正确、@Database 里的 entities 列表是否包含所有 Entity。等你跑通第一次查询,离线优先的根基就算打牢了。