☰
成为全栈·Flutter App 篇·点赞、收藏与阅读历史:乐观更新和失败回退
2026/10/15 18:57:50 网站建设 项目流程

成为全栈·Flutter App 篇·点赞、收藏与阅读历史:乐观更新和失败回退

点一下点赞按钮,等接口返回再改图标——这是我最早的写法。

测的时候觉得别扭:按钮点下去之后什么都没发生,要等三四百毫秒才有反应。在网络好的时候不明显,一到弱网就变成了"App 卡了"。

于是改成乐观更新:点下去立刻变成已赞,接口成功就完事,失败就回滚。

然后我遇到了真正的麻烦——失败回滚比不优化还麻烦。

这篇讲features/data/reaction_store.dart那八十五行,以及一个我一开始完全没想到的约束:点赞状态和点赞数量必须成对修改。

乐观更新的三个步骤

先把流程说清楚,后面所有细节都挂在这上面:

1. 本地立即改成新状态 → 用户立刻看到反馈 2. 发请求,同时记住原值 → 为了能回滚 3a. 成功 → 用服务端返回值校准 → 可能和本地值不同 3b. 失败 → 把原值写回去 → 用户看到回滚

第3 步的"成功"分支容易被忽略。服务端返回的likeCount可能和你本地算的不一样——比如这篇在你操作期间被别人点了。所以成功之后要用服务端值覆盖,而不是"保持我本地这个值"。

现在看ReactionStore的核心:

/// 点赞和收藏的乐观值跨页面共享;失败时由调用方提交原值回滚。classReactionStore{finalreactions=<int,Map<String,dynamic>>{};finalevents=StreamController<int>.broadcast();voidset(int id,{bool?liked,int?count,bool?favorite,bool local=true,}){finalold=reactions[id]??{};...reactions[id]={...old,'savedAt':DateTime.now().millisecondsSinceEpoch,'liked':?liked,'likeCount':?count,'favorite':?favorite,};

那个?liked是 Dart 的 null-aware 合并——参数是 null 就保留old里的值,不是 null 就覆盖。

为什么需要这个?因为set这个方法被三种不同场景调用:

调用场景传什么
点赞成功回写liked + count
收藏成功favorite(不传点赞字段)
读列表时同步基线liked + count +local: false

如果没有?,一次收藏操作会把点赞状态清空。因为...old之后直接写'liked': liked,而liked是 null。

这也是 M4-03 那个"生成模型所有字段可空"决策的延续——可空在这里不是缺陷,是让"部分更新"成为可能。

为什么状态和数量必须成对

现在讲那个我一开始忽略的关键约束。看这段:

// 点赞状态与数量必须成对种入,防止单独更新计数破坏状态一致性。if(local&&liked!=null&&count!=null){finalpath=Endpoints.likeStatus(id);cache.seed(key(path,const{},private:true),{'liked':liked,'likeCount':count},policy(path)!,resourceTags(path),);}

liked != null && count != null—— 少一个都不种入缓存。

为什么这么严?因为点赞状态缓存(likeStatus那个端点)会返回liked和likeCount两个字段,而 UI 显示 “12 赞” 时通常需要判断:“我赞过吗”。

现在考虑只更新数量的情况:假设用户取消点赞,liked变 false,likeCount从 13 变 12。如果只种入{'likeCount': 12},那缓存里的liked就成了null 或者过期值。

下次进文章页,UI 读liked拿到 null —— 按 falsy 判断显示"未赞",看起来是对的。但如果用户刚才点的其实是"点赞",那个值是 true,UI 会显示"未赞"而服务端是已赞——状态彻底错乱。

而且这种错误特别难查:列表页显示正常(列表是patch改的),文章详情页显示不对(从缓存读的)。两个页面不一致。

所以规则是:要么两个字段一起更新并种入缓存,要么两个都不更新。宁可让缓存旧一点,也不能让它自相矛盾。

收藏的favoriteOverrides没有这个约束,因为它只有一个字段,不存在"部分更新"的问题:

if(favoriteOverrides.length>=CacheLimits.favoriteOverrides&&!favoriteOverrides.containsKey(id)){favoriteOverrides.remove(favoriteOverrides.keys.first);}if(local&&favorite!=null){favoriteOverrides[id]=(favorite,DateTime.now());}

注意容量上限的淘汰逻辑和reactions一样,都是"满了就删最早那个"。这是个简单但有效的内存保护——因为这两个 Map 都是只增不减的(除非你显式移除),用户翻几百篇文章就会留下几百个条目。

patch:递归改所有页面里的那一项

用户点赞后,首页列表、搜索结果、分类页、收藏页里显示这篇文章的地方,数字都得变。

最笨的做法是每个页面各自维护状态。但那意味着十几处代码要同步,而且漏一处就出现"列表显示 12,详情显示 13"。

