【Jetpack 内核实战 06 】:Room 进阶——响应式查询、数据库迁移与列表自动刷新

0 阅读14分钟

Room 进阶——响应式查询、数据库迁移与列表自动刷新

各位 Android 开发者好,欢迎来到《Jetpack 内核实战》系列第 6 篇。 在上一篇中,我们从零搭建了 Room 数据库,完成了记事本增删改查的基础持久化能力。但目前的实现还存在三个核心痛点:数据增删后需要手动刷新列表、App 版本升级修改表结构易导致数据丢失、复杂查询场景实现繁琐。 本篇我们进入 Room 进阶实战,系统讲解响应式查询、数据库迁移、复杂查询三大核心能力,彻底解决上述痛点,实现数据变化自动刷新 UI、版本升级数据无损迁移。全文延续 Kotlin 规范与分层架构,所有代码均经过编译验证,可直接落地到项目中。

一、响应式查询:数据变化自动驱动 UI

传统的数据库查询是「一次性」的,查询完成后数据就和数据库脱离了关系,后续数据发生变化需要手动重新查询刷新 UI,不仅代码冗余,还很容易出现数据不同步的问题。Room 原生支持响应式查询,完美解决了这个问题。

1. 响应式查询的核心价值

响应式查询的本质是:DAO 方法返回可观察的数据流(LiveData/Flow),当数据库中对应表的数据发生增删改时,Room 会自动重新执行查询,发射最新的数据集。

核心优势:

  • 无需手动刷新,数据变化自动更新 UI,保证界面与数据库始终一致
  • 与 Jetpack 架构无缝融合,LiveData 自带生命周期安全,Flow 配合协程实现更灵活的流处理
  • 线程安全,所有数据库操作自动在后台线程执行,不会阻塞主线程

2. 两种响应式实现:LiveData 与 Flow

Room 提供了两种主流的响应式返回类型,分别适配不同的技术栈与场景。

(1)LiveData 版本:入门简单,生命周期感知

最基础的响应式写法,直接将返回值改为 LiveData,Room 自动处理数据监听与主线程分发:

@Dao
interface NoteDao {
    // 返回 LiveData,数据库变化自动更新数据
    @Query("SELECT * FROM notes ORDER BY createTime DESC")
    fun getAllNotesLiveData(): LiveData<List<Note>>
}

适用场景:简单的 UI 数据展示、技术栈以 LiveData 为主的存量项目,直接在 UI 层 observe 即可,无需额外处理生命周期。

(2)Flow 版本:Kotlin 原生,功能更强大

Kotlin 协程生态下的官方推荐方案,返回 Flow,可以搭配丰富的流操作符做数据转换、防抖、过滤等处理:

@Dao
interface NoteDao {
    // 返回 Flow,冷流,收集时开始监听数据库变化
    @Query("SELECT * FROM notes ORDER BY createTime DESC")
    fun getAllNotesFlow(): Flow<List<Note>>
}

适用场景:现代 Kotlin 项目、需要复杂数据处理的场景,是 Jetpack 架构未来的主流方向,后续 StateFlow、Paging3 等组件均基于 Flow 生态。

⚠️ 注意:响应式查询的方法不能加 suspend 关键字。返回 LiveData/Flow 本身就是异步的,Room 会自动在后台线程执行查询,无需挂起函数。

3. 底层原理:InvalidationTracker 监听机制

Room 能实现自动刷新,核心依赖内部的 InvalidationTracker 跟踪器:

  1. 初始化数据库时,Room 会为每个表注册跟踪器

  2. 当执行增删改操作时,Room 会标记对应表为「已失效」

  3. 所有监听该表的响应式查询,会收到失效通知,自动重新执行 SQL 查询

  4. 新的查询结果通过 LiveData/Flow 发射给观察者

整个过程完全自动,无需开发者手动管理监听,且只监听表级变化,性能开销极低。

二、数据库迁移:版本升级数据不丢失的核心方案

