21、Paging 3与Room:PagingSource、@RawQuery、分页加载、无限滚动

分页加载,说白了就是「数据太多,一次拿不完,分批次拿」。

我最早做列表页的时候,傻乎乎地一次性查几千条数据塞进 RecyclerView。结果呢?界面卡成 PPT,内存直接飙到 200MB。用户滑动几下就 ANR 了。嗯,那会儿我才意识到——分页不是「优化」,是「必须」。

今天咱们聊的 Paging 3 + Room,是 Android 官方目前最推荐的分页方案。它把「怎么分页」「什么时候加载下一页」「数据怎么缓存」这些脏活累活全包了。你只需要告诉它「我要查什么数据」,剩下的交给 PagingSource 和 PagingData。

21.1 Paging 3 的核心架构

先看一张图,帮你快速建立整体认知:

Paging 3 + Room 分页架构图 RecyclerView PagingDataAdapter ViewModel Pager.flow / LiveData Repository PagingSource Room DAO @RawQuery / @Query SQLite 数据库 观察 请求 查询 SQL PagingData 流

整个流程其实就三步:

  1. UI 层:RecyclerView 通过 PagingDataAdapter 订阅数据流。
  2. ViewModel:通过 Pager 构建 PagingData 流,交给 UI 层。
  3. 数据层:PagingSource 负责从 Room DAO 按页拉取数据。

我个人习惯把 PagingSource 放在 Repository 里,这样切换数据源(比如从 Room 换成网络)时,ViewModel 完全不用改。

21.2 PagingSource:分页的核心引擎

PagingSource 是 Paging 3 的灵魂。它定义了「怎么加载一页数据」「怎么知道还有没有下一页」。

来看一个最典型的实现:

class StudentPagingSource(
    private val studentDao: StudentDao,
    private val query: String
) : PagingSource<Int, Student>() {

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Student> {
        return try {
            // 页码从 1 开始
            val page = params.key ?: 1
            val pageSize = params.loadSize

            // 从 Room 查数据
            val students = studentDao.getStudentsPaged(
                query = query,
                limit = pageSize,
                offset = (page - 1) * pageSize
            )

            // 判断是否还有下一页
            val nextKey = if (students.size < pageSize) null else page + 1

            LoadResult.Page(
                data = students,
                prevKey = if (page == 1) null else page - 1,
                nextKey = nextKey
            )
        } catch (e: Exception) {
            LoadResult.Error(e)
        }
    }

    override fun getRefreshKey(state: PagingState<Int, Student>): Int? {
        // 刷新时回到最近访问的页面
        return state.anchorPosition?.let { anchorPosition ->
            state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1)
                ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1)
        }
    }
}

关键点解析:

  • LoadParams 里有个 loadSize,它不等于你设置的 pageSize。Paging 3 会根据屏幕可见区域动态调整加载数量,这叫「自适应分页」。
  • nextKey 返回 null 时,Paging 3 就知道「数据加载完了」,不会再触发请求。
  • getRefreshKey 用于下拉刷新时保持当前位置。我踩过坑——不实现这个方法,刷新后直接跳到第一页,用户体验很差。

21.3 @RawQuery:写你自己的 SQL

有些场景下,@Query 注解搞不定复杂的动态查询。比如用户输入搜索关键词,还要按不同字段排序。这时候 @RawQuery 就派上用场了。

我曾经在一个项目中需要支持「按姓名模糊搜索 + 按年龄排序 + 按班级筛选」的组合查询。用 @Query 写死的话,得写十几个方法。用 @RawQuery 一条 SQL 搞定。

@Dao
interface StudentDao {

    // 常规分页查询
    @Query("""
        SELECT * FROM students
        WHERE name LIKE '%' || :query || '%'
        ORDER BY id ASC
        LIMIT :limit OFFSET :offset
    """)
    suspend fun getStudentsPaged(
        query: String,
        limit: Int,
        offset: Int
    ): List<Student>

    // 动态 SQL 分页查询
    @RawQuery(observedEntities = [Student::class])
    suspend fun getStudentsDynamicPaged(
        query: SupportSQLiteQuery
    ): List<Student>
}

调用时构建 SupportSQLiteQuery:

