Room数据库(上):架构、注解与响应式查询

各位同学,今天我们来聊聊Room。说实话,Room是我在Android开发中最喜欢的库之一。它不像某些框架那样「过度设计」,也不像裸写SQLite那样原始。它恰到好处地解决了本地持久化的问题。

我最早接触Room是在2017年,那时候项目里还在用GreenDAO。说实话,每次写实体类都要生成一堆代码,烦得很。后来Google推出了Room,我试用了一下,嗯,真香。尤其是编译时校验SQL语法这个特性,帮我省了不少线上bug。

1. Room的整体架构

Room本质上是对SQLite的抽象封装。它分为三层:

  • Entity(实体层):对应数据库中的表
  • DAO(数据访问层):定义增删改查操作
  • Database(数据库层):持有数据库连接,提供DAO实例

这三层的关系,我画了张图,你一看就明白:

Room 三层架构 Entity @Entity 注解的类 对应数据库表结构 DAO @Dao 注解的接口 定义SQL操作 Database @Database 注解的抽象类 持有数据库连接 数据流向: App → Database.getInstance() → DAO → Entity → SQLite 查询结果:SQLite → Entity → DAO (返回LiveData/Flow) → UI 注意:Room 自动处理 Entity 与 SQLite 表之间的映射

你看,这个架构其实很清晰。App层只需要跟Database打交道,拿到DAO之后直接操作,完全不用关心底层SQLite的细节。我个人觉得,这种分层设计最大的好处是——测试起来特别方便。你可以轻松mock DAO,写单元测试。

2. 核心注解详解

2.1 @Entity — 定义表结构

每个@Entity注解的类,对应数据库中的一张表。我见过不少新手把Entity写得特别复杂,其实没必要。来看一个典型的例子:

@Entity(tableName = "users")
data class User(
    @PrimaryKey(autoGenerate = true)
    val id: Long = 0,
    
    @ColumnInfo(name = "user_name")
    val name: String,
    
    @ColumnInfo(name = "age")
    val age: Int,
    
    @ColumnInfo(name = "created_at")
    val createdAt: Long = System.currentTimeMillis()
)

这里有几个要点:

  • tableName:指定表名,不写的话默认用类名
  • @PrimaryKey:主键,autoGenerate = true 表示自增
  • @ColumnInfo:指定列名,不写的话默认用字段名
💡 我的习惯:表名用复数(users),列名用下划线命名法(user_name)。这样跟后端数据库风格保持一致,DBA看了也不会吐槽。

2.2 @Dao — 数据访问对象

DAO是接口或者抽象类。你只需要定义方法,Room会在编译时自动生成实现代码。我刚开始用的时候还担心性能问题,后来看了生成的代码,嗯,Room生成的SQLite操作代码非常高效。

@Dao
interface UserDao {
    @Query("SELECT * FROM users")
    fun getAllUsers(): List<User>
    
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun insertUser(user: User)
    
    @Update
    fun updateUser(user: User)
    
    @Delete
    fun deleteUser(user: User)
}

注意看@Insert里的onConflict = OnConflictStrategy.REPLACE。这个参数很实用——当主键冲突时,是替换还是忽略?我曾经在项目中遇到过一个问题:用户重复注册时,如果不加这个参数,会直接崩溃。后来我统一加上了REPLACE策略,世界清净了。

2.3 @Database — 数据库入口

Database是一个抽象类,需要继承RoomDatabase。它负责创建数据库实例,并提供DAO的访问方法。

@Database(entities = [User::class], version = 1, exportSchema = false)
abstract class AppDatabase : RoomDatabase() {
    abstract fun userDao(): UserDao
    
    companion object {
        @Volatile
        private var INSTANCE: AppDatabase? = null
        
        fun getInstance(context: Context): AppDatabase {
            return INSTANCE ?: synchronized(this) {
                val instance = Room.databaseBuilder(
                    context.applicationContext,
                    AppDatabase::class.java,
                    "app_database.db"
                ).build()
                INSTANCE = instance
                instance
            }
        }
    }
}
⚠️ 注意:这里用了单例模式,并且加上了@Volatilesynchronized。为什么?因为数据库实例应该是全局唯一的,否则会创建多个连接,导致数据不一致。我见过有人忘了加同步,结果在并发场景下数据库被重复创建,数据全乱了。

3. 数据库迁移策略

