成为全栈·Flutter App 篇·列表分页与下拉刷新去重、失败保留和返回位置做列表页的时候我写过一句很自豪的代码items.addAll(page.items);// 翻页合并一行搞定上线第三天用户反馈“首页文章会重复。”我一开始不信。服务端分页是干净的第 1 页 12 条、第 2 页 12 条去重逻辑能有什么问题直到我打开日志看到同一个 id 在列表里出现了两次。原因不难想象但暴露出来的东西比这个 bug 本身多得多下拉刷新和翻页这两件事方向是相反的。刷新要先扔掉旧的翻页要先留着旧的刷新失败要让用户看到原来的内容翻页失败不该把已加载的全清掉。而我当时把它们当成了同一件事。这篇讲 Flutter 列表分页里真正会出错的几个地方以及flutter-app/lib/shared/widgets/article_feed.dart307 行里那些看起来啰嗦、实际都在挡错的代码。先把四种状态分清楚翻页列表最容易乱的地方是状态纠缠。至少要分清这四种它们的处理完全不同状态含义该做什么首次加载列表空正在拿第 1 页显示骨架屏翻页中已有内容加载下一页不清空、不显示 loading失败已有内容翻页失败保留已有内容底部显示重试失败首次第 1 页就失败全屏错误 重试第四种和第三种的处理截然不同这个区分是后面所有逻辑的基础。我一开始把失败统一处理成清空重试结果用户翻到第 5 页时网络抖了一下已经加载的 48 篇文章全没了。而失败时到底清不清这件事答案不是拍脑袋定的。看代码里的判断}catch(e){if(mountedticketserialeis!SessionChangedeis!CacheSuperseded){setState((){errore;if(repo.forbidden(e)||dependencies.any((k)!repo.cache.usable(k))){items[];// ← 只在重试也没用时才清空page0;}});}}注意items []藏在if里面而且条件很具体只有当错误属于重试也不会变的那一类或者依赖的缓存已经彻底不可用时才清空。repo.forbidden(e)就是 M4-07 讲过的那个集合——401/403/404 和几个业务码。这类错误重试一百次也是同样的结果清空让用户看到一个无权限/已删除的空状态比让他盯着一个永远转不出来的列表要好。而超时、网络抖动这类forbidden返回 false列表内容原封不动保留。顺带解释那两个被忽略的异常类型SessionChanged和CacheSuperseded都不该显示错误——前者是会话换代M4-09响应当废后者是请求被更新的请求取代了旧的响应自然作废。把它们当成错误展示给用户只会得到莫名其妙报了个错。serial解决我自己造成的竞态上面那段 catch 里的ticket serial是什么先看它怎么来的finalticketserial;setState((){busytrue;errornull;});finalreporef.read(repositoryProvider);if(reset)repo.cache.invalidate({repo.feedTag(widget.path,widget.query)});try{finalpawaitrepo.track(dependencies,()repo.articles(page:reset||check?1:page1,...),);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()开头还有两道门if(busy!reset)return;if(pendingUpdate!reset!check)return;busy防连点pendingUpdate有更新待提示时禁止继续翻页——因为数据可能已经变了这时候往上叠加旧页只会更乱。注意两处都放行了reset用户主动下拉刷新时任何情况下都应该能打断进行中的加载。翻页之前先看一眼首屏这是我在这篇里最想讲的一个设计。看load()的开头finalfirstKeyrepository.key(widget.path,{page:1,pageSize:12,...widget.query,},private:widget.path.startsWith(Endpoints.privatePrefix));// 翻下一页前先检查首屏是否变化阻止旧页混入新的排序结果。if(page0!reset!check!repository.cache.fresh(firstKey)){awaitload(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 页完全不是一回事——中间可能挤进了那篇爆款的文章结果你列表里出现重复、且顺序错乱。而新内容点击更新的那个提示就是这么来的// 私有列表立即同步公共列表提示更新追加页按 ID 去重。void_applyPage(PageResultArticlep,bool check,bool reset){if(checkwidget.path.startsWith(Endpoints.privatePrefix)){finaldiffers!_samePrefix(p.items);if(differs){itemsList.of(p.items);// 私有列表直接换不打扰用户pagep.page;morep.hasMore;}pendingUpdatefalse;}elseif(check){pendingUpdate!_samePrefix(p.items);// 公共列表只提示}else{...}}私有列表直接同步公共列表只提示——这个分叉的理由很实际。私有列表我的收藏、我的投稿内容变化通常来自用户自己的操作他已经知道变化了直接刷新不会困惑而公共列表热门、最新因为排序漂移而变动用户会觉得莫名其妙——“我又没干什么怎么顺序变了”。所以公共列表只在顶部显示一个按钮不自动替换内容if(pendingUpdate)TextButton(onPressed:()load(reset:true),child:constText(有新内容点击更新),),这是产品判断不是技术选择。自动替换会让用户正在阅读的位置失效也会让他失去我刚才看到的到底是什么的感觉。去重不是每次都要但必须有回到最初那个items.addAll(page.items)的 bug。我在 M4-12 开头说它一行搞定现在说清楚为什么行不通。真实情况是服务端分页通常是有重复风险的尤其是这些场景——翻页期间有新文章发布导致分页偏移移动上面的排序漂移热门列表用户在别的设备上取消了收藏集合变化弱网下用户重复触发加载所以去重是兜底不是锦上添花。实现只有一行但有个细节finalidsitems.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判断的// 与已展示前缀比较完整摘要既检测排序也检测内容变化。bool_samePrefix(ListArticlenext)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里// 恢复快照后再校验首屏避免返回页面时先清空列表和滚动位置。voidinitState(){super.initState();controller.addListener((){if(controller.hasClients)savedOffsetcontroller.offset;});finalreporepositoryref.read(repositoryProvider);snapshotKeyrepo.key(widget.path,widget.query,private:widget.path.startsWith(Endpoints.privatePrefix));finalsavedrepo.snapshots[snapshotKey];finalmaxAgerepo.policy(widget.path)?.maxAge??constDuration(minutes:1);if(saved!nullDateTime.now().difference(saved.saved)maxAge){savedOffsetsaved.offset;itemsList.of(saved.items);pagesaved.page;moresaved.more;WidgetsBinding.instance.addPostFrameCallback((_){if(mountedcontroller.hasClients){controller.jumpTo(saved.offset.clamp(0,controller.position.maxScrollExtent),);}});}changesrepo.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()里// 只保存依赖仍可用的分页快照销毁阶段使用已有仓库引用。voiddispose(){finalreporepository;if(items.isNotEmptypage20dependencies.isNotEmptydependencies.every(repo.cache.usable)){repo.saveSnapshot(snapshotKey,FeedSnapshot(List.of(items),page,more,controller.hasClients?controller.offset:savedOffset,resourceTags,dependencies.map(repo.cache.savedAt).whereTypeDateTime().foldDateTime(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 讲过的trackfinalpawaitrepo.track(dependencies,()repo.articles(...));它记录这个列表实际读过哪些缓存键这样缓存失效事件来了可以精确判断是否和我有关。没有它onCacheEvent里的dependencies.contains(e.key)就没法工作。什么时候自动加载下一页最后一个实现细节滚到底的判定child:NotificationListenerScrollNotification(onNotification:(n){if(n.metrics.extentAfter350more!busy!pendingUpdateerrornull){load();}returnfalse;},五个条件同时成立才加载。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 通常不是最严重的但也不该被当成小事。下一篇讲点赞收藏这类写操作的乐观更新——比列表更进一步的地方在于它要处理操作已经成功了但界面还没来得及更新的情况。延伸阅读Dio Repository统一响应信封与模型适配用真实 API 构建首页焦点、最新与热门内容列表页范式让分页、筛选和返回位置进入 URL列表接口三件套分页、筛选、排序如果这篇文章对你有帮助欢迎订阅我的 CSDN 专栏「成为全栈」 专栏地址https://blog.csdn.net/fungleo/category_13204651.html 本系列配套代码仓库https://github.com/fengcms/become-a-full-stack-developer