fun buildDynamicQuery(
    search: String,
    sortField: String,
    sortOrder: String,
    limit: Int,
    offset: Int
): SupportSQLiteQuery {
    val sql = """
        SELECT * FROM students
        WHERE name LIKE '%' || ? || '%'
        ORDER BY $sortField $sortOrder
        LIMIT ? OFFSET ?
    """.trimIndent()

    return SimpleSQLiteQuery(sql, arrayOf(search, limit, offset))
}

⚠️ 注意:

  • @RawQuery 不会自动监听表变化。你需要手动设置 observedEntities,否则数据更新后 UI 不会刷新。
  • 排序字段 $sortField 不要直接拼接用户输入,防止 SQL 注入。最好做个白名单校验。

21.4 在 ViewModel 中组装 Pager

有了 PagingSource 和 DAO,接下来在 ViewModel 里把它们串起来:

class StudentViewModel(
    private val studentDao: StudentDao
) : ViewModel() {

    val studentFlow: Flow<PagingData<Student>> = Pager(
        config = PagingConfig(
            pageSize = 20,           // 每页 20 条
            enablePlaceholders = false, // 不显示占位符
            initialLoadSize = 40,    // 首次加载 40 条
            maxSize = 200            // 内存中最多缓存 200 条
        ),
        pagingSourceFactory = {
            StudentPagingSource(studentDao, "")
        }
    ).flow.cachedIn(viewModelScope)
}

💡 我的建议:

  • cachedIn(viewModelScope) 一定要加。不加的话,每次屏幕旋转都会重新加载数据。
  • initialLoadSize 设成 pageSize 的两倍,这样第一屏加载更快,用户体验更好。
  • maxSize 控制内存占用。如果你的列表项很复杂(比如带图片),建议设小一点,比如 100。

21.5 UI 层:PagingDataAdapter 与无限滚动

UI 层反而最简单。PagingDataAdapter 已经帮我们处理好了「什么时候加载下一页」的逻辑。

class StudentAdapter : PagingDataAdapter<Student, StudentViewHolder>(diffCallback) {

    override fun onBindViewHolder(holder: StudentViewHolder, position: Int) {
        val student = getItem(position)
        holder.bind(student)
    }

    companion object {
        val diffCallback = object : DiffUtil.ItemCallback<Student>() {
            override fun areItemsTheSame(old: Student, new: Student): Boolean {
                return old.id == new.id
            }

            override fun areContentsTheSame(old: Student, new: Student): Boolean {
                return old == new
            }
        }
    }
}

// 在 Fragment 中订阅
viewLifecycleOwner.lifecycleScope.launch {
    viewModel.studentFlow.collectLatest { pagingData ->
        adapter.submitData(pagingData)
    }
}

你想想看,这里我们完全没写「滑动到底部加载更多」的代码。Paging 3 内部通过 RecyclerView 的 OnScrollListener 自动检测「距离底部还有多少项」,提前触发加载。这就是「无限滚动」的本质。

21.6 避坑指南

我这些年用 Paging 3 踩过的坑,列出来给你参考:

问题 原因 解决方案
下拉刷新后数据重复 没有正确处理 getRefreshKey 实现 getRefreshKey,返回正确的锚点页码
快速滑动时崩溃 PagingData 被多次 collect 使用 cachedIn 缓存数据流
@RawQuery 不刷新 忘记设置 observedEntities 加上 observedEntities = [Entity::class]
列表底部一直 loading nextKey 永远不为 null 检查分页逻辑,数据不足时返回 null
内存泄漏 PagingSource 持有 Activity 引用 确保 PagingSource 不持有 Context

我曾经在一个日活百万的 App 里,因为没加 cachedIn,导致用户切后台再回来时列表重新加载,流量消耗暴增。嗯,从那以后我每次写 Paging 都会检查这一行。

21.7 小结

Paging 3 + Room 的组合,说白了就是「用官方方案解决官方问题」。你不需要自己写 AsyncListDiffer,不需要自己管理加载状态,甚至不需要关心线程切换——Paging 3 默认在 Dispatchers.IO 执行 load 方法。

但要注意,Paging 3 不是银弹。如果你的数据量很小(比如不到 100 条),直接用 Flow<List<T>> 反而更简单。分页本身有开销,杀鸡不用牛刀。

最后送你一句话:分页不是功能,是体验。用户不会因为你用了 Paging 3 而夸你,但会因为列表卡顿而骂你。把基础打扎实,比追新框架更重要。


公众号:蓝海资料掘金营,微信deep3321