21、分页加载:Paging 3库与MVVM的集成实践

分页加载这件事,说白了就是「数据太多,一次拿不完」。我早年做第一个新闻客户端时,傻乎乎地一次性拉取500条数据,结果列表卡成PPT,用户直接卸载了。后来才明白——分页不是可选项,而是刚需。

Android官方提供的Paging 3库,算是把分页这件事给「标准化」了。它跟MVVM架构配合起来,简直天衣无缝。今天我就带你走一遍完整的集成流程,顺便聊聊我踩过的坑。

为什么选Paging 3?

市面上分页方案不少,但Paging 3有几个核心优势:

  • 生命周期感知:自动跟随Activity/Fragment生命周期,不会内存泄漏
  • 数据源抽象:支持网络、数据库、或者网络+数据库混合
  • 与Room深度集成:数据库变更自动触发UI刷新
  • 错误处理:内置重试、加载状态回调

我个人习惯是:只要项目涉及列表加载,优先考虑Paging 3。除非数据量极小(比如不到50条),否则别自己造轮子。

架构概览

先看一张整体流程图,帮你建立全局认知:

数据源层 PagingSource (网络/数据库) load() 方法 返回 LoadResult Repository层 Pager 构建器 配置 pageSize 返回 Flow<PagingData> ViewModel层 cachedIn(viewModelScope) 暴露 StateFlow UI 层订阅 UI层 (Activity/Fragment) collectAsLazyPagingItems() 自动处理加载/刷新/重试 Pager 配置 StateFlow 订阅

嗯,这张图的核心逻辑就是:数据从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 方法决定了刷新后回到哪一页。我曾经没重写这个方法,导致每次下拉刷新都跳回第一页,用户体验极差。后来老老实实补上了。

小技巧:如果你的API返回的是「游标分页」(比如cursor=xxx),PagingSource的Key类型可以换成String。不过大多数RESTful API还是用页码分页,Int就够用了。

第二步: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秒内回来,列表瞬间恢复,不用转圈圈。

避坑指南:千万不要在ViewModel里直接调用 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就行。至于那些手写分页的代码,该删就删,别舍不得——技术债迟早要还的。


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