本地持久化是每个 Android 应用都绕不开的一层,而「直接用 SQLiteOpenHelper 手写 SQL」的时代早已过去。Room 作为 Jetpack 的官方 ORM,在编译期校验 SQL、把查询结果映射成对象、并原生支持协程与 Flow,几乎成了现代 Android 项目的默认选择。本文从配置到迁移、从并发到加密,把 Room 的完整用法和取舍一次讲清。
一句话总结: Room 的价值不在「少写 SQL」,而在编译期发现 SQL 错误、把数据库读写变成可组合的 Flow 流,以及让迁移成为一件可版本化、可测试的事。
一、Room 在持久化方案中的位置
Android 上常见的本地存储有几类:SharedPreferences、DataStore、SQLite(含 Room)、文件存储。它们不是互斥的,而是按数据形态分工。
| 方案 | 数据形态 | 类型安全 | 响应式 | 关系查询 | 适用场景 |
|---|---|---|---|---|---|
| SharedPreferences | 键值对 | 否 | 有限 | 无 | 少量配置,逐步被淘汰 |
| DataStore | 键值对 / Proto | 是 | 是(Flow) | 无 | 用户设置、开关、token |
| Room | 结构化表 | 是 | 是(Flow) | 强 | 列表、缓存、离线数据 |
| 文件 | 任意 | 否 | 否 | 无 | 图片、日志、导出 |
Room 是 SQLite 之上的一层抽象,本质上仍是 SQLite 文件,因此它继承了 SQLite 的事务、索引、WAL 等特性,同时用注解处理器在编译期生成实现类。它不负责「配置项」这类零散数据——那正是 DataStore 的领域,两者常常并存。
判断依据: 数据是否需要按条件查询、排序、分页、做关联?需要就用 Room;只是「存一个值、读一个值」,用 DataStore。
二、依赖配置与 KSP
2.1 用 KSP 替代 KAPT
老项目常见 kapt,但它要为每个注解处理器生成 Java 存根再编译,拖慢构建。KSP(Kotlin Symbol Processing)直接读 Kotlin 语法树,Room 2.6 起官方推荐 KSP,构建速度通常提升 2 倍以上。
// gradle/libs.versions.toml
[versions]
room = "2.7.0"
ksp = "2.0.21-1.0.28"
kotlin = "2.0.21"
[libraries]
room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
room-testing = { module = "androidx.room:room-testing", version.ref = "room" }
// app/build.gradle.kts
plugins {
id("com.google.devtools.ksp") version "2.0.21-1.0.28"
}
dependencies {
implementation(libs.room.runtime)
implementation(libs.room.ktx) // 提供协程与 Flow 支持
ksp(libs.room.compiler) // 注意是 ksp 而非 kapt
androidTestImplementation(libs.room.testing)
}
2.2 版本与兼容性
| 组件 | 建议版本 | 说明 |
|---|---|---|
| Room | 2.7.0 | 2.7 起支持 Kotlin Multiplatform,KSP2 兼容更好 |
| KSP | 2.0.21-1.0.28 | 前缀必须与 Kotlin 版本严格对应 |
| Kotlin | 2.0.21 / 2.1.x | KSP 版本跟随 Kotlin 升级 |
| AGP | 8.5 及以上 | 低于 8.x 可能不识别 KSP2 |
KSP 版本号形如 <Kotlin 版本>-<KSP 自身版本>,两者不匹配会在配置阶段直接报错,这是升级 Kotlin 时最常见的踩坑点。
三、实体与表结构
3.1 @Entity 与主键
一个 @Entity 对应一张表,@PrimaryKey 声明主键。autoGenerate = true 对应 SQLite 的 AUTOINCREMENT,适合自增主键;若用服务端下发的字符串 ID,则手动赋值。
@Entity(tableName = "articles")
data class ArticleEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
@ColumnInfo(name = "title")
val title: String,
@ColumnInfo(name = "body")
val body: String,
@ColumnInfo(name = "updated_at")
val updatedAt: Long,
@ColumnInfo(name = "is_read", defaultValue = "0")
val isRead: Boolean = false
)
字段名与列名不一致时用 @ColumnInfo(name = ...) 显式映射;忽略某个字段用 @Ignore。
3.2 索引与唯一约束
查询频繁的列应建索引,否则每次都是全表扫描。唯一约束则用于防止重复写入。
@Entity(
tableName = "articles",
indices = [
Index(value = ["updated_at"]),
Index(value = ["remote_id"], unique = true)
]
)
data class ArticleEntity(
@PrimaryKey val id: Long,
@ColumnInfo(name = "remote_id") val remoteId: String,
@ColumnInfo(name = "updated_at") val updatedAt: Long
)
权衡: 索引加速读、拖慢写并占用空间。列表页按
updated_at倒序排列时建索引收益明显;只写不读的日志表则不必。
四、DAO 与响应式查询
4.1 @Query 返回 Flow
Room 的杀手锏是:@Query 返回 Flow<T> 时,任何写入该表的事务提交后,Flow 会自动重新发射查询结果。UI 只需订阅,无需手动刷新。
@Dao
interface ArticleDao {
@Query("SELECT * FROM articles ORDER BY updated_at DESC")
fun observeAll(): Flow<List<ArticleEntity>>
@Query("SELECT * FROM articles WHERE is_read = 0 LIMIT :limit")
suspend fun loadUnread(limit: Int): List<ArticleEntity>
@Query("SELECT COUNT(*) FROM articles")
fun observeCount(): Flow<Int>
}
Flow 返回的是「可观察查询」,而 suspend 返回的是「一次性读取」。UI 层用前者,后台任务用后者。
4.2 写操作与事务
@Dao
interface ArticleDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(article: ArticleEntity): Long
@Insert
suspend fun insertAll(articles: List<ArticleEntity>)
@Update
suspend fun update(article: ArticleEntity)
@Delete
suspend fun delete(article: ArticleEntity)
@Query("DELETE FROM articles WHERE is_read = 1")
suspend fun clearRead()
@Transaction
suspend fun replaceAll(articles: List<ArticleEntity>) {
clearRead()
insertAll(articles)
}
}
@Transaction 保证「先删后插」要么全成功要么全回滚。带 @Transaction 的 suspend 方法在 Room 2.6 之后可以安全地包含挂起调用。
五、关系映射
SQLite 是关系型数据库,但 Room 的对象模型默认是扁平的。处理一对多、多对多用 @Embedded、@Relation、@Junction。
5.1 @Embedded 内嵌对象
把多个字段内联进同一张表,避免为「值对象」单独建表。
data class Author(
val name: String,
@ColumnInfo(name = "author_email") val email: String
)
@Entity(tableName = "articles")
data class ArticleEntity(
@PrimaryKey val id: Long,
val title: String,
@Embedded val author: Author
)
5.2 @Relation 与 @Junction
data class ArticleWithComments(
@Embedded val article: ArticleEntity,
@Relation(parentColumn = "id", entityColumn = "article_id")
val comments: List<CommentEntity>
)
data class ArticleWithTags(
@Embedded val article: ArticleEntity,
@Relation(
parentColumn = "id",
entityColumn = "id",
associateBy = Junction(
value = ArticleTagCrossRef::class,
parentColumn = "article_id",
entityColumn = "tag_id"
)
)
val tags: List<TagEntity>
)
@Relation 会额外发一条 IN (...) 查询,而不是 SQL JOIN。数据量大时这种「N+1 的变体」需要注意,必要时改用 @Query 手写 JOIN 并定义 @Embedded 的结果类。
六、类型转换器
Room 只认识基本类型。要存 List<String>、Date、Instant、枚举,需提供 @TypeConverter。
class Converters {
private val json = Json { ignoreUnknownKeys = true }
@TypeConverter
fun fromStringList(value: List<String>): String =
json.encodeToString(value)
@TypeConverter
fun toStringList(value: String): List<String> =
json.decodeFromString(value)
@TypeConverter
fun fromInstant(value: Instant?): Long? = value?.toEpochMilli()
@TypeConverter
fun toInstant(value: Long?): Instant? = value?.let(Instant::ofEpochMilli)
}
在 @Database 上用 @TypeConverters(Converters::class) 注册。若某字段有专用转换器,可在字段级用 @TypeConverters 覆盖,作用域更小、优先级更高。
坑: 转换器里做反射型 JSON 解析时,R8 可能混淆数据类字段名,导致序列化键名变化。生产构建需为数据类保留字段名(见第 8 篇网络层的 keep 规则)。
七、数据库迁移
表结构一旦发布就不能随意改。Room 用版本号管理 schema,每次改结构都要提供迁移路径。
7.1 手写 Migration
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE articles ADD COLUMN is_read INTEGER NOT NULL DEFAULT 0")
}
}
Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
.addMigrations(MIGRATION_1_2, MIGRATION_2_3)
.build()
7.2 AutoMigration
对于「加列、删列、建索引」这类简单变更,Room 2.4 起可用 @AutoMigration 自动生成迁移代码。
@Database(
entities = [ArticleEntity::class],
version = 2,
autoMigrations = [
AutoMigration(from = 1, to = 2)
]
)
abstract class AppDatabase : RoomDatabase()
需要 room.schemaLocation 开启 schema 导出,AutoMigration 才能对比前后版本:
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
7.3 三种迁移策略的取舍
| 策略 | 数据保留 | 编写成本 | 风险 | 适用 |
|---|---|---|---|---|
| 手写 Migration | 保留 | 高 | 写错导致崩溃 | 生产应用必须 |
| AutoMigration | 保留 | 低 | 仅支持简单变更 | 加列 / 索引 |
| fallbackToDestructiveMigration | 全部丢失 | 无 | 用户数据清空 | 纯缓存、开发期 |
// 仅当数据库只是缓存、丢了能重建时使用
Room.databaseBuilder(context, AppDatabase::class.java, "cache.db")
.fallbackToDestructiveMigration(dropAllTables = true)
.build()
一句话总结:
fallbackToDestructiveMigration是开发期的便利,不是生产期的策略;一旦它进入线上,用户升级 App 就等于清空数据。
八、构建数据库与线程模型
8.1 RoomDatabase.Builder
@Database(
entities = [ArticleEntity::class, CommentEntity::class],
version = 2,
exportSchema = true
)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun articleDao(): ArticleDao
}
val db = Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
.addMigrations(MIGRATION_1_2)
.setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING)
.build()
数据库实例应当全局唯一(用单例或依赖注入持有),每次 build() 都开一个连接池,重复创建会浪费资源。通过 Android 依赖注入与 Hilt
把 AppDatabase 以 @Singleton 提供,是最常见的做法。
8.2 为什么禁用 allowMainThreadQueries
// 不要这样写
Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
.allowMainThreadQueries()
.build()
主线程查询会阻塞 UI,一次慢查询就足以触发 ANR。Room 默认禁止主线程访问正是为了强制你切到后台线程。正确姿势是让 DAO 方法为 suspend 或返回 Flow,由 Room 内部调度到 IO 线程。
8.3 WAL 与并发
WAL(Write-Ahead Logging)让读与写不再互相阻塞:读事务读快照,写事务追加日志。它是 Room 在 API 16 以上默认开启的模式。
| 模式 | 读写并发 | 说明 |
|---|---|---|
| WAL | 读写可并行 | 默认,推荐 |
| TRUNCATE | 读写互斥 | 兼容性更好,性能差 |
即便有 WAL,SQLite 同一时刻仍只允许一个写事务。高频写入要合批:用 @Transaction 包裹批量插入,或在 insertAll 里一次提交多条。
九、Room 与 DataStore 的分工
| 维度 | Room | DataStore |
|---|---|---|
| 数据模型 | 表、行、关系 | 键值 / Proto |
| 查询能力 | SQL 全功能 | 无查询语言 |
| 响应式 | Flow | Flow |
| 典型用途 | 列表、缓存、离线数据 | 设置、开关、token |
| 事务 | 支持 | 单次编辑 |
结论是「并存」而非「二选一」:用户偏好放 DataStore,业务实体放 Room。需要同步服务端数据时,Android 网络层与 Retrofit 实践 拿到响应后写入 Room,UI 再从 Room 的 Flow 读取——这条「网络到数据库到 UI」的单向链路是离线优先架构的骨架。
十、加密与安全
SQLite 文件默认是明文,root 设备或备份文件可能被读取。敏感数据需要加密,常用方案是 SQLCipher(Zetetic)。
val passphrase: ByteArray = SQLiteDatabase.getBytes(
"your-strong-passphrase".toCharArray()
)
val factory = SupportOpenHelperFactory(passphrase)
Room.databaseBuilder(context, AppDatabase::class.java, "secure.db")
.openHelperFactory(factory)
.build()
依赖为 net.zetetic:sqlcipher-android:4.6.1。密钥不能硬编码在代码里,应由 Android Keystore 生成并存储。加密会带来 5% 到 15% 的性能开销,只对确实敏感的表启用即可。
十一、常见坑清单
| 坑 | 表现 | 规避方式 |
|---|---|---|
| 主线程查询 | ANR | 禁用 allowMainThreadQueries,用 suspend/Flow |
| 忘记迁移 | 升级后崩溃 | 每次改 schema 都加 Migration |
| KSP 版本不匹配 | 配置阶段报错 | KSP 前缀对齐 Kotlin 版本 |
| Flow 未在合适作用域收集 | 内存泄漏 | 在 lifecycleScope 或 repeatOnLifecycle 内收集 |
| @Relation 的 N+1 | 大列表卡顿 | 数据量大时手写 JOIN |
| TypeConverter 无空值处理 | 反序列化崩溃 | 转换器参数用可空类型 |
| 数据库实例重复创建 | 资源浪费 | 单例或 DI 提供 |
| 枚举字段直接存 | 顺序变化即错乱 | 存字符串名或显式 code |
| 用 REPLACE 覆盖 | 级联删除副作用 | 慎用 OnConflictStrategy.REPLACE |
| schema 未导出 | AutoMigration 无法生成 | 配置 room.schemaLocation |
小结
Room 不是「把 SQL 藏起来」的糖衣,而是一套把持久化变成类型安全、可测试、可迁移的工程方案。掌握四点即可覆盖绝大多数场景:用 KSP 提升构建速度、用 Flow 返回让 UI 自动刷新、用 Migration 保证升级不丢数据、用 DI 保证数据库实例唯一。至于「该不该上 Room」,答案是只要数据需要查询、排序或关联,就值得;只有零散配置项才交给 DataStore。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。