4. SQLite与Room实现持久化:建表语句、DAO接口、数据库升级策略
4.1 原理概述:Room 如何封装 SQLite
Room 是 Android 官方提供的 ORM(对象关系映射)持久化库,位于 SQLite 之上,提供编译时验证、直观的注解 API 和自动迁移支持。离线缓存场景中,Room 承担 结构化数据本地落地 的核心职责,将内存中的缓存对象映射为 SQLite 行记录。
- 编译时代码生成:Room 根据 @Entity、@Dao 注解在编译期生成实现类(如 CacheEntryDao_Impl),避免运行时反射开销。
- 三层架构:Application → Room Database(抽象类) → DAO(数据访问层) → Entity(表映射) → SQLite(物理存储)。
- 线程安全:DAO 方法可标注 suspend(协程)或 LiveData/RxJava,Room 自动管理后台游标操作。
离线缓存适用性:Room 适合存储结构化缓存(如 JSON 解析后的实体列表、用户配置、离线任务队列),与 SharedPreferences 和文件缓存互补。
4.2 Room 核心组件与注解对比
| 组件 | 注解 | 作用 | 离线缓存场景 |
|---|---|---|---|
| Entity | @Entity | 定义表名、字段、主键、索引 | 映射缓存条目、过期时间、优先级 |
| DAO | @Dao | 声明数据操作接口 | 增删改查 + 过期清理 |
| Database | @Database | 数据库持有者,提供 DAO 实例 | 单例管理,控制版本 |
| 迁移 | Migration | 定义版本间 schema 变更 | 缓存表字段扩展、TTL 策略调整 |
4.3 Entity 建表语句与字段设计
在 Room 中,@Entity 注解的类直接映射为 SQLite 表。离线缓存表通常包含 缓存键、序列化数据、过期时间戳 和 元数据 四类字段。
4.3.1 基础缓存表 Entity
@Entity(
tableName = "cache_entries",
indices = [
Index(value = ["expires_at"], name = "idx_expires_at"),
Index(value = ["key"], unique = true, name = "idx_key_unique")
]
)
data class CacheEntry(
@PrimaryKey(autoGenerate = false)
@ColumnInfo(name = "key")
val key: String,
@ColumnInfo(name = "data")
val data: String, // JSON 序列化后的缓存体
@ColumnInfo(name = "content_type")
val contentType: String, // 类型标记(json / protobuf / text)
@ColumnInfo(name = "expires_at")
val expiresAt: Long, // 过期时间戳(毫秒)
@ColumnInfo(name = "created_at")
val createdAt: Long, // 创建时间戳
@ColumnInfo(name = "version")
val version: Int = 1 // 缓存版本,用于迁移
)
4.3.2 建表 SQL(Room 自动生成,等价手动 DDL)
CREATE TABLE IF NOT EXISTS cache_entries (
`key` TEXT NOT NULL PRIMARY KEY,
`data` TEXT NOT NULL,
content_type TEXT NOT NULL DEFAULT 'json',
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL,
version INTEGER NOT NULL DEFAULT 1
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_key_unique ON cache_entries (`key`);
CREATE INDEX IF NOT EXISTS idx_expires_at ON cache_entries (expires_at);
索引设计注意:过期清理(
DELETE FROM cache_entries WHERE expires_at < :now)需要全表扫描,务必对 expires_at 建索引,否则离线缓存表增长到数万行后清理延迟可达秒级。
4.4 DAO 接口设计模式
DAO 是离线缓存的核心操作层。每个方法对应一次 SQL 操作,Room 支持 挂起函数(Coroutines)、LiveData 响应式观察和 RxJava 响应式流。
4.4.1 完整 DAO 接口示例
@Dao
interface CacheEntryDao {
// ---------- 写入 ----------
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(entry: CacheEntry)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertBatch(entries: List<CacheEntry>)
// ---------- 读取 ----------
@Query("SELECT * FROM cache_entries WHERE `key` = :key")
suspend fun get(key: String): CacheEntry?
@Query("SELECT * FROM cache_entries WHERE `key` IN (:keys)")
suspend fun getMulti(keys: List<String>): List<CacheEntry>
@Query("SELECT * FROM cache_entries WHERE expires_at > :now ORDER BY created_at DESC")
fun getAllValid(now: Long): LiveData<List<CacheEntry>>
// ---------- 删除 ----------
@Query("DELETE FROM cache_entries WHERE `key` = :key")
suspend fun delete(key: String)
@Query("DELETE FROM cache_entries WHERE expires_at < :now")
suspend fun deleteExpired(now: Long): Int // 返回清理行数
@Query("DELETE FROM cache_entries")
suspend fun clearAll()
// ---------- 统计 ----------
@Query("SELECT COUNT(*) FROM cache_entries WHERE expires_at > :now")
suspend fun countValid(now: Long): Int
}
4.4.2 事务批量操作
@Dao
abstract class CacheEntryDaoWithTransaction {
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract suspend fun insert(entry: CacheEntry)
@Query("DELETE FROM cache_entries WHERE expires_at < :now")
abstract suspend fun deleteExpired(now: Long): Int
@Transaction
open suspend fun refreshCache(entries: List<CacheEntry>, now: Long) {
deleteExpired(now) // 先清理过期
entries.forEach { insert(it) } // 再批量插入新缓存
}
}
@Transaction 规则:当 DAO 方法组合了多个写操作(删除+插入),使用
@Transaction 确保原子性;Room 会将多个 SQL 放在同一个 SQLite 事务中,性能提升 30%~50%。
4.5 数据库升级策略
离线缓存表结构会随版本迭代变化(如增加 TTL 字段、修改索引)。Room 提供 两种升级路径:
| 策略 | 实现方式 | 数据保留 | 适用场景 |
|---|---|---|---|
| Destructive Fallback | fallbackToDestructiveMigration() | ❌ 丢失全部数据 | 开发期、缓存可重新拉取的场景 |
| Migration 对象 | 编写 Migration(1, 2) 类 | ✅ 保留并转换数据 | 生产环境、用户本地缓存不可丢失 |
4.5.1 生产级 Migration 示例
// 版本 1 → 2:增加 ttl 字段,并基于 expires_at 回填初始值
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"ALTER TABLE cache_entries ADD COLUMN ttl INTEGER NOT NULL DEFAULT 86400000"
)
// 回填已有数据的 ttl(60 秒过期时间窗口)
database.execSQL(
"UPDATE cache_entries SET ttl = (expires_at - created_at) WHERE ttl = 86400000"
)
}
}
// 版本 2 → 3:重新设计索引,覆盖新查询模式
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("DROP INDEX IF EXISTS idx_expires_at")
database.execSQL(
"CREATE INDEX IF NOT EXISTS idx_expires_at_ttl ON cache_entries (expires_at, ttl)"
)
}
}
4.5.2 Database 构建时注入迁移
@Database(
entities = [CacheEntry::class],
version = 3,
exportSchema = true // 输出 schema JSON 用于测试
)
abstract class AppDatabase : RoomDatabase() {
abstract fun cacheEntryDao(): CacheEntryDao
companion object {
// 注意:生产环境禁止使用 fallbackToDestructiveMigration
fun build(context: Context): AppDatabase =
Room.databaseBuilder(context, AppDatabase::class.java, "offline_cache.db")
.addMigrations(MIGRATION_1_2, MIGRATION_2_3)
.build()
}
}
迁移脚本测试:
exportSchema = true 会在 build/generated/source/room/schema 输出 JSON schema,配合 MigrationTestHelper 编写单元测试,确保迁移脚本在旧库上执行正确。
4.6 调试与验证
4.6.1 Android Studio Database Inspector
运行 App 后,通过 View → Tool Windows → App Inspection → Database Inspector 打开。可直接执行 SQL 查询、观察表数据、触发迁移验证。
-- 在 Database Inspector 中执行的调试查询
SELECT `key`, expires_at, (expires_at - strftime('%s','now') * 1000) AS ttl_remaining
FROM cache_entries
ORDER BY ttl_remaining ASC
LIMIT 10;
4.6.2 启用 Room 日志(QueryCallback)
Room.databaseBuilder(context, AppDatabase::class.java, "offline_cache.db")
.setQueryCallback(object : RoomDatabase.QueryCallback() {
override fun onQuery(sql: String, bindArgs: List<Any?>) {
Log.d("RoomSQL", "SQL: $sql | Args: $bindArgs")
}
}, Executors.newSingleThreadExecutor())
.addMigrations(MIGRATION_1_2, MIGRATION_2_3)
.build()
输出的日志示例:
D/RoomSQL: SQL: SELECT * FROM cache_entries WHERE `key` = ? | Args: [weather_beijing]
D/RoomSQL: SQL: DELETE FROM cache_entries WHERE expires_at < ? | Args: [1718200000000]
4.6.3 迁移测试(JUnit + MigrationTestHelper)
@RunWith(AndroidJUnit4::class)
class MigrationTest {
private val TEST_DB = "migration_test.db"
@Test
fun migrate1To2_shouldAddTtlColumn() {
val helper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java,
listOf(CacheEntry::class.java)
)
// 创建版本1的数据库
var db = helper.createDatabase(TEST_DB, 1)
db.execSQL("INSERT INTO cache_entries (`key`, data, content_type, expires_at, created_at) " +
"VALUES ('k1', '{}', 'json', 2000000, 1000000)")
db.close()
// 迁移到版本2
db = helper.runMigrationsAndValidate(TEST_DB, 2, true, MIGRATION_1_2)
// 验证 ttl 字段存在且有值
val cursor = db.query("SELECT ttl FROM cache_entries WHERE `key` = 'k1'")
assertTrue(cursor.moveToFirst())
assertEquals(1000000, cursor.getLong(0)) // expires_at - created_at
cursor.close()
db.close()
}
}
4.7 常见错误与解决方案
| 错误现象 | 根因 | 解决方案 |
|---|---|---|
编译错误:PrimaryKey must be non-null | 主键字段声明为可空类型 | 主键使用 T 而非 T?;若用自动生成,确保类型可自增 |
运行时崩溃:Migration not found | 版本号跳变且未提供对应 Migration | 添加 .fallbackToDestructiveMigration()(仅开发)或补全 Migration 链 |
| 查询返回空列表但数据存在 | DAO 返回类型与查询字段不匹配 | 检查 @ColumnInfo(name) 是否与 SQL 列名一致;使用 @Query 的列映射 |
| 插入数据后立即查询不到 | 协程作用域不一致(插入在另一个事务未提交) | 确保插入和读取在同一个 @Transaction 或使用 awaitClose 等待 |
| 数据库文件膨胀(>100MB) | 未执行 DELETE expired 或未 VACUUM | 定期清理 + 调用 supportSQLiteDatabase.execSQL("VACUUM")(建议在后台线程) |
4.8 性能优化建议
- 批量操作使用 @Transaction:减少 SQLite 事务提交次数,批量插入 1000 行耗时从 ~800ms 降至 ~150ms。
- 避免在 DAO 中返回大量 LiveData:若表行数 > 5000,使用 Paging 3 + Room 的分页查询,避免一次性加载全部数据到内存。
- 索引覆盖查询字段:对
WHERE、ORDER BY、JOIN中使用的列建立索引,但避免过多索引拖慢写入。 - 缓存版本号与 Migration 分离:业务缓存版本(如 JSON schema 版本)不要混入 Room 版本号,否则会导致不必要的数据库迁移。
- 使用 WAL 模式:Room 默认启用 SQLite WAL(预写日志),并发读写性能提升 2~3 倍。可通过
setJournalMode(JournalMode.WRITE_AHEAD_LOGGING)显式指定。
本章核心结论:Room 通过 @Entity 声明表结构、@Dao 提供类型安全的 API、Migration 管理 schema 演进,三者配合 编译时验证 和 协程原生支持,为离线缓存提供了稳定高效的本地持久化底座。下一章将基于此 DAO 层构建 Cache Manager,统一管理缓存过期刷新策略。