Android 数据持久化与 Room

本地数据该怎么存、迁移怎么做,是 Android 项目绕不开的工程问题。本文以 Room 2.7 与 KSP 为主线讲本地持久化:实体与数据访问对象建模、流式响应查询、事务与关系映射、类型转换器、数据库迁移与自动迁移的取舍,以及索引唯一约束、加密与预写日志并发。文中给出与 DataStore 的分工对照表,帮你为不同数据选出可维护的持久化方案。

本地持久化是每个 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 版本与兼容性

组件建议版本说明
Room2.7.02.7 起支持 Kotlin Multiplatform,KSP2 兼容更好
KSP2.0.21-1.0.28前缀必须与 Kotlin 版本严格对应
Kotlin2.0.21 / 2.1.xKSP 版本跟随 Kotlin 升级
AGP8.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 的分工

维度RoomDataStore
数据模型表、行、关系键值 / Proto
查询能力SQL 全功能无查询语言
响应式FlowFlow
典型用途列表、缓存、离线数据设置、开关、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。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「Android 开发」更多文章

  1. Kotlin Multiplatform 跨平台共享
  2. Android R8 混淆与 Baseline Profile
  3. Android 测试体系:单元测试、Espresso 与 Compose 测试