成为全栈·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,而不是改旧的。
小结
乐观更新的核心流程只有三步,但把它做对的约束有六条:
- 回滚到上一次成功状态,不是本次操作前的状态——用户可能又点了一次。
- 服务端返回值优先于本地计算值——操作期间可能有别人也在动。
- 状态和数量必须成对更新——否则缓存自相矛盾,两个页面显示不一致。
- patch 要限定对象类型——id 会撞车,
containsKey('title')是护栏。 - override 管理乐观值的生命周期——解决"服务端还不知道用户刚操作过"的时间差。
- 不 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