网络与数据篇(4/6):列表分页与并发请求优化

6 阅读4分钟

列表分页与并发请求优化:把「滑到底就卡」变成可测算的工程题

系列:网络与数据篇(4/6)|建议标签:Flutter 分页 并发 Dio 性能


1. 问题背景:业务场景 + 现象

  • 场景:房间列表、排行榜、消息流、订单列表等「长列表 + 分页接口」。
  • 现象
    • 快速滑到底连续触发多次 loadMore,接口顺序乱序返回,列表闪动、重复、缺页
    • 下拉刷新与加载更多同时进行,同一页数据被请求两遍,列表状态被后到的响应覆盖。
    • Tab 切换或筛选条件变更时,旧请求晚到,新条件下列表被脏数据污染
    • 弱网时触发重试或用户连点,请求风暴打满线程池,整机发热、帧率掉。

2. 原因分析:核心原理 + 排查过程

2.1 核心原理

  • 分页本质是 有序异步序列:第 N 页必须在与「当前列表基准版本」一致的前提下合并。
  • 客户端并发不是原罪,**缺乏「代数合并规则」和「代数取消/忽略」**才是。
  • Flutter 列表卡顿往往来自:重复 setState / 大面积 diff + 主线程上过重 JSON 解析(这两点要和网络并发分开看)。

2.2 排查过程(建议顺序)

  1. 打点请求page/cursor/filterKey/requestId、开始时间、结束时间、是否被取消。
  2. 对比返回顺序:是否出现「页码倒序落地」。
  3. 查重复触发ScrollController 的阈值是否过浅、build 里是否无意调用 loadMore
  4. 查合并逻辑:是否在异步回调里直接用闭包外的 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 + 低端机基准场景无请求风暴时不应异常飙升

日志示例字段geninFlightcursordurationMscanceled


6. 可复用结论:通用经验 + 避坑清单

通用经验

  1. 分页合并是 状态机问题,不是网络 API 问题;先定「何谓当前列表版本」,再谈并发。
  2. 取消 + 代数 常成对出现:取消解决资源浪费,代数解决「已发出无法取消的包」。
  3. 列表性能:控制 单次合并的数据量、避免在 build 链路里解析大 JSON;大列表用合理 itemExtent / 懒加载。

避坑清单

  • loadMore 绑在过度敏感的滚动回调上且无节流。
  • 仅用 _loading 标志,但允许两个不同条件请求交叉(还需 gen/cancel)。
  • 合并时用「本地 page++」而不是「服务端 nextCursor」。
  • 全局线程池里并行解析超大页,导致 UI jank(应分页解析或 isolate,视体量而定)。

下期预告(5/6):接口幂等、重试、超时与降级——何时重试等于事故放大器,以及如何和分页代数协同。