App迭代过程中,数据库表结构肯定会变。比如加个字段、改个类型什么的。Room提供了两种迁移方式:

3.1 自动迁移(推荐)

从Room 2.4.0开始,支持自动迁移。你只需要在@Database注解里声明:

@Database(
    entities = [User::class],
    version = 2,
    autoMigrations = [
        AutoMigration(from = 1, to = 2)
    ]
)
abstract class AppDatabase : RoomDatabase() { ... }

但自动迁移只支持简单的场景,比如新增字段、删除字段。如果涉及到表的重构,还是得手动写。

3.2 手动迁移

手动迁移需要实现Migration类:

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

// 在构建数据库时添加
Room.databaseBuilder(context, AppDatabase::class.java, "app_database.db")
    .addMigrations(MIGRATION_1_2)
    .build()
💡 避坑指南:我曾经在迁移时忘记处理旧数据,结果用户升级后数据全丢了。后来我养成了一个习惯——每次写迁移脚本前,先备份数据库。你可以用.fallbackToDestructiveMigration()作为兜底策略,但生产环境慎用。

4. TypeConverter — 处理复杂类型

Room默认只支持基本类型和String。如果你要存Date、List、Map这些复杂类型,就得用TypeConverter。

举个例子,存一个日期:

class Converters {
    @TypeConverter
    fun fromTimestamp(value: Long?): Date? {
        return value?.let { Date(it) }
    }
    
    @TypeConverter
    fun dateToTimestamp(date: Date?): Long? {
        return date?.time
    }
}

// 在Database上注册
@Database(entities = [User::class], version = 1, exportSchema = false)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() { ... }

你想想看,如果没有TypeConverter,每次存Date都得手动转Long,取出来再转回来,多麻烦。Room帮你自动完成了这个转换。

我项目中还遇到过存List的场景。比如用户的标签:

class StringListConverter {
    @TypeConverter
    fun fromStringList(value: List<String>?): String? {
        return value?.joinToString(",")
    }
    
    @TypeConverter
    fun toStringList(value: String?): List<String>? {
        return value?.split(",")?.map { it.trim() }
    }
}
⚠️ 注意:用逗号分隔List有个坑——如果字符串本身包含逗号,就会解析错误。建议用JSON序列化,或者用Gson库来处理。我一般用Gson,稳定可靠。

5. 响应式查询:Flow与LiveData

这是Room最强大的特性之一。你不需要手动刷新数据,Room会在数据库发生变化时自动通知UI。

5.1 使用LiveData

@Dao
interface UserDao {
    @Query("SELECT * FROM users")
    fun getAllUsersLiveData(): LiveData<List<User>>
}

在ViewModel里观察:

class UserViewModel(application: Application) : AndroidViewModel(application) {
    private val userDao = AppDatabase.getInstance(application).userDao()
    val allUsers: LiveData<List<User>> = userDao.getAllUsersLiveData()
}

5.2 使用Flow(推荐)

如果你用Kotlin协程,Flow是更好的选择。它天然支持协程,而且生命周期更可控。

@Dao
interface UserDao {
    @Query("SELECT * FROM users")
    fun getAllUsersFlow(): Flow<List<User>>
}

在ViewModel里:

class UserViewModel(application: Application) : AndroidViewModel(application) {
    private val userDao = AppDatabase.getInstance(application).userDao()
    val allUsers: StateFlow<List<User>> = userDao.getAllUsersFlow()
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
}

为什么我推荐Flow?说白了,LiveData在非UI层使用有点别扭,而且不支持背压。Flow跟协程配合得天衣无缝,你可以在数据流上做各种变换,比如map、filter、combine,非常灵活。

💡 我的经验:在UI层用LiveData,在ViewModel层用Flow。这样既保证了UI的简洁性,又享受了Flow的强大能力。当然,如果你项目全用Flow,那也没问题。

6. 总结

Room的核心思想其实很简单:用注解定义结构,用DAO封装操作,用Database管理连接。再加上TypeConverter处理复杂类型,Flow/LiveData实现响应式查询,一套完整的本地持久化方案就出来了。

嗯,这一章的内容就到这里。记住,Room的编译时校验是你最好的朋友——写错SQL?编译直接报错,不会等到运行时才崩溃。这个特性,我用过的都说好。


公众号:蓝海资料掘金营,微信deep3321