7、本地数据持久化:Room数据库的集成与DAO设计

本地数据持久化,说白了就是让App在没网的时候也能干活。我见过太多项目,网络一断就白屏,用户体验极差。Room作为Google官方推荐的ORM框架,本质上是对SQLite的封装,但用起来舒服太多了。

今天我们就来聊聊Room的集成和DAO设计。这部分内容,我个人觉得是MVVM架构里最实在的一环——数据层稳了,上层才能安心。

7.1 Room的核心组件

Room由三个核心部分组成:

  • Entity:数据表对应的实体类
  • DAO:数据访问对象,定义增删改查操作
  • Database:数据库持有者,连接Entity和DAO

嗯,这里要注意:Entity和DAO是一对多的关系。一个Entity可以对应多个DAO,但一个DAO通常只操作一个Entity。我在项目中遇到过有人把十几个表塞进一个DAO里,结果维护起来简直噩梦。

7.2 集成Room到项目

首先,在build.gradle里添加依赖:

// 项目级 build.gradle
plugins {
    id 'com.google.devtools.ksp' version '1.9.0-1.0.13' apply false
}

// 模块级 build.gradle
plugins {
    id 'com.google.devtools.ksp'
}

dependencies {
    def room_version = "2.6.1"
    implementation "androidx.room:room-runtime:$room_version"
    implementation "androidx.room:room-ktx:$room_version"
    ksp "androidx.room:room-compiler:$room_version"
}

为什么用KSP而不是KAPT?因为KSP编译速度更快,尤其是大型项目。我去年把一个模块从KAPT切到KSP,编译时间直接少了40%。

7.3 定义Entity

来看一个用户表的例子:

@Entity(tableName = "users")
data class UserEntity(
    @PrimaryKey(autoGenerate = true)
    val id: Long = 0,
    @ColumnInfo(name = "user_name")
    val userName: String,
    @ColumnInfo(name = "phone")
    val phone: String?,
    @ColumnInfo(name = "create_time")
    val createTime: Long = System.currentTimeMillis()
)

几个要点:

  • 主键用Long类型,配合autoGenerate,省心
  • 可空字段用?标记,Room会自动处理SQLite的NULL
  • @ColumnInfo可以自定义列名,但建议和字段名保持一致
注意:Entity类不要用data class的copy方法去修改主键字段,Room会报错。我踩过这个坑,排查了半天才发现是copy导致主键冲突。

7.4 DAO设计原则

DAO是数据层的核心。我个人习惯把DAO设计成接口,而不是抽象类。为什么?因为接口更灵活,配合Kotlin的默认实现,写起来很舒服。

@Dao
interface UserDao {
    @Query("SELECT * FROM users ORDER BY create_time DESC")
    fun getAllUsers(): Flow<List<UserEntity>>

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

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertUser(user: UserEntity): Long

    @Update
    suspend fun updateUser(user: UserEntity)

    @Delete
    suspend fun deleteUser(user: UserEntity)
}

这里有几个设计要点:

  • 查询返回Flow,这样数据变化时UI会自动更新
  • 写操作用suspend函数,确保在协程中执行
  • 插入用onConflict = REPLACE,避免重复数据报错
小技巧:如果业务需要批量插入,用@Insert注解的vararg参数或者集合类型,Room会自动开启事务,比循环插入快10倍以上。

7.5 复杂查询与事务

实际项目中,查询往往不是简单的单表操作。来看一个联表查询的例子:

@Dao
interface OrderDao {
    @Transaction
    @Query("SELECT * FROM orders WHERE user_id = :userId")
    fun getOrdersWithItems(userId: Long): Flow<List<OrderWithItems>>
}

data class OrderWithItems(
    @Embedded val order: OrderEntity,
    @Relation(
        parentColumn = "id",
        entityColumn = "order_id"
    )
    val items: List<OrderItemEntity>
)

