列表优化(下):Paging3分页库、异步加载数据、预加载策略、列表卡顿监控

好,咱们接着聊列表优化。上一章我们把 RecyclerView 的底层机制和常见优化手段讲透了。这一章,我重点聊聊数据层面的东西——说白了,就是数据怎么来、怎么加载、怎么预判用户行为,以及出了问题怎么监控。

你想想看,一个列表滑起来卡顿,很多时候不是 RecyclerView 本身的问题,而是数据加载拖了后腿。UI 线程等着数据,能不卡吗?

Paging3 分页库:别再自己写分页逻辑了

我记得几年前,团队里有个哥们儿自己写了一套分页加载框架,各种回调、各种状态管理,最后维护得想哭。后来我们全面切到了 Paging3,世界清净了。

Paging3 是 Google 官方推出的分页库,它解决了几个核心痛点:

  • 数据源管理:从网络或数据库加载数据,统一抽象成 PagingSource
  • 页面状态:加载中、加载失败、加载完成,都有现成的状态回调
  • 与 RecyclerView 集成:PagingDataAdapter 直接绑定,省去 DiffUtil 的重复劳动
  • 协程支持:天然与 Kotlin 协程配合,异步加载不阻塞 UI

来看一个最简单的 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 { anchorPosition ->
            state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1)
        }
    }
}

这里有个细节我特别想强调——getRefreshKey 方法。很多人写 PagingSource 时直接返回 null,结果刷新后列表跳到了第一页,用户体验很差。正确的做法是返回当前锚点位置所在的页码,这样刷新后还能保持在当前浏览位置附近。

我的习惯:PagingSource 的 loadSize 不要写死。我一般会根据屏幕可见 item 数量动态计算,比如 params.loadSize = visibleItemCount * 3,这样既能保证预加载足够,又不会一次请求太多数据。

异步加载数据:别让主线程干脏活累活

异步加载,说白了就是「别在主线程上做耗时操作」。这个道理大家都懂,但实际项目中我见过太多翻车案例。

我曾经接手过一个项目,列表数据加载直接在 Activity 的 onCreate 里用 AsyncTask 做网络请求。嗯,你没看错,是 AsyncTask。结果呢?用户快速滑动时,列表一片空白,因为数据还没回来,而 AsyncTask 的线程池已经被占满了。

现在推荐的做法很明确:

  • 网络请求:Retrofit + 协程,或者 OkHttp + 协程
  • 本地数据库:Room + 协程 Flow,或者 SQLDelight
  • 图片加载:Coil 或 Glide,它们内部已经做了异步解码和缓存

来看一个结合 Room 和 Paging3 的典型写法:

@Dao
interface ArticleDao {
    @Query("SELECT * FROM articles ORDER BY publishTime DESC")
    fun getPagedArticles(): PagingSource<Int, Article>
}

// ViewModel 中
class ArticleViewModel(application: Application) : AndroidViewModel(application) {
    val pager = Pager(PagingConfig(pageSize = 20)) {
        database.articleDao().getPagedArticles()
    }.flow.cachedIn(viewModelScope)
}

这里 cachedIn(viewModelScope) 是关键。如果不加,每次配置变更(比如屏幕旋转)都会重新创建 PagingData 流,导致数据重新加载。加上之后,数据会在 ViewModel 的作用域内缓存,旋转屏幕不会丢失。

注意:千万不要在 Fragment 或 Activity 的 lifecycleScope 里调用 cachedIn。因为 Fragment 销毁重建时,旧的 scope 会被取消,缓存也就没了。一定要用 ViewModelScope 或更稳定的 scope。

预加载策略:比用户快一步

预加载,说白了就是「猜用户下一步想干什么」。猜对了,体验丝滑;猜错了,浪费流量和内存。

Paging3 默认的预加载策略是基于「距离阈值」的。当列表滑动到距离底部还有一定距离时,就会触发下一页的加载。这个距离可以通过 PagingConfig.prefetchDistance 配置。

我个人习惯的设置是:

PagingConfig(
    pageSize = 20,
    prefetchDistance = 5,
    enablePlaceholders = false,
    initialLoadSize = 40
)

