21、分页加载:Paging 3库与MVVM的集成实践
分页加载这件事,说白了就是「数据太多,一次拿不完」。我早年做第一个新闻客户端时,傻乎乎地一次性拉取500条数据,结果列表卡成PPT,用户直接卸载了。后来才明白——分页不是可选项,而是刚需。
Android官方提供的Paging 3库,算是把分页这件事给「标准化」了。它跟MVVM架构配合起来,简直天衣无缝。今天我就带你走一遍完整的集成流程,顺便聊聊我踩过的坑。
为什么选Paging 3?
市面上分页方案不少,但Paging 3有几个核心优势:
- 生命周期感知:自动跟随Activity/Fragment生命周期,不会内存泄漏
- 数据源抽象:支持网络、数据库、或者网络+数据库混合
- 与Room深度集成:数据库变更自动触发UI刷新
- 错误处理:内置重试、加载状态回调
我个人习惯是:只要项目涉及列表加载,优先考虑Paging 3。除非数据量极小(比如不到50条),否则别自己造轮子。
架构概览
先看一张整体流程图,帮你建立全局认知:
嗯,这张图的核心逻辑就是:数据从PagingSource来,经过Repository包装成PagingData流,ViewModel缓存后交给UI层消费。每一层各司其职,互不干扰。
第一步:定义PagingSource
PagingSource是分页的「发动机」。你需要告诉它:怎么加载每一页数据。
class ArticlePagingSource(
private val api: ArticleApi,
private val query: String
) : PagingSource<Int, Article>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Article> {
return try {
val page = params.key ?: 1
val response = api.searchArticles(query, page, params.loadSize)
LoadResult.Page(
data = response.articles,
prevKey = if (page > 1) page - 1 else null,
nextKey = if (response.articles.isNotEmpty()) page + 1 else null
)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
override fun getRefreshKey(state: PagingState<Int, Article>): Int? {
return state.anchorPosition?.let { anchorPos ->
state.closestPageToPosition(anchorPos)?.prevKey?.plus(1)
}
}
}
这里有个细节:getRefreshKey 方法决定了刷新后回到哪一页。我曾经没重写这个方法,导致每次下拉刷新都跳回第一页,用户体验极差。后来老老实实补上了。
第二步:Repository层组装Pager
Repository负责把PagingSource包装成Pager,并返回Flow。这里我习惯用依赖注入把API接口传进来:
class ArticleRepository(private val api: ArticleApi) {
fun getSearchResultStream(query: String): Flow<PagingData<Article>> {
return Pager(
config = PagingConfig(
pageSize = 20,
enablePlaceholders = false,
prefetchDistance = 5
),
pagingSourceFactory = { ArticlePagingSource(api, query) }
).flow
}
}
prefetchDistance 这个参数我单独说一下。它控制「提前多少条开始加载下一页」。默认值是pageSize,但我发现网络差的时候,用户滑到倒数第5条才开始加载,会有明显的白屏。所以我一般设成 pageSize/2,也就是10条左右。你想想看,用户滑得正爽,突然卡住等加载,多扫兴。
第三步:ViewModel层缓存数据
ViewModel的核心工作就是调用Repository,然后用 cachedIn 把数据缓存起来。这样屏幕旋转时数据不会丢失:
class ArticleViewModel(private val repository: ArticleRepository) : ViewModel() {
val articles: StateFlow<PagingData<Article>> = repository
.getSearchResultStream("Android")
.cachedIn(viewModelScope)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = PagingData.empty()
)
}
注意 WhileSubscribed(5000) 这个参数。它表示:如果UI层5秒内重新订阅,直接复用旧数据,不重新加载。这个设计很巧妙——比如用户从详情页返回列表页,5秒内回来,列表瞬间恢复,不用转圈圈。
Pager(...).flow.collect 而不加 cachedIn。我曾经这么干过,结果每次屏幕旋转都重新请求网络,用户流量哗哗地流,被产品经理骂了一顿。
第四步:UI层消费PagingData
在Compose中,用 collectAsLazyPagingItems 把Flow转成UI可用的列表数据:
@Composable
fun ArticleListScreen(viewModel: ArticleViewModel) {
val articles = viewModel.articles.collectAsLazyPagingItems()
LazyColumn {
items(
count = articles.itemCount,
key = { index -> articles[index]?.id ?: index }
) { index ->
val article = articles[index]
if (article != null) {
ArticleItem(article)
} else {
// 占位符,显示骨架屏
ShimmerPlaceholder()
}
}
// 处理加载更多状态
articles.loadState.apply {
when (append) {
is LoadState.Loading -> {
item { LoadingIndicator() }
}
is LoadState.Error -> {
item {
RetryButton { articles.retry() }
}
}
else -> {}
}
}
}
}
这里有个容易忽略的点:key 参数一定要传。如果不传,列表项位置变化时会导致整个列表重新绘制,性能大打折扣。我见过一个项目没设key,滑动时卡得跟PPT似的。
网络+数据库混合分页
如果项目用了Room,Paging 3可以做到「先读缓存,再请求网络,最后更新UI」。这个模式叫「RemoteMediator」:
class ArticleRemoteMediator(
private val api: ArticleApi,
private val db: ArticleDatabase
) : RemoteMediator<Int, Article>() {
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, Article>
): MediatorResult {
return try {
val page = when (loadType) {
LoadType.REFRESH -> 1
LoadType.PREPEND -> return MediatorResult.Success(true)
LoadType.APPEND -> {
val lastItem = state.lastItemOrNull()
lastItem?.page?.plus(1) ?: 1
}
}
val response = api.searchArticles("Android", page, state.config.pageSize)
db.articleDao().insertAll(response.articles)
MediatorResult.Success(response.articles.isEmpty())
} catch (e: Exception) {
MediatorResult.Error(e)
}
}
}
这种方案的好处是:用户离线也能看到旧数据。我做过一个资讯App,用户在地铁里没信号,照样能刷之前加载过的文章,体验提升了一大截。
常见问题与避坑
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 列表滑动时闪烁 | 没有设置key或item ID不稳定 | 给items()设置稳定的key |
| 下拉刷新后跳到第一页 | getRefreshKey返回null | 根据anchorPosition计算正确的key |
| 内存泄漏 | 没有使用cachedIn | 在ViewModel中调用cachedIn |
| 加载更多时重复请求 | prefetchDistance设置过大 | 调整为pageSize的一半 |
嗯,这些坑我基本都踩过一遍。尤其是「下拉刷新跳回第一页」那个问题,当时排查了半天,最后发现是PagingSource的key计算逻辑写错了。所以说,分页看似简单,细节真不少。
总结
Paging 3 + MVVM这套组合拳,打好了能让列表加载如丝般顺滑。核心就三点:
- PagingSource:定义数据怎么来
- Pager + cachedIn:控制数据流和缓存
- collectAsLazyPagingItems:UI层无脑消费
我个人建议,新项目直接上Paging 3,别犹豫。老项目迁移也不难,把原来的分页逻辑替换成PagingSource就行。至于那些手写分页的代码,该删就删,别舍不得——技术债迟早要还的。