@Transaction注解保证了联表查询的一致性。我记得有一次线上问题,就是因为没加@Transaction,导致查询结果里订单和明细对不上。

7.6 数据库升级策略

App迭代过程中,数据库结构肯定会变。Room提供了两种升级方式:

方式 适用场景 缺点
fallbackToDestructiveMigration 开发阶段、数据不重要 会丢失所有数据
Migration对象 生产环境、数据重要 需要写SQL迁移脚本

生产环境我强烈建议用Migration对象。写起来虽然麻烦点,但数据安全第一。来看一个例子:

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

val MIGRATION_2_3 = object : Migration(2, 3) {
    override fun migrate(database: SupportSQLiteDatabase) {
        database.execSQL(
            "CREATE TABLE IF NOT EXISTS `addresses` (" +
            "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
            "`user_id` INTEGER NOT NULL, " +
            "`address` TEXT NOT NULL)"
        )
    }
}

@Database(
    entities = [UserEntity::class, AddressEntity::class],
    version = 3,
    exportSchema = true
)
abstract class AppDatabase : RoomDatabase() {
    abstract fun userDao(): UserDao
    abstract fun addressDao(): AddressDao

    companion object {
        fun getInstance(context: Context): AppDatabase {
            return Room.databaseBuilder(
                context.applicationContext,
                AppDatabase::class.java,
                "app_database.db"
            )
            .addMigrations(MIGRATION_1_2, MIGRATION_2_3)
            .build()
        }
    }
}
核心要点:exportSchema设为true,Room会生成schema文件,方便追踪数据库版本变化。这个文件要提交到Git,团队协作时特别有用。

7.7 测试DAO

数据层一定要写测试。Room官方提供了测试支持库:

@RunWith(AndroidJUnit4::class)
class UserDaoTest {
    private lateinit var database: AppDatabase
    private lateinit var userDao: UserDao

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

    @After
    fun teardown() {
        database.close()
    }

    @Test
    fun testInsertAndQuery() = runTest {
        val user = UserEntity(userName = "张三", phone = "13800138000")
        val userId = userDao.insertUser(user)
        val queriedUser = userDao.getUserById(userId)
        assertEquals("张三", queriedUser?.userName)
    }
}

用inMemoryDatabaseBuilder创建内存数据库,测试速度快,而且每次测试完自动销毁,互不影响。

7.8 避坑指南

最后分享几个我踩过的坑:

  • 不要在DAO里做耗时操作:Room虽然做了优化,但复杂查询还是可能卡UI。用Flow或者LiveData异步获取结果。
  • Entity字段不要用基本类型:比如int、long,要用包装类Integer、Long。否则数据库里为NULL时会直接崩溃。
  • 数据库版本号要递增:不能跳版本,比如从1直接升到3,中间缺了2的迁移脚本,Room会抛异常。

我曾经在一个项目里,因为Entity里用了int类型,结果某个用户的年龄字段在数据库里是NULL,App直接闪退。从那以后,我所有Entity字段都用包装类。

7.9 知识体系总览

下面这张图总结了Room的核心知识结构:

Room数据库知识体系 Entity DAO Database Entity 要点 • @Entity 注解 • @PrimaryKey 主键 • @ColumnInfo 列名 DAO 操作类型 • @Insert 插入 • @Update 更新 • @Delete 删除 • @Query 查询 Database 配置 • 版本管理 • Migration 迁移 • 单例模式 • 测试支持 核心设计原则 DAO返回Flow实现响应式更新 | 写操作用suspend确保异步 Entity字段用包装类 | 生产环境必须写Migration脚本

Room的设计哲学就是让本地数据持久化变得简单可靠。你想想看,以前用SQLite的时候,写个CRUD要多少样板代码?现在用Room,一个注解就搞定了。

好了,这一章的内容就到这里。记住一句话:数据层稳了,上层才能安心。下一章我们聊聊网络层,看看Retrofit和OkHttp怎么和Room配合,打造完整的数据通道。


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