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可以自定义列名,但建议和字段名保持一致
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,避免重复数据报错
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()
}
}
}
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的设计哲学就是让本地数据持久化变得简单可靠。你想想看,以前用SQLite的时候,写个CRUD要多少样板代码?现在用Room,一个注解就搞定了。
好了,这一章的内容就到这里。记住一句话:数据层稳了,上层才能安心。下一章我们聊聊网络层,看看Retrofit和OkHttp怎么和Room配合,打造完整的数据通道。
公众号:蓝海资料掘金营,微信deep3321