在 App 的迭代过程中,修改表结构是高频需求(新增字段、新增表、修改字段类型)。如果没有做数据库迁移,版本号变更后 Room 默认会直接重建数据库,导致用户所有本地数据丢失——这是线上绝对不能接受的。

1. Migration 迁移机制

Room 提供了 Migration 类来处理数据库版本升级,每个 Migration 对应两个版本之间的升级逻辑,通过执行自定义 SQL 完成表结构变更,同时保留用户数据。

核心规则:

  • 每个数据库版本对应一个整数版本号,在 @Database 注解的 version 属性中定义
  • 升级必须连续,比如从 1 升到 3,需要提供 1→2 和 2→3 两个 Migration,Room 会自动按顺序执行
  • 迁移 SQL 必须正确执行,否则会抛出异常导致崩溃

2. 标准迁移实战:版本 1 → 2 新增字段

我们给记事本的 notes 表新增一个 priority 优先级字段,完整实现版本 1 到版本 2 的迁移。

步骤 1:修改实体类,新增字段

@Entity(tableName = "notes")
data class Note(
    @PrimaryKey(autoGenerate = true)
    val id: Long = 0,

    val content: String,

    val createTime: Date,

    val updateTime: Date,

    // 新增优先级字段:0 普通,1 重要
    val priority: Int = 0
)

步骤 2:定义 Migration 迁移逻辑

新建 database/migration 包,创建 NoteMigrations.kt:

package com.example.jetpacknote.data.database.migration

import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase

/**
 * 数据库版本 1 → 2 迁移
 * 给 notes 表新增 priority 优先级字段
 */
val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(database: SupportSQLiteDatabase) {
        // 执行 ALTER TABLE 语句新增字段
        // 非空字段必须指定默认值,否则旧数据插入会报错
        database.execSQL(
            "ALTER TABLE notes ADD COLUMN priority INTEGER NOT NULL DEFAULT 0"
        )
    }
}

步骤 3:注册 Migration 到数据库

修改 AppDatabase.kt,升级版本号,添加迁移配置:

@Database(entities = [Note::class], version = 2, exportSchema = false)
@TypeConverters(DateConverter::class)
abstract class AppDatabase : RoomDatabase() {

    abstract fun noteDao(): NoteDao

    companion object {
        @Volatile
        private var INSTANCE: AppDatabase? = null

        fun getInstance(context: Context): AppDatabase {
            return INSTANCE ?: synchronized(this) {
                val instance = Room.databaseBuilder(
                    context.applicationContext,
                    AppDatabase::class.java,
                    "jetpack_note_db"
                )
                    // 注册所有迁移规则
                    .addMigrations(MIGRATION_1_2)
                    .build()
                INSTANCE = instance
                instance
            }
        }
    }
}

3. 常见迁移场景处理

(1)删除字段

SQLite 不支持直接 DROP COLUMN 删除字段,需要通过「新建表→迁移数据→删除旧表→重命名新表」的流程实现:

val MIGRATION_2_3 = object : Migration(2, 3) {
    override fun migrate(database: SupportSQLiteDatabase) {
        // 1. 创建新表(不含要删除的字段)
        database.execSQL("""
            CREATE TABLE notes_new (
                id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
                content TEXT NOT NULL,
                createTime INTEGER NOT NULL,
                updateTime INTEGER NOT NULL
            )
        """)
        // 2. 把旧表数据复制到新表
        database.execSQL("""
            INSERT INTO notes_new (id, content, createTime, updateTime)
            SELECT id, content, createTime, updateTime FROM notes
        """)
        // 3. 删除旧表
        database.execSQL("DROP TABLE notes")
        // 4. 重命名新表为原表名
        database.execSQL("ALTER TABLE notes_new RENAME TO notes")
    }
}

(2)新增表

直接执行建表 SQL 即可:

val MIGRATION_2_3 = object : Migration(2, 3) {
    override fun migrate(database: SupportSQLiteDatabase) {
        database.execSQL("""
            CREATE TABLE categories (
                id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
                name TEXT NOT NULL
            )
        """)
    }
}