解释一下这几个参数:

  • pageSize:每页加载多少条。20 是个比较稳妥的值,太少会导致频繁请求,太多会增加单次加载耗时。
  • prefetchDistance:距离底部还有多少条时触发预加载。5 意味着当倒数第 5 条出现在屏幕上时,就开始加载下一页。
  • enablePlaceholders:是否显示占位符。我一般关掉,因为占位符会导致 item 高度必须固定,而实际项目中列表 item 高度往往是动态的。
  • initialLoadSize:首次加载多少条。设为 pageSize 的两倍,可以让用户一进来就看到更多内容,减少「白屏等待」的感觉。

除了 Paging3 自带的预加载,我们还可以做更激进的预加载策略。比如:

  • 图片预加载:当某个 item 即将进入屏幕时,提前加载它的图片。Glide 提供了 preload() 方法。
  • 数据预取:如果用户经常在 Wi-Fi 环境下使用,可以在空闲时提前拉取后面几页的数据缓存到本地。
  • 行为预测:根据用户滑动速度动态调整预加载距离。滑得快就多预加载几页,滑得慢就少加载。

避坑指南:我曾经在一个新闻客户端里做过「滑得越快预加载越多」的策略。结果测试时发现,用户快速滑到列表底部,预加载了 5 页数据,但用户根本没看,直接退出了。白白浪费了流量和服务器资源。

后来我加了一个「可见时间阈值」——只有 item 在屏幕上停留超过 500ms,才认为用户「可能」会看,这时候再触发预加载。效果好了很多。

列表卡顿监控:没有数据,优化就是瞎搞

做性能优化,最怕的就是「我觉得不卡了」。感觉靠不住,得上数据。

列表卡顿监控,我主要关注三个指标:

指标 含义 阈值
FPS 每秒帧数,低于 60 说明有卡顿 < 55 视为卡顿
FrameDrop 单帧绘制耗时超过 16ms 的次数 单帧 > 16ms 即丢帧
Jank 连续多帧丢帧,用户能感知到的卡顿 连续 3 帧以上丢帧

监控手段我推荐两种:

  • Choreographer:Android 自带的帧率回调,可以监听每一帧的绘制时间。缺点是只能拿到当前进程的数据。
  • FrameMetrics:Android 7.0 引入的 API,可以获取更详细的帧信息,包括绘制、布局、输入等各阶段的耗时。

来看一个简单的 FrameMetrics 监控实现:

class JankMonitor(private val window: Window) {
    private val frameMetrics = FrameMetrics()
    private val listener = Window.OnFrameMetricsAvailableListener { _, frameMetrics, _ ->
        val frameDuration = frameMetrics.getMetric(FrameMetrics.TOTAL_DURATION) / 1_000_000
        if (frameDuration > 16) {
            Log.w("JankMonitor", "丢帧: ${frameDuration}ms")
            // 上报到 APM 平台
        }
    }

    fun start() {
        window.addOnFrameMetricsAvailableListener(listener, Handler(Looper.getMainLooper()))
    }

    fun stop() {
        window.removeOnFrameMetricsAvailableListener(listener)
    }
}

实际项目中,我一般不会只监控丢帧,还会记录丢帧时的堆栈信息。这样当卡顿发生时,我能知道是哪个方法拖慢了 UI 线程。比如是某个 View 的 onDraw 太耗时,还是某个回调里做了网络请求。

我的经验:卡顿监控一定要上线,而且要持续看。很多卡顿在开发阶段根本复现不了,只有用户在实际设备上、真实网络环境下才会触发。我见过一个案例,某款低端机在列表滑动时频繁卡顿,最后发现是图片解码时内存不足触发了频繁 GC。这种问题不上线监控根本发现不了。

知识体系总览

我把这一章的核心逻辑画了张图,方便你理解各个模块之间的关系:

列表优化核心逻辑 数据源 网络 / 数据库 / 内存 Paging3 分页库 PagingSource / PagingConfig 预加载策略 prefetchDistance / 行为预测 RecyclerView 展示 PagingDataAdapter + DiffUtil 卡顿监控 FrameMetrics / Choreographer 反馈优化 数据从数据源经过分页、预加载到达 UI,监控层持续检测卡顿并反馈优化

这张图展示了数据从源头到 UI 的完整链路。数据源经过 Paging3 分页处理后,配合预加载策略,最终展示在 RecyclerView 上。同时,卡顿监控模块持续检测 UI 线程的帧率表现,一旦发现丢帧,就反馈给开发者进行优化。

嗯,这一章的内容就到这里。列表优化是个系统工程,数据层、UI 层、监控层缺一不可。希望你能把这些策略真正落地到项目中,而不是停留在「知道」的层面。

专注资料整理