列表分页与并发请求优化:把「滑到底就卡」变成可测算的工程题
系列:网络与数据篇(4/6)|建议标签:
Flutter分页并发Dio性能
1. 问题背景:业务场景 + 现象
- 场景:房间列表、排行榜、消息流、订单列表等「长列表 + 分页接口」。
- 现象:
- 快速滑到底连续触发多次
loadMore,接口顺序乱序返回,列表闪动、重复、缺页。 - 下拉刷新与加载更多同时进行,同一页数据被请求两遍,列表状态被后到的响应覆盖。
- Tab 切换或筛选条件变更时,旧请求晚到,新条件下列表被脏数据污染。
- 弱网时触发重试或用户连点,请求风暴打满线程池,整机发热、帧率掉。
- 快速滑到底连续触发多次
2. 原因分析:核心原理 + 排查过程
2.1 核心原理
- 分页本质是 有序异步序列:第 N 页必须在与「当前列表基准版本」一致的前提下合并。
- 客户端并发不是原罪,**缺乏「代数合并规则」和「代数取消/忽略」**才是。
- Flutter 列表卡顿往往来自:重复 setState / 大面积 diff + 主线程上过重 JSON 解析(这两点要和网络并发分开看)。
2.2 排查过程(建议顺序)
- 打点请求:
page/cursor/filterKey/requestId、开始时间、结束时间、是否被取消。 - 对比返回顺序:是否出现「页码倒序落地」。
- 查重复触发:
ScrollController的阈值是否过浅、build里是否无意调用loadMore。 - 查合并逻辑:是否在异步回调里直接用闭包外的
currentPage++,而不是以服务器返回值为准。
3. 解决方案:方案对比 + 最终选择
| 方案 | 做法 | 优点 | 风险/成本 |
|---|---|---|---|
| A. 全局串行队列 | 同一时间只允许一个分页请求 | 最稳 | 吞吐差,弱网体验钝 |
| B. 允许并发 + 序号校验收包 | 每次请求带 requestGeneration,仅处理最新一代 | 体验与吞吐兼顾 | 要统一状态机,代码纪律要求高 |
| C. 防抖/节流 | loadMore 200ms 内合并 | 实现快 | 挡不住乱序与脏数据,只能缓解 |
最终选择(推荐):B 为主(代数/版本门闩),必要时对「加载更多」做轻量节流;刷新与筛选用 cancelToken 或代数丢弃 二选一或组合。
补充:
- cursor / page 两种分页统一成「下一页参数由服务端给出」,客户端少维护隐式页码。
- 预取:距底部还有 N 项时触发,比「贴在底部才请求」更顺滑,但要限制在飞请求数。
4. 关键代码:最小必要代码片段
4.1 用「代数」忽略过期响应(核心思想)
class PagedListController<T> {
int _gen = 0;
/// filter 或刷新时调用
void bumpGeneration() => _gen++;
Future<void> loadNext() async {
final myGen = _gen;
// final resp = await api.fetch(..., cancelToken: token);
// if (myGen != _gen) return; // 代数不一致:整段合并丢弃
}
}
要点:比较的是「发起时快照的 gen」与「当前 gen」,而不是比较页码。
4.2 与 Dio CancelToken 组合(筛选/刷新场景)
CancelToken? _listCancel;
Future<void> refreshList() async {
_listCancel?.cancel('refreshed');
_listCancel = CancelToken();
final token = _listCancel!;
try {
// await dio.get('/items', cancelToken: token);
} on DioException catch (e) {
if (CancelToken.isCancel(e)) return;
rethrow;
}
}
要点:新请求取消旧请求适合「同一资源位」强一致;与代数法可同时使用(取消减负,代数防竞态)。
4.3 防止 loadMore 重入(简单可靠)
bool _loadingMore = false;
Future<void> loadMore() async {
if (_loadingMore || !_hasMore) return;
_loadingMore = true;
try {
// await fetch...
} finally {
_loadingMore = false;
}
}
要点:这是 互斥;若仍要并发预取,把互斥升级为「在飞请求计数 + 最大并发 1~2」。
5. 效果验证:数据/截图/日志
建议用三类指标验收:
| 指标 | 怎么看 | 预期 |
|---|---|---|
| 乱序率 | 日志里 page/cursor 与 UI 当前尾游标是否一致 | 生产环境趋近 0 |
| 重复请求比 | 同一 filterKey 短时间 duplicate 次数 | 明显下降 |
| 帧率与耗电 | DevTools Timeline + 低端机基准场景 | 无请求风暴时不应异常飙升 |
日志示例字段:gen、inFlight、cursor、durationMs、canceled。
6. 可复用结论:通用经验 + 避坑清单
通用经验
- 分页合并是 状态机问题,不是网络 API 问题;先定「何谓当前列表版本」,再谈并发。
- 取消 + 代数 常成对出现:取消解决资源浪费,代数解决「已发出无法取消的包」。
- 列表性能:控制 单次合并的数据量、避免在
build链路里解析大 JSON;大列表用合理itemExtent/ 懒加载。
避坑清单
-
loadMore绑在过度敏感的滚动回调上且无节流。 - 仅用
_loading标志,但允许两个不同条件请求交叉(还需 gen/cancel)。 - 合并时用「本地 page++」而不是「服务端 nextCursor」。
- 全局线程池里并行解析超大页,导致 UI jank(应分页解析或 isolate,视体量而定)。
下期预告(5/6):接口幂等、重试、超时与降级——何时重试等于事故放大器,以及如何和分页代数协同。