4. ⚠️ 巨坑提醒:fallbackToDestructiveMigration

Room 提供了一个兜底配置 fallbackToDestructiveMigration(),如果找不到对应版本的 Migration,会直接销毁所有表并重建数据库,数据全部清空。

环境建议
开发/调试阶段✅ 可以使用,方便快速迭代
测试阶段⚠️ 不建议,可能掩盖迁移问题
生产环境❌ 绝对禁止,会导致用户数据永久丢失
// 开发阶段配置(上线前务必删除!)
Room.databaseBuilder(...)
    .fallbackToDestructiveMigration()  // ⚠️ 仅开发用,上线必须移除
    .build()

三、复杂查询进阶:覆盖搜索、多表、聚合、分页场景

基础 CRUD 只能满足简单需求,实际项目中还有大量复杂查询场景,Room 原生支持完整的 SQLite 语法,配合注解可以实现绝大多数查询需求。

1. 模糊搜索:实时响应的关键词查询

结合响应式 Flow,可以实现输入关键词实时搜索的效果:

@Dao
interface NoteDao {
    @Query("SELECT * FROM notes WHERE content LIKE '%' || :keyword || '%' ORDER BY createTime DESC")
    fun searchNotesFlow(keyword: String): Flow<List<Note>>
}

配合 Flow 的 debounce 防抖操作符,可以实现输入停止后再搜索,优化性能(后续 Flow 进阶篇会详细讲解)。

2. 多表联查:关联表数据查询

给笔记加分类功能,需要 notes 表关联 categories 表查询。

步骤 1:新建分类实体

@Entity(tableName = "categories")
data class Category(
    @PrimaryKey(autoGenerate = true)
    val id: Long = 0,
    val name: String
)

步骤 2:定义联查结果 POJO

用 @Embedded + @Relation 注解关联表:

data class NoteWithCategory(
    @Embedded
    val note: Note,

    @Relation(
        parentColumn = "categoryId",
        entityColumn = "id"
    )
    val category: Category?
)

步骤 3:DAO 中定义联查方法

@Dao
interface NoteDao {
    @Transaction  // 保证联查原子性
    @Query("SELECT * FROM notes")
    fun getNotesWithCategory(): Flow<List<NoteWithCategory>>
}

3. 聚合查询:统计与分组

Room 支持所有 SQLite 聚合函数,如 COUNT、SUM、AVG、GROUP BY 等:

data class CategoryCount(
    val categoryId: Long,
    val count: Int
)

@Dao
interface NoteDao {
    @Query("SELECT categoryId, COUNT(*) as count FROM notes GROUP BY categoryId")
    fun getNoteCountByCategory(): Flow<List<CategoryCount>>
}

4. 分页查询:LIMIT + OFFSET

数据量较大时,不能一次性查询所有数据,需要分页加载:

@Dao
interface NoteDao {
    @Query("SELECT * FROM notes ORDER BY createTime DESC LIMIT :pageSize OFFSET :offset")
    suspend fun getNotesByPage(pageSize: Int, offset: Int): List<Note>
}

企业级推荐:使用 Jetpack Paging3 组件,Room 原生支持 PagingSource,可实现无痛分页加载、下拉刷新、上拉加载更多(后续进阶篇会专门讲解)。

四、实战落地:记事本项目全量升级

理论讲完,我们基于记事本项目完成全量升级,实现三个核心目标:响应式列表自动刷新、数据库版本无损迁移、实时模糊搜索。

1. 改造 DAO 为 Flow 响应式查询

修改 NoteDao.kt,新增响应式查询方法:

package com.example.jetpacknote.data.dao

import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import com.example.jetpacknote.data.entity.Note
import kotlinx.coroutines.flow.Flow

@Dao
interface NoteDao {

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertNote(note: Note): Long

    @Update
    suspend fun updateNote(note: Note): Int