现在的做法是递归改缓存:

// 仅修改具有文章标题的摘要,避免误改评论等同名 id 对象。if(count!=null){dynamicpatch(dynamicvalue){if(valueisList)returnvalue.map(patch).toList();if(valueisMap){return{for(finaleinvalue.entries)e.key:e.key=='likeCount'&&value['id']==id&&value.containsKey('title')?count:patch(e.value),};}returnvalue;}cache.patch(patch);}

这个patch函数是个递归遍历器:遇到 List 就逐项处理,遇到 Map 就检查每个键。找到符合条件的likeCount就替换,否则继续往里递归。

if (value is Map)那个分支里的三元表达式有点绕,拆开看:

e.key=='likeCount'&&// 这是点赞数字段value['id']==id&&// 属于这一篇value.containsKey('title')// 而且它是文章摘要?count// → 替换:patch(e.value)// → 否则继续递归

value.containsKey('title')这个条件是本篇里最容易被忽略、也最重要的一行。

因为id 在不同资源里会重复。文章的 id 是 42,某条评论的 id 也可能是 42(不同表各自自增)。如果不加这个判断,用户给文章点赞之后,评论区里那条 id=42 的评论的某个字段也会被改掉。

加上containsKey('title')就限定了:只有文章摘要(它有标题)才会被改。评论、用户、分类这些对象即使 id 撞上了也不受影响。

注释里那句"避免误改评论等同名 id 对象"写的就是这件事。

而页面这边只需要订阅事件:

// 所有页面订阅同一文章事件,使详情与列表立即反映乐观结果。events.add(id);
Stream<int>getreactionEvents=>reactionStore.events.stream;

为什么用StreamController.broadcast()?因为一个事件要通知所有在显示这篇文章的页面,不只是最后一个。普通(非广播)的 Stream 只能有一个监听者,第二个订阅会直接抛异常。

事件里带的是文章 id而不是"某篇文章变了"这种模糊信息——页面收到后能判断"这正是我显示的这一篇吗",不相关就忽略。这避免了"任何互动都刷新整个页面"。

local 这个参数是干什么的

set有个local = true的参数,注释写得很清楚:

// local=false 表示读取基线,只更新展示值而不续期互动状态缓存。

区别在两个地方:

if(local&&liked!=null&&count!=null){cache.seed(...);}// ← 写缓存if(local&&favorite!=null){favoriteOverrides[id]=...;}// ← 记 override

而reactions[id] = {...}那一段是无论 local 是什么都执行的——它只改内存里的展示值。

所以:

调用local内存展示值缓存override
点赞成功回写true更新更新—
收藏成功true更新—更新
读取列表同步基线false更新不更新不更新

为什么读列表要用local: false?因为服务端返回的基线数据不应该当成"新鲜结果"。

想一下:如果列表返回说某篇文章likeCount: 12,于是我们seed进缓存并开始两分钟的 fresh 期——但这个数据其实是几秒前的(用户可能在别处刚点过)。把它当成新鲜数据,会让真正的新数据进不来。

用local: false,等于说"我知道这个数,用来显示,但我不知道它有多新,所以不延长缓存寿命"。缓存的时效仍然由它原本的策略决定。

这个参数体现的是一个很细的判断:数据的"来源"和数据的"展示"是两件事。从服务端读来的数据适合显示,但不适合用来续期缓存——因为读的那一刻它就已经旧了。

失败回滚:真正麻烦的部分

现在说开头那个问题:为什么回滚比不优化还麻烦。

失败时的操作看起来很简单——“把原值写回去”:

// 调用方逻辑:失败时用操作前的值再调一次 setfinalbefore=repository.reactions[id]?.['liked'];// ... 发送请求,失败 →repository.setReaction(id,liked:before,local:false);

但这里有三个真实的坑。

第一,操作期间用户可能又点了一次。

时序:用户点赞 → 乐观变已赞 → 用户取消点赞 → 第一个请求失败 → 回滚成"已赞"。

但用户的真实意图是"取消点赞"。这个回滚把状态改回了一个用户已经放弃的状态。

正确的做法是回滚到上一次成功的状态,而不是"这次操作之前的状态"。所以现在多记了一个savedAt:

'savedAt':DateTime.now().millisecondsSinceEpoch,

这个字段暂时还没有被真正用起来——我原本计划用它做时序判断,但发现还需要配合请求队列才能做对。目前它只是被记录下来。我留着它是因为它便宜,而且将来补时序判断时不用重新改数据结构。

第二,回滚本身可能失败。回滚调用set也会走缓存更新,而缓存写盘可能失败。这时候内存和磁盘就不一致了。

