成为全栈·Flutter App 篇·列表分页与下拉刷新:去重、失败保留和返回位置

成为全栈·Flutter App 篇·列表分页与下拉刷新:去重、失败保留和返回位置

做列表页的时候我写过一句很自豪的代码:

dart 复制代码
items.addAll(page.items);      // 翻页合并,一行搞定

上线第三天,用户反馈:"首页文章会重复。"

我一开始不信。服务端分页是干净的,第 1 页 12 条、第 2 页 12 条,去重逻辑能有什么问题?直到我打开日志,看到同一个 id 在列表里出现了两次。

原因不难想象,但暴露出来的东西比这个 bug 本身多得多:下拉刷新和翻页这两件事,方向是相反的。 刷新要"先扔掉旧的",翻页要"先留着旧的";刷新失败要让用户看到原来的内容,翻页失败不该把已加载的全清掉。而我当时把它们当成了同一件事。

这篇讲 Flutter 列表分页里真正会出错的几个地方,以及 flutter-app/lib/shared/widgets/article_feed.dart(307 行)里那些看起来啰嗦、实际都在挡错的代码。

先把四种状态分清楚

翻页列表最容易乱的地方是状态纠缠。至少要分清这四种,它们的处理完全不同:

状态 含义 该做什么
首次加载 列表空,正在拿第 1 页 显示骨架屏
翻页中 已有内容,加载下一页 不清空、不显示 loading
失败(已有内容) 翻页失败 保留已有内容,底部显示重试
失败(首次) 第 1 页就失败 全屏错误 + 重试

第四种和第三种的处理截然不同,这个区分是后面所有逻辑的基础。我一开始把"失败"统一处理成"清空重试",结果用户翻到第 5 页时网络抖了一下,已经加载的 48 篇文章全没了。

而"失败时到底清不清"这件事,答案不是拍脑袋定的。看代码里的判断:

dart 复制代码
} catch (e) {
  if (mounted && ticket == serial && e is! SessionChanged && e is! CacheSuperseded) {
    setState(() {
      error = e;
      if (repo.forbidden(e) ||
          dependencies.any((k) => !repo.cache.usable(k))) {
        items = [];      // ← 只在"重试也没用"时才清空
        page = 0;
      }
    });
  }
}

注意 items = [] 藏在 if 里面,而且条件很具体:只有当错误属于"重试也不会变"的那一类,或者依赖的缓存已经彻底不可用时才清空。

repo.forbidden(e) 就是 M4-07 讲过的那个集合------401/403/404 和几个业务码。这类错误重试一百次也是同样的结果,清空让用户看到一个"无权限/已删除"的空状态,比让他盯着一个永远转不出来的列表要好。而超时、网络抖动这类,forbidden 返回 false,列表内容原封不动保留。

顺带解释那两个被忽略的异常类型:SessionChanged 和 CacheSuperseded 都不该显示错误------前者是会话换代(M4-09),响应当废;后者是请求被更新的请求取代了,旧的响应自然作废。把它们当成错误展示给用户,只会得到"莫名其妙报了个错"。

serial:解决我自己造成的竞态

上面那段 catch 里的 ticket == serial 是什么?先看它怎么来的:

dart 复制代码
final ticket = ++serial;
setState(() {
  busy = true;
  error = null;
});
final repo = ref.read(repositoryProvider);
if (reset) repo.cache.invalidate({repo.feedTag(widget.path, widget.query)});
try {
  final p = await repo.track(
    dependencies,
    () => repo.articles(page: reset || check ? 1 : page + 1, ...),
  );
  if (!mounted || ticket != serial) return;      // ← 过期响应就地丢弃
  setState(() => _applyPage(p, check, reset));

每次 load() 发请求前领一个号(++serial),回来时核对号是不是最新的。不是最新就直接 return,什么都不做。

这是必要的,因为 load() 有好几种触发来源:滚动到底自动加载、下拉刷新、缓存失效事件、返回页面时可见性恢复、手动点"有新内容"。它们可以重叠。

举个真实的翻车场景(我调试这个 bug 花了半小时):

  1. 用户滚到底,触发翻页,load() 领到 serial = 5,请求发出去
  2. 同时用户手动下拉刷新,load() 领到 serial = 6
  3. 刷新请求先回来,正确的第 1 页数据渲染
  4. 翻页请求后回来------它带的是第 2 页的数据,现在渲染出来就成了"第 1 页 + 旧的第 2 页"混排

第 4 步在界面上看,就是文章重复和乱序。没有任何报错,没有任何异常,纯粹是两个异步结果的顺序问题。

serial 是这里最便宜的解法:第 4 步的 ticket(5) != serial(6) 成立,直接丢弃。至于 mounted 检查,那是另一回事------Widget 可能已经被销毁,这时候 setState 会抛异常。

顺带说一句,load() 开头还有两道门:

dart 复制代码
if (busy && !reset) return;
if (pendingUpdate && !reset && !check) return;

busy 防连点,pendingUpdate(有更新待提示)时禁止继续翻页------因为数据可能已经变了,这时候往上叠加旧页只会更乱。注意两处都放行了 reset:用户主动下拉刷新时,任何情况下都应该能打断进行中的加载。

翻页之前先看一眼首屏

这是我在这篇里最想讲的一个设计。看 load() 的开头:

dart 复制代码
final firstKey = repository.key(widget.path, {
  'page': 1, 'pageSize': 12, ...widget.query,
}, private: widget.path.startsWith(Endpoints.privatePrefix));

// 翻下一页前先检查首屏是否变化,阻止旧页混入新的排序结果。
if (page > 0 && !reset && !check && !repository.cache.fresh(firstKey)) {
  await load(check: true, verify: true);
  if (!mounted || pendingUpdate || error != null) return;
}

翻译成人话:已经加载了 3 页,现在要拉第 4 页------先确认第 1 页的数据还是不是最新的。

为什么需要?因为排序会变。设想"热门文章"列表按浏览量排序:

  • 你 10:00 加载了第 1--3 页
  • 10:05,一篇文章爆了,它冲进第 1 页
  • 10:10,你继续下拉翻到第 4 页

服务端此时返回的第 4 页,是按新排序的第 4 页。它的内容和十分钟前"第 4 页"完全不是一回事------中间可能挤进了那篇爆款的文章,结果你列表里出现重复、且顺序错乱。

而"新内容点击更新"的那个提示,就是这么来的:

dart 复制代码
// 私有列表立即同步,公共列表提示更新;追加页按 ID 去重。
void _applyPage(PageResult<Article> p, bool check, bool reset) {
  if (check && widget.path.startsWith(Endpoints.privatePrefix)) {
    final differs = !_samePrefix(p.items);
    if (differs) {
      items = List.of(p.items);      // 私有列表:直接换,不打扰用户
      page = p.page;
      more = p.hasMore;
    }
    pendingUpdate = false;
  } else if (check) {
    pendingUpdate = !_samePrefix(p.items);   // 公共列表:只提示
  } else { ... }
}

私有列表直接同步,公共列表只提示------这个分叉的理由很实际。私有列表(我的收藏、我的投稿)内容变化通常来自用户自己的操作,他已经知道变化了,直接刷新不会困惑;而公共列表(热门、最新)因为排序漂移而变动,用户会觉得莫名其妙------"我又没干什么,怎么顺序变了"。

所以公共列表只在顶部显示一个按钮,不自动替换内容:

dart 复制代码
if (pendingUpdate)
  TextButton(
    onPressed: () => load(reset: true),
    child: const Text('有新内容,点击更新'),
  ),

这是产品判断,不是技术选择。 自动替换会让用户正在阅读的位置失效,也会让他失去"我刚才看到的到底是什么"的感觉。

去重:不是每次都要,但必须有

回到最初那个 items.addAll(page.items) 的 bug。我在 M4-12 开头说它"一行搞定",现在说清楚为什么行不通。

真实情况是:服务端分页通常是有重复风险的,尤其是这些场景------

  • 翻页期间有新文章发布,导致分页偏移移动
  • 上面的排序漂移(热门列表)
  • 用户在别的设备上取消了收藏,集合变化
  • 弱网下用户重复触发加载

所以去重是兜底,不是锦上添花。实现只有一行,但有个细节:

dart 复制代码
final ids = items.map((e) => e.id).toSet();
items.addAll(p.items.where((a) => ids.add(a.id)));

ids.add(a.id) 放在 where 里做过滤------Set.add 返回 true 表示"之前没有",并且顺手把它加进去了。所以这句同时完成了两件事:判断"是不是新的",以及"如果是新的就收进 id 集合"。

比先构造一个新集合再合并要省一次遍历,逻辑还更紧凑。这是那种"看懂了就再也不想改回去"的写法。

注意去重是按 id,不是按整条内容。早期我想过去重整个对象,结果两条内容相同但 id 不同的文章(比如同一篇被不同分类收录)会同时出现------后来发现那是数据本身的问题,不该在列表层掩盖。

比较新旧不能只看条数

pendingUpdate 是靠 _samePrefix 判断的:

dart 复制代码
// 与已展示前缀比较完整摘要,既检测排序也检测内容变化。
bool _samePrefix(List<Article> next) =>
    next.map((a) => a.data.json.toString()).join() ==
    items.take(next.length).map((a) => a.data.json.toString()).join();

注意 items.take(next.length)------只比较已展示的前缀部分,长度不对等也没关系。这正是"检查首屏"的含义:新拿到的第 1 页,和已展示的第 1 页,逐条比对。

比的是 a.data.json.toString(),也就是生成模型里那个原始 JSON 的字符串形式。为什么不比标题、id 这些字段? 因为它们可能都没变但正文变了、发布时间变了、点赞数变了------而排序漂移的情况里,位置变了但字段没变同样需要被检测到。整份 JSON 一起比,才能同时抓到"内容变了"和"顺序变了"两种情况。

这里的 data.json 就是 M4-03 里生成器保留的那个原始引用。当时我把它写进去的理由是"方便以后用到原始字段",现在它成了检测内容变化的依据------生成器的一个看似多余的决策,在下游找到了用途。

代价是这个比较有点贵(字符串化整个 JSON)。但它只在"翻页前检查首屏"时跑,频率不高,而且只在有后续页的时候才跑,首屏加载完全不经过这里。为了这点开销去做增量 diff 优化,不值得。

返回时恢复位置:先恢复,再校验

Tab 切换和页面返回也是一类"回来时数据可能变了"的场景。

先看恢复。initState 里:

dart 复制代码
// 恢复快照后再校验首屏,避免返回页面时先清空列表和滚动位置。
void initState() {
  super.initState();
  controller.addListener(() {
    if (controller.hasClients) savedOffset = controller.offset;
  });
  final repo = repository = ref.read(repositoryProvider);
  snapshotKey = repo.key(widget.path, widget.query,
      private: widget.path.startsWith(Endpoints.privatePrefix));
  final saved = repo.snapshots[snapshotKey];
  final maxAge = repo.policy(widget.path)?.maxAge ?? const Duration(minutes: 1);
  if (saved != null && DateTime.now().difference(saved.saved) < maxAge) {
    savedOffset = saved.offset;
    items = List.of(saved.items);
    page = saved.page;
    more = saved.more;
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (mounted && controller.hasClients) {
        controller.jumpTo(
          saved.offset.clamp(0, controller.position.maxScrollExtent),
        );
      }
    });
  }
  changes = repo.cache.events.listen(onCacheEvent);
  load(check: items.isNotEmpty);
}

