成为全栈·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 花了半小时):
- 用户滚到底,触发翻页,
load()领到serial = 5,请求发出去 - 同时用户手动下拉刷新,
load()领到serial = 6 - 刷新请求先回来,正确的第 1 页数据渲染
- 翻页请求后回来------它带的是第 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 次"时才出现,测试环境几乎复现不了,但它在真实使用中天天发生------那这个防御就值得留着。
小结
列表分页看起来是最简单的功能,实际上它是并发、缓存、状态机和交互判断四件事的交汇处。
这篇涉及的不变量,可以浓缩成四条:
- 失败清不清,取决于"重试会不会变" ------ 只有
forbidden那类错误才清空已有内容。 - 任何异步结果回来都要核对它是否还属于当前状态 ------
serial票据是最便宜的做法。 - 合并数据前先确认基准没变 ------ 排序会漂移,追加页之前要检查首屏。
- 去重是兜底,不是优化 ------ 用
Set.add的返回值顺手做过滤。
第四条最容易被当成"这个功能不需要那么讲究",但它恰好是用户最先报的那个 bug。报错快的 bug 通常不是最严重的,但也不该被当成小事。
下一篇讲点赞收藏这类写操作的乐观更新------比列表更进一步的地方在于,它要处理"操作已经成功了,但界面还没来得及更新"的情况。
延伸阅读
如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏 「成为全栈」:
🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html
📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer
