2、Room核心组件:Entity、DAO、Database的概念与职责

Room 说白了就是一个在 Android 上帮你管理本地数据库的框架。它把 SQLite 的繁琐操作包了一层,让你用注解就能定义表、写查询。

但光知道怎么用不够——你得先理解它的三大核心组件:Entity、DAO、Database。这三个东西各管一摊,缺一个 Room 就跑不起来。

Room 核心组件架构图 @Database 数据库入口,管理版本与 DAO @Dao 封装 CRUD 操作,编译时生成实现 @Entity 定义表结构,每个字段映射到列 引 用 产物与位置 • 编译生成 Database_Impl.java • 编译生成 DAO_Impl.java • Entity 编译为普通数据类 • Mapper 文件可通过 Migration 管理 • 运行时通过 Room.databaseBuilder 创建 典型关系 • 1个 Database 可以有多个 DAO • 1个 DAO 操作多个 Entity • Entity 可通过外键关联其他 Entity • 推荐每个实体一个 DAO 文件 • DAO 方法可返回 Flow / LiveData

2.1 Entity:它就是一张表

Entity 是 Room 里最基础的数据模型。你定义一个数据类,加上 @Entity 注解,Room 就会为它在 SQLite 里创建一张表。每个字段(非静态、非 transient)默认对应一列。

@Entity(tableName = "users")
data class User(
    @PrimaryKey val id: Int,
    val name: String,
    val age: Int,
    @ColumnInfo(name = "email_address") val email: String?
)

说几个我踩过的坑:

  • 主键必须有。你不写 @PrimaryKey 编译就报错。如果需要复合主键,用 @PrimaryKeys({"col1", "col2"})。
  • 字段默认必须可空,或者给默认值。否则插入时 Room 会抱怨列不能为 null。
  • 我习惯给每个表加一个 createdAt 和 updatedAt 字段,用 @ColumnInfo(defaultValue = "CURRENT_TIMESTAMP") 自动填充。

💡 提示:如果字段名和列名不一致,务必用 @ColumnInfo(name = "...") 映射。我见过有人直接改了 Kotlin 属性名没加注解,结果数据库里旧数据全丢了。

2.2 DAO:你的 SQL 管家

DAO(Data Access Object)把所有数据库操作封装起来。你定义一个接口,在方法上写 @Query、@Insert、@Update、@Delete,Room 编译时自动生成实现代码。

@Dao
interface UserDao {
    @Insert
    suspend fun insertUser(user: User)

    @Update
    suspend fun updateUser(user: User)

    @Delete
    suspend fun deleteUser(user: User)

    @Query("SELECT * FROM users WHERE id = :userId")
    suspend fun getUserById(userId: Int): User?

    @Query("SELECT * FROM users ORDER BY name ASC")
    fun getAllUsers(): Flow<List<User>>
}

注意几点:

  • 尽量用挂起函数(suspend)或返回 Flow/LiveData。Room 会自动在后台线程执行。
  • @Query 里的 SQL 语句编译时就会检查——表名、字段名写错了直接报错。这是个强大的提前发现问题的手段。
  • 我以前在一个项目里把所有查询挤在一个 DAO 里,后来改需求时找个方法要找半天。后来我养成习惯:每个 Entity 单独一个 DAO 文件。

⭐ 重点:DAO 方法的返回值如果是 Flow,Room 会在表数据变化时自动重新发送最新数据——这也是 Room 和 Compose 状态同步的基础。

2.3 Database:整个数据库的大门

Database 是一个抽象类,继承自 RoomDatabase。它通过注解声明所有 Entity 和 DAO,并提供一个单例方法供应用获取实例。

@Database(
    entities = [User::class, Address::class],
    version = 1,
    exportSchema = true
)
abstract class AppDatabase : RoomDatabase() {
    abstract fun userDao(): UserDao
    abstract fun addressDao(): AddressDao

    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.db"
                ).build()
                INSTANCE = instance
                instance
            }
        }
    }
}

这里有几个关键点:

  • entities 列表必须包含所有使用到的 Entity 类,漏一个编译报错。
  • version 号:每次改表结构(增删字段、改约束)都必须升版本,并配合 Migration 或 fallbackToDestructiveMigration。
  • exportSchema = true:会在 build 目录生成一个 JSON 文件记录历史版本 schema,方便迁移调试。我建议线上版本打开,开发阶段随意。
  • 数据库实例应该是单例,避免多次创建导致资源浪费。我一般用双重检查锁定(就像上面那样)。

⚠️ 警告:千万不要在 getDatabase() 里传 Activity 或 Fragment 的 context——这会导致内存泄漏。请使用 applicationContext。

2.4 三个组件如何协同工作

看下面的流程就清楚了:

  1. 你调用 AppDatabase.getDatabase(context).userDao().getAllUsers()
  2. Room 拿到 Database 实例,再拿到 UserDao 的实现(编译生成的对象)。
  3. DAO 执行 SQL 查询,把结果集的每一行映射回 User Entity 对象。
  4. 如果返回的是 Flow,Room 还会监听 users 表的变化,一旦有 insert/update/delete 就重新发射数据。

说白了,Entity 定义形状,DAO 定义操作,Database 定义环境。三者缺一不可。

📌 个人习惯:我通常在一个模块(比如 :data)里放一个 database 包,里面按功能分三个子包:entity、dao、database,再加一个 migration 包放版本迁移脚本。这样结构清晰,新同事一看就懂。

2.5 关于代码生成的细节

Room 在编译时做了很多事。我遇到过一个问题:我改了 Entity 的字段名,但忘了改 DAO 里的 @Query 列名,结果运行时崩溃——因为编译时 Room 只检查了 Entity 注解,没检查 SQL 字符串里的列名(直到运行时才发现)。

怎么避免?写单元测试!Room 官方提供了 Room.inMemoryDatabaseBuilder 来创建内存数据库,你可以直接在测试里跑 DAO 方法验证。

@RunWith(AndroidJUnit4::class)
class UserDaoTest {
    private lateinit var db: AppDatabase
    private lateinit var dao: UserDao

    @Before
    fun createDb() {
        db = Room.inMemoryDatabaseBuilder(
            ApplicationProvider.getApplicationContext(),
            AppDatabase::class.java
        ).build()
        dao = db.userDao()
    }

    @After
    fun closeDb() = db.close()

    @Test
    fun insertAndQuery() = runTest {
        val user = User(1, "Alice", 30, "alice@example.com")
        dao.insertUser(user)
        val result = dao.getUserById(1)
        assertEquals(user, result)
    }
}

嗯,这里要特别注意:数据库迁移是 Room 最容易被忽略的坑。版本升级时如果没写 Migration,Room 会直接抛 IllegalStateException。我建议从第一个版本就把 exportSchema = true 打开,然后每次改版都写一个 Migration 类。

组件注解/基类核心职责编译时产物
Entity@Entity定义表结构、列、主键、索引普通数据类(无特殊)
DAO@Dao封装 CRUD + 自定义查询DAO_Impl 实现类
DatabaseRoomDatabase管理连接、版本、DAO 获取Database_Impl 实现类

最后再啰嗦一句:Room 和 SQLiteOpenHelper 的区别就在于它帮你做了大量代码生成和编译时检查。我用原生的 SQLiteOpenHelper 写过几个项目,每次改表结构都要手动改 SQL 和 Cursor 映射,还容易出 bug。换了 Room 之后,幸福感提升不少——当然,前提是你得把 Entity、DAO、Database 这三件套理解透。