    @Delete
    suspend fun deleteNote(note: Note): Int

    // 响应式:全量笔记流
    @Query("SELECT * FROM notes ORDER BY createTime DESC")
    fun getAllNotesFlow(): Flow<List<Note>>

    // 响应式:模糊搜索流
    @Query("SELECT * FROM notes WHERE content LIKE '%' || :keyword || '%' ORDER BY createTime DESC")
    fun searchNotesFlow(keyword: String): Flow<List<Note>>

    @Query("SELECT * FROM notes WHERE id = :noteId")
    suspend fun getNoteById(noteId: Long): Note?

    @Query("DELETE FROM notes")
    suspend fun clearAllNotes()
}

2. 数据库版本升级 + Migration 实现

按照第二节的步骤,给 Note 新增 priority 字段,编写 MIGRATION_1_2 迁移规则,升级数据库版本为 2,注册到 AppDatabase 中。

3. Repository 层封装响应式流

修改 NoteRepository.kt,对外暴露响应式数据流:

package com.example.jetpacknote.data.repository

import android.content.Context
import com.example.jetpacknote.data.database.AppDatabase
import com.example.jetpacknote.data.dao.NoteDao
import com.example.jetpacknote.data.entity.Note
import kotlinx.coroutines.flow.Flow
import java.util.Date

class NoteRepository private constructor(context: Context) {

    private val noteDao: NoteDao = AppDatabase.getInstance(context).noteDao()

    // 笔记列表响应式流
    val noteListFlow: Flow<List<Note>> = noteDao.getAllNotesFlow()

    // 搜索结果响应式流
    fun searchNotes(keyword: String): Flow<List<Note>> {
        return noteDao.searchNotesFlow(keyword)
    }

    suspend fun addNote(content: String): Result<Boolean> {
        return try {
            val note = Note(
                content = content,
                createTime = Date(),
                updateTime = Date(),
                priority = 0
            )
            noteDao.insertNote(note)
            Result.success(true)
        } catch (e: Exception) {
            Result.failure(e)
        }
    }

    suspend fun updateNote(note: Note): Result<Boolean> {
        return try {
            val newNote = note.copy(updateTime = Date())
            noteDao.updateNote(newNote)
            Result.success(true)
        } catch (e: Exception) {
            Result.failure(e)
        }
    }

    suspend fun deleteNote(note: Note): Result<Boolean> {
        return try {
            noteDao.deleteNote(note)
            Result.success(true)
        } catch (e: Exception) {
            Result.failure(e)
        }
    }

    companion object {
        @Volatile
        private var INSTANCE: NoteRepository? = null

        fun getInstance(context: Context): NoteRepository {
            return INSTANCE ?: synchronized(this) {
                INSTANCE ?: NoteRepository(context.applicationContext).also { INSTANCE = it }
            }
        }
    }
}

4. ViewModel 层接入数据流

修改 NoteViewModel.kt,将一次性加载改为持续监听响应式流,同时新增搜索功能:

package com.example.jetpacknote.ui

import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import com.example.jetpacknote.data.entity.Note
import com.example.jetpacknote.data.repository.NoteRepository
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.SharingStarted

@OptIn(ExperimentalCoroutinesApi::class)
class NoteViewModel(application: Application) : AndroidViewModel(application) {

    private val repository = NoteRepository.getInstance(application)

    // 搜索关键词流
    private val searchKeyword = MutableStateFlow("")