三处讲究:

DateTime.now().difference(saved.saved) < maxAge ------ 快照有保质期。 maxAge 直接取端点的缓存策略上限,不是写死的常数。超过 20 分钟的热门列表快照就不用了。

addPostFrameCallback 里才能 jumpTo。 因为那时 ListView 才完成布局,controller.position.maxScrollExtent 才有值。在 initState 里直接调用会抛异常。

clamp(0, controller.position.maxScrollExtent) 是必须的。 恢复的位置可能是 5000px,但如果这期间列表只剩 10 条(内容变了),maxScrollExtent 可能只有 800------不 clamp 就是一次越界跳转,甚至崩溃。这行是真实修过一次 bug 加上的。

保存快照的条件更严格,在 dispose() 里:

dart 复制代码
// 只保存依赖仍可用的分页快照;销毁阶段使用已有仓库引用。
void dispose() {
  final repo = repository;
  if (items.isNotEmpty &&
      page <= 20 &&
      dependencies.isNotEmpty &&
      dependencies.every(repo.cache.usable)) {
    repo.saveSnapshot(
      snapshotKey,
      FeedSnapshot(
        List.of(items), page, more,
        controller.hasClients ? controller.offset : savedOffset,
        resourceTags,
        dependencies.map(repo.cache.savedAt)
            .whereType<DateTime>()
            .fold<DateTime>(DateTime.now(), (a, b) => a.isBefore(b) ? a : b),
      ),
    );
  }
  changes?.cancel();
  controller.dispose();
  super.dispose();
}

三个条件值得说:page <= 20 是防止把 20 页的列表整个塞进内存快照;dependencies.every(repo.cache.usable) 是确认依赖的那些缓存还在,别把指向已清理数据的快照留下;最后那个 fold 是取所有依赖里最早的那个保存时间------只要有一个依赖已经过期,整个快照就该按最早的时间算,因为快照的有效性取决于它最不新鲜的那块。

dependencies 是从哪来的?M4-07 讲过的 track:

dart 复制代码
final p = await repo.track(dependencies, () => repo.articles(...));

它记录这个列表实际读过哪些缓存键 ,这样缓存失效事件来了可以精确判断"是否和我有关"。没有它,onCacheEvent 里的 dependencies.contains(e.key) 就没法工作。

什么时候自动加载下一页

最后一个实现细节,滚到底的判定:

dart 复制代码
child: NotificationListener<ScrollNotification>(
  onNotification: (n) {
    if (n.metrics.extentAfter < 350 &&
        more &&
        !busy &&
        !pendingUpdate &&
        error == null) {
      load();
    }
    return false;
  },

五个条件同时成立才加载。350 是"距离底部还有 350 逻辑像素时预取"------不用滚到底才加载,用户感知上更顺。

return false 表示不拦截事件 ,让 RefreshIndicator 和 Scrollable 继续处理。这是 NotificationListener 的约定:返回 true 会吃掉事件,下拉刷新就失效了。

其余四个条件里,error == null 和 pendingUpdate 是熔断 ------出错或有更新待处理时停止追加,避免在不稳定的状态上继续叠加数据。!busy 防止重复触发。

这套东西该测什么

分页逻辑的测试重点不在"能不能加载",在几个具体的边界:

场景 该验证什么
翻页时首屏变化 出现"有新内容"提示,且不自动替换
两次 load 重叠 旧响应被 serial 丢弃,列表不重复
翻页失败 已有内容保留,底部出现重试
第 1 页失败 清空并显示全屏错误
快照恢复 位置正确,且内容已过期的快照不被使用
返回时位置越界 clamp 生效,不崩溃

其中"两次 load 重叠"最难写,因为需要控制两个请求的完成顺序。用一个可控的假 Repository(延迟可以设置)比 mock 真实网络可靠得多。

顺带说一句:这些测试的可行性来自上一轮的设计。 ArticleFeed 依赖的是 ref.read(repositoryProvider),不是自己 new 出来的------所以测试时能替换成一个假实现。如果它内部直接 Repository(),一个可控的分页测试都写不出来。

一条我一开始想省掉的事

load() 开头那个"翻页前检查首屏"的分支,包括 await load(check: true, verify: true) 再判断 pendingUpdate || error != null 就返回------一共九行。

我最初的判断是:用户手动刷新一下就好了,何必每次翻页都多一个请求?

后来在真机上测试排序漂移的场景,遇到了"文章重复"和"顺序错乱"两个投诉。而且有个细节很致命:用户往往是连续快速下拉的 ,这时候多次 check 会叠加成额外请求风暴。

所以现在这一步是有条件的 :只在 page > 0(已经有后续页)且首屏缓存已经不新鲜时才做。首屏加载完全不经过它。而且它用的 check 请求命中的是已有缓存(force: verify),通常不会真的打到网络。

判断这类"看起来多余的检查"该不该留,我的标准是:它挡住的 bug 是否需要用户主动、快速、且带特定操作序列才能触发。 如果一个 bug 只有在"用户连续快速下拉 5 次"时才出现,测试环境几乎复现不了,但它在真实使用中天天发生------那这个防御就值得留着。

小结

列表分页看起来是最简单的功能,实际上它是并发、缓存、状态机和交互判断四件事的交汇处。

这篇涉及的不变量,可以浓缩成四条:

  1. 失败清不清,取决于"重试会不会变" ------ 只有 forbidden 那类错误才清空已有内容。
  2. 任何异步结果回来都要核对它是否还属于当前状态 ------ serial 票据是最便宜的做法。
  3. 合并数据前先确认基准没变 ------ 排序会漂移,追加页之前要检查首屏。
  4. 去重是兜底,不是优化 ------ 用 Set.add 的返回值顺手做过滤。

第四条最容易被当成"这个功能不需要那么讲究",但它恰好是用户最先报的那个 bug。报错快的 bug 通常不是最严重的,但也不该被当成小事。

下一篇讲点赞收藏这类写操作的乐观更新------比列表更进一步的地方在于,它要处理"操作已经成功了,但界面还没来得及更新"的情况。

延伸阅读


如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏 「成为全栈」:

🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html

📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer

相关推荐
大龄秃头程序员3 小时前
【从一道面试题入手】深入理解 Dart 事件循环调度机制
flutter
恋猫de小郭4 小时前
Kotlin 2.5 的 Companion 改造,对 KMP 的重要升级
android·前端·flutter
程序员老刘20 小时前
Flutter你看看人家RN
flutter·react native·ai编程
大龄秃头程序员1 天前
【Flutter Bloc 从 0 到 1】用最少代码跑通 Bloc
flutter
m0_738185821 天前
Flutter 鸿蒙化实战:sqflite 适配 OpenHarmony,本地 SQLite 数据库
flutter·华为·harmonyos·鸿蒙
梦曦i1 天前
uni-app、uni-app x 与 Flutter 深度对比:2026年跨平台开发框架选型指南
flutter·uni-app·uni-app x
恋猫de小郭1 天前
Flitz ,一个把 Flutter 玩成热插拔 Preview 的有趣项目,不错的思路
android·前端·flutter
FungLeo1 天前
成为全栈·Flutter App 篇·Refresh Token 旋转与并发 401:安全存储与会话代次
flutter·jwt·移动安全·身份认证·token刷新·成为全栈