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 和文件缓存互补。
Room 持久化三层架构与数据流 Room 持久化三层架构与数据流 Application (离线缓存调用方) getDatabase() RoomDatabase (AppDatabase) @Entity 映射 @Dao 生成实现 Entity (数据模型) CacheEntry / User DAO (数据访问接口) @Insert / @Query 表结构映射 SQL 操作 SQLite (物理存储层) 编译时验证:Room 在 apt 阶段检查 SQL 语法与 Entity 字段一致性,提前暴露错误 配合 KSP 或 kapt 使用,不依赖运行时反射

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 FallbackfallbackToDestructiveMigration()❌ 丢失全部数据开发期、缓存可重新拉取的场景
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,统一管理缓存过期刷新策略。