    // flatMapLatest:关键词变化时自动切换搜索流
    val noteListFlow: StateFlow<List<Note>> = searchKeyword
        .flatMapLatest { keyword ->
            if (keyword.isBlank()) repository.noteListFlow
            else repository.searchNotes(keyword)
        }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000),
            initialValue = emptyList()
        )

    // 操作状态(加载、错误)
    private val _operationState = MutableLiveData<NoteUiState>()
    val operationState: LiveData<NoteUiState> = _operationState

    // 更新搜索关键词
    fun updateSearchKeyword(keyword: String) {
        searchKeyword.value = keyword
    }

    // 添加笔记
    fun addNote(content: String) {
        viewModelScope.launch {
            _operationState.value = NoteUiState.Loading
            val result = repository.addNote(content)
            result.onSuccess {
                _operationState.value = NoteUiState.Success(emptyList())
            }.onFailure { error ->
                _operationState.value = NoteUiState.Error(error.message ?: "添加失败")
            }
        }
    }

    // 删除笔记
    fun deleteNote(note: Note) {
        viewModelScope.launch {
            repository.deleteNote(note)
            // 不需要手动刷新!Flow 会自动推送新数据
        }
    }
}

核心变化:

  • 不再有手动 loadNotes() 方法

  • 列表数据变成持续监听的 Flow,数据库增删改后自动更新

  • 搜索关键词变化时自动切换查询流,实现实时搜索

5. UI 层实现自动刷新

Activity 中观察数据流,自动更新列表,无需手动调用刷新:

package com.example.jetpacknote

import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import android.view.View
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewModels
import com.example.jetpacknote.databinding.ActivityMainBinding
import com.example.jetpacknote.ui.NoteUiState
import com.example.jetpacknote.ui.NoteViewModel
import kotlinx.coroutines.launch

class MainActivity : AppCompatActivity() {

    private val binding by lazy { ActivityMainBinding.inflate(layoutInflater) }
    private val viewModel: NoteViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(binding.root)

        initClickListeners()
        initSearchListener()
        observeData()
    }

    private fun initClickListeners() {
        binding.btnAdd.setOnClickListener {
            val content = binding.etInput.text.toString().trim()
            if (content.isEmpty()) {
                Toast.makeText(this, "请输入笔记内容", Toast.LENGTH_SHORT).show()
                return@setOnClickListener
            }
            viewModel.addNote(content)
            binding.etInput.text?.clear()
        }
    }

    private fun initSearchListener() {
        binding.etSearch.addTextChangedListener(object : TextWatcher {
            override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
            override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
            override fun afterTextChanged(s: Editable?) {
                viewModel.updateSearchKeyword(s?.toString().orEmpty())
            }
        })
    }

    private fun observeData() {
        // 监听笔记列表流,自动更新 UI
        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.noteListFlow.collect { list ->
                    binding.tvContent.text = list.joinToString("\n\n") { it.content }
                }
            }
        }

        // 监听操作状态
        viewModel.operationState.observe(this) { state ->
            when (state) {
                is NoteUiState.Loading -> {
                    binding.progressBar.visibility = View.VISIBLE
                }
                is NoteUiState.Success -> {
                    binding.progressBar.visibility = View.GONE
                }
                is NoteUiState.Error -> {
                    binding.progressBar.visibility = View.GONE
                    Toast.makeText(this, state.errorMsg, Toast.LENGTH_SHORT).show()
                }
            }
        }
    }
}

运行验证

操作预期结果
添加笔记列表自动刷新,无需手动调用加载方法 ✅
输入搜索关键词列表自动过滤匹配的笔记 ✅
卸载旧版本安装新版本数据库正常迁移,数据不会丢失 ✅
旋转屏幕数据不丢失,状态自动恢复 ✅

五、FAQ / 避坑指南

Q1:Room 迁移新增/删除字段有哪些注意事项?

A:核心注意点如下:

操作注意事项
新增非空字段必须加 DEFAULT 默认值,否则旧数据插入会失败,直接崩溃
删除字段SQLite 不支持 DROP COLUMN,必须走「新建表→迁移数据→删旧表→重命名」流程
Migration SQL 必须和实体类一致字段名、类型、非空属性必须完全匹配,否则编译或运行时报错
版本号必须递增只能从低版本升到高版本,降级场景需单独处理
// ✅ 正确:新增非空字段带默认值
database.execSQL("ALTER TABLE notes ADD COLUMN priority INTEGER NOT NULL DEFAULT 0")

