列表优化(下):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,结果刷新后列表跳到了第一页,用户体验很差。正确的做法是返回当前锚点位置所在的页码,这样刷新后还能保持在当前浏览位置附近。
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 的作用域内缓存,旋转屏幕不会丢失。
预加载策略:比用户快一步
预加载,说白了就是「猜用户下一步想干什么」。猜对了,体验丝滑;猜错了,浪费流量和内存。
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 太耗时,还是某个回调里做了网络请求。
知识体系总览
我把这一章的核心逻辑画了张图,方便你理解各个模块之间的关系:
这张图展示了数据从源头到 UI 的完整链路。数据源经过 Paging3 分页处理后,配合预加载策略,最终展示在 RecyclerView 上。同时,卡顿监控模块持续检测 UI 线程的帧率表现,一旦发现丢帧,就反馈给开发者进行优化。
嗯,这一章的内容就到这里。列表优化是个系统工程,数据层、UI 层、监控层缺一不可。希望你能把这些策略真正落地到项目中,而不是停留在「知道」的层面。