资讯详情

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

📅 2026/10/15 18:57:56 | 华诺云谱 👁 阅读
成为全栈·Flutter App 篇·点赞、收藏与阅读历史:乐观更新和失败回退
成为全栈·Flutter App 篇·点赞、收藏与阅读历史乐观更新和失败回退点一下点赞按钮等接口返回再改图标——这是我最早的写法。测的时候觉得别扭按钮点下去之后什么都没发生要等三四百毫秒才有反应。在网络好的时候不明显一到弱网就变成了App 卡了。于是改成乐观更新点下去立刻变成已赞接口成功就完事失败就回滚。然后我遇到了真正的麻烦——失败回滚比不优化还麻烦。这篇讲features/data/reaction_store.dart那八十五行以及一个我一开始完全没想到的约束点赞状态和点赞数量必须成对修改。乐观更新的三个步骤先把流程说清楚后面所有细节都挂在这上面1. 本地立即改成新状态 → 用户立刻看到反馈 2. 发请求同时记住原值 → 为了能回滚 3a. 成功 → 用服务端返回值校准 → 可能和本地值不同 3b. 失败 → 把原值写回去 → 用户看到回滚第3 步的成功分支容易被忽略。服务端返回的likeCount可能和你本地算的不一样——比如这篇在你操作期间被别人点了。所以成功之后要用服务端值覆盖而不是保持我本地这个值。现在看ReactionStore的核心/// 点赞和收藏的乐观值跨页面共享失败时由调用方提交原值回滚。classReactionStore{finalreactionsint,MapString,dynamic{};finaleventsStreamControllerint.broadcast();voidset(int id,{bool?liked,int?count,bool?favorite,bool localtrue,}){finaloldreactions[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(localliked!nullcount!null){finalpathEndpoints.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变 falselikeCount从 13 变 12。如果只种入{likeCount: 12}那缓存里的liked就成了null 或者过期值。下次进文章页UI 读liked拿到 null —— 按 falsy 判断显示未赞看起来是对的。但如果用户刚才点的其实是点赞那个值是 trueUI 会显示未赞而服务端是已赞——状态彻底错乱。而且这种错误特别难查列表页显示正常列表是patch改的文章详情页显示不对从缓存读的。两个页面不一致。所以规则是要么两个字段一起更新并种入缓存要么两个都不更新。宁可让缓存旧一点也不能让它自相矛盾。收藏的favoriteOverrides没有这个约束因为它只有一个字段不存在部分更新的问题if(favoriteOverrides.lengthCacheLimits.favoriteOverrides!favoriteOverrides.containsKey(id)){favoriteOverrides.remove(favoriteOverrides.keys.first);}if(localfavorite!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.keylikeCountvalue[id]idvalue.containsKey(title)?count:patch(e.value),};}returnvalue;}cache.patch(patch);}这个patch函数是个递归遍历器遇到 List 就逐项处理遇到 Map 就检查每个键。找到符合条件的likeCount就替换否则继续往里递归。if (value is Map)那个分支里的三元表达式有点绕拆开看e.keylikeCount// 这是点赞数字段value[id]id// 属于这一篇value.containsKey(title)// 而且它是文章摘要?count// → 替换:patch(e.value)// → 否则继续递归value.containsKey(title)这个条件是本篇里最容易被忽略、也最重要的一行。因为id 在不同资源里会重复。文章的 id 是 42某条评论的 id 也可能是 42不同表各自自增。如果不加这个判断用户给文章点赞之后评论区里那条 id42 的评论的某个字段也会被改掉。加上containsKey(title)就限定了只有文章摘要它有标题才会被改。评论、用户、分类这些对象即使 id 撞上了也不受影响。注释里那句避免误改评论等同名 id 对象写的就是这件事。而页面这边只需要订阅事件// 所有页面订阅同一文章事件使详情与列表立即反映乐观结果。events.add(id);StreamintgetreactionEventsreactionStore.events.stream;为什么用StreamController.broadcast()因为一个事件要通知所有在显示这篇文章的页面不只是最后一个。普通非广播的 Stream 只能有一个监听者第二个订阅会直接抛异常。事件里带的是文章 id而不是某篇文章变了这种模糊信息——页面收到后能判断这正是我显示的这一篇吗不相关就忽略。这避免了任何互动都刷新整个页面。local 这个参数是干什么的set有个local true的参数注释写得很清楚// localfalse 表示读取基线只更新展示值而不续期互动状态缓存。区别在两个地方if(localliked!nullcount!null){cache.seed(...);}// ← 写缓存if(localfavorite!null){favoriteOverrides[id]...;}// ← 记 override而reactions[id] {...}那一段是无论 local 是什么都执行的——它只改内存里的展示值。所以调用local内存展示值缓存override点赞成功回写true更新更新—收藏成功true更新—更新读取列表同步基线false更新不更新不更新为什么读列表要用local: false因为服务端返回的基线数据不应该当成新鲜结果。想一下如果列表返回说某篇文章likeCount: 12于是我们seed进缓存并开始两分钟的 fresh 期——但这个数据其实是几秒前的用户可能在别处刚点过。把它当成新鲜数据会让真正的新数据进不来。用local: false等于说我知道这个数用来显示但我不知道它有多新所以不延长缓存寿命。缓存的时效仍然由它原本的策略决定。这个参数体现的是一个很细的判断数据的来源和数据的展示是两件事。从服务端读来的数据适合显示但不适合用来续期缓存——因为读的那一刻它就已经旧了。失败回滚真正麻烦的部分现在说开头那个问题为什么回滚比不优化还麻烦。失败时的操作看起来很简单——“把原值写回去”// 调用方逻辑失败时用操作前的值再调一次 setfinalbeforerepository.reactions[id]?.[liked];// ... 发送请求失败 →repository.setReaction(id,liked:before,local:false);但这里有三个真实的坑。第一操作期间用户可能又点了一次。时序用户点赞 → 乐观变已赞 → 用户取消点赞 → 第一个请求失败 → 回滚成已赞。但用户的真实意图是取消点赞。这个回滚把状态改回了一个用户已经放弃的状态。正确的做法是回滚到上一次成功的状态而不是这次操作之前的状态。所以现在多记了一个savedAtsavedAt: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 生效收藏没消失读取列表localfalse不延长缓存寿命其中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
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