// ❌ 错误:非空字段无默认值,旧数据迁移会失败
database.execSQL("ALTER TABLE notes ADD COLUMN priority INTEGER NOT NULL")

Q2:Flow 监听数据库变化的原理是什么?

A:核心依赖 Room 的 InvalidationTracker 跟踪机制:

  1. 数据库初始化时,为每个表创建跟踪标记

  2. 所有增删改事务提交后,触发对应表的失效标记

  3. 响应式查询的 Flow 订阅表的失效通知,收到通知后自动在后台线程重新执行查询

  4. 新的查询结果通过 Flow 发射给观察者

重要:Flow 是冷流,只有被收集时才会开始监听,取消收集后自动停止监听,不会造成内存泄漏。

Q3:大量数据查询如何避免主线程阻塞?

A:遵循以下规范可以完全避免主线程阻塞:

规范说明
优先使用响应式查询LiveData/Flow 类型的查询,Room 自动在后台线程执行,结果自动切到主线程
同步查询加 suspend普通一次性查询用挂起函数,在协程中调用,不要在主线程直接执行
分页加载数据量超过百级时,用 LIMIT 分页或 Paging3 组件分页加载
避免主线程执行事务所有增删改操作用 suspend 函数,放在 IO 调度器执行
复杂数据处理放后台查询结果的转换、过滤等操作,在 Flow 的操作符中处理

Q4:fallbackToDestructiveMigration 到底能不能用?

A:生产环境绝对不要用。

环境建议
开发/调试阶段✅ 可以使用,方便快速迭代
测试阶段⚠️ 不建议,可能掩盖迁移问题
生产环境❌ 绝对禁止,会导致用户数据永久丢失

如果已经在生产环境使用了 fallbackToDestructiveMigration(),建议尽快移除并提供完整的 Migration 路径。

Q5:LiveData 和 Flow 在 Room 中如何选择?

A:根据使用场景选择:

场景推荐原因
DAO → RepositoryFlow操作符丰富,便于数据转换和组合
Repository → ViewModelFlow配合 stateIn 转换为 StateFlow
ViewModel → ActivityLiveData 或 StateFlowLiveData 自动感知生命周期;StateFlow 配合 repeatOnLifecycle 同样安全
简单 UI 绑定LiveData代码更简洁,无需处理协程
复杂数据流处理Flow操作符丰富(map、filter、flatMapLatest 等)

推荐组合:Repository 返回 Flow,ViewModel 中转换为 StateFlow,Activity 中使用 repeatOnLifecycle 安全收集。这是当前 Google 官方推荐的最佳实践。

六、架构演进小结

阶段数据查询方式UI 更新方式
第 5 篇(Room 入门)一次性查询(suspend fun)手动调用 loadNotes()
本篇(Room 进阶)响应式查询(Flow/LiveData)数据变化自动刷新 UI ✅

至此,我们的数据层已经具备了完整的响应式能力:

用户操作(增/删/改)
        ↓
    Repository
        ↓
 Room 数据库(数据变更)
        ↓
InvalidationTracker(检测变化)
        ↓
 Flow/LiveData 自动重新查询
        ↓
  ViewModel(状态更新)
        ↓
 Activity(UI 自动刷新)✅

下篇预告

本篇我们完成了 Room 的进阶升级,掌握了响应式查询、数据库迁移、复杂查询三大核心能力,实现了数据变化自动刷新 UI、版本升级数据无损迁移,记事本项目的持久化能力已经达到企业级标准。

目前我们已经解决了结构化数据的持久化问题,但轻量级的配置存储(比如主题设置、用户偏好)还没有对应的方案。下一篇我们将学习 DataStore,Google 官方推出的 SharedPreferences 替代方案,彻底解决 SP 的 ANR、类型不安全等痛点。


🌟 点赞 + 收藏 + 关注,第一时间收到更新推送 💬 评论区聊聊:你在 Room 数据库迁移时踩过哪些坑?是用 AutoMigration 还是手动 Migration?欢迎留言交流!