21、Paging 3与Room:PagingSource、@RawQuery、分页加载、无限滚动
分页加载,说白了就是「数据太多,一次拿不完,分批次拿」。
我最早做列表页的时候,傻乎乎地一次性查几千条数据塞进 RecyclerView。结果呢?界面卡成 PPT,内存直接飙到 200MB。用户滑动几下就 ANR 了。嗯,那会儿我才意识到——分页不是「优化」,是「必须」。
今天咱们聊的 Paging 3 + Room,是 Android 官方目前最推荐的分页方案。它把「怎么分页」「什么时候加载下一页」「数据怎么缓存」这些脏活累活全包了。你只需要告诉它「我要查什么数据」,剩下的交给 PagingSource 和 PagingData。
21.1 Paging 3 的核心架构
先看一张图,帮你快速建立整体认知:
整个流程其实就三步:
- UI 层:RecyclerView 通过 PagingDataAdapter 订阅数据流。
- ViewModel:通过 Pager 构建 PagingData 流,交给 UI 层。
- 数据层: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 而夸你,但会因为列表卡顿而骂你。把基础打扎实,比追新框架更重要。