现在的处理是内存优先——reactions是内存 Map,一定能改。缓存写失败只影响下次启动后的状态,不影响当前界面。

第三,也是最麻烦的:回滚时要清 override。

收藏有个favoriteOverrides,记录"乐观值 + 时间"。为什么需要它?

因为服务端不知道用户刚刚收藏了什么。所以下次从服务端同步收藏列表时,这个文章可能还没出现在列表里——如果同步时直接用服务端数据覆盖,用户会看到收藏"自己消失了"。

favoriteOverrides就是在这段时间差里保护用户操作的:服务端列表 + 本地 override 合并,override 优先。

那么回滚时就要注意:取消收藏要移除 override,失败回滚要恢复 override。这个 override 的生命周期管理,是我在这个功能里花时间最多的地方。

为什么点击不 debounce

用户连点 10 次会发 10 个请求。这个问题我想过要不要 debounce。

现在的判断是不做 debounce,但做禁用:

// 发送中禁用按钮SubmitButton(busy:sending,onPressed:send)

理由是点赞是个切换操作,连点 10 次的真实意图就是切换 10 次(或者用户不知道已经点上了)。debounce 会把后 9 次吞掉,那用户点 10 次之后的状态和他想要的完全相反。

而"点错了立刻再点一次"这个场景,用户的真实意图是"取消刚才那一下",所以应该允许连点。

换成"发布评论"就不一样了——那个操作是幂等的创建,连点会发多个请求创建多条内容,那里必须防。同是写操作,语义不同,策略就不同(M4-08 讲过这张表)。

验收该测什么

互动功能的测试比一般功能难,因为要模拟失败。关键用例:

场景该验证
点赞成功后被他人点赞数字被服务端值校准,不是本地 +1
点赞请求失败状态回滚,图标恢复
列表页和详情页同时显示两处数字一致
id 撞车(文章 42 / 评论 42)评论没被改
收藏后立刻刷新override 生效,收藏没"消失"
读取列表(local=false)不延长缓存寿命

其中"id 撞车"那条特别值得说。它是我在写测试时才想到的——写测试时我故意造了个评论 id 等于文章 id,然后发现 patch 会误伤。这是个纯靠想很难想出来、但一旦发生就很难查的 bug。

一条我一开始想省掉的事

patch那个递归函数里,除了containsKey('title')判断,还有一个不太起眼的东西:

if(valueisList)returnvalue.map(patch).toList();

它返回了一个新 List,而不是原地修改。

看起来多了一次拷贝,性能上是浪费。但缓存里的数据是可能被别处共享引用的——如果原地修改了一个 List,而另一个页面的 Widget 还持有那个 List 的引用,它会在不重建的情况下突然变了。

Flutter 里这种"数据在眼皮底下变了但界面没更新"的现象极难排查:你 debug 打印,数据是对的,可屏幕就是旧的。

所以现在宁可多拷贝一次,换来"数据不可变"这个心智模型。在这个项目里,缓存数据被当作不可变值来对待,这是 M4-13 那个"Widget 不可变所以缓存要跟着失效"结论的延伸。

同一条原则在reactions[id] = {...old, ...}里也体现着——每次都是构造新 Map,而不是改旧的。

小结

乐观更新的核心流程只有三步,但把它做对的约束有六条:

  1. 回滚到上一次成功状态,不是本次操作前的状态——用户可能又点了一次。
  2. 服务端返回值优先于本地计算值——操作期间可能有别人也在动。
  3. 状态和数量必须成对更新——否则缓存自相矛盾,两个页面显示不一致。
  4. patch 要限定对象类型——id 会撞车,containsKey('title')是护栏。
  5. override 管理乐观值的生命周期——解决"服务端还不知道用户刚操作过"的时间差。
  6. 不 debounce——切换语义下连点是有意义的用户意图。

其中第 3 和第 4 条是我在写测试时才想清楚的,都属于"看起来能跑、实际会错"的问题。它们的共同点是:错误发生在两个本该一致的东西之间——两个字段、两个页面——而这种不一致在单页面调试时看不见。

顺带一个跨篇的观察:这三篇(乐观更新、评论删除、WebView 返回)都是"用户的操作可能失败"这个主题。写操作比读操作难写的根本原因是:读操作失败了就失败了,用户重试一次就行;写操作失败时,本地状态已经变了,必须回滚,而回滚又要处理"期间用户可能又操作了"。

下一篇讲会员中心——它是私有数据最集中的一页,也是"账号隔离"这个概念落地最完整的地方。

延伸阅读

  • Dio + Repository:统一响应信封与模型适配
  • 移动端错误与限流:429、重试和写请求边界
  • 列表分页与下拉刷新:去重、失败保留和返回位置
  • 点赞、收藏与阅读历史:三种互动状态的所有权不同

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

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

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询