购物车角标要在商品页、订单页同时更新,用户切换账号后又必须清空。再加上接口请求,旧账号的响应可能在新账号登录后才返回。此时选状态管理方案,要回答四个问题:状态归谁管、谁能修改、异步结果还能不能写回、对象什么时候销毁。
这篇文章写于 2026 年 10 月。文中的购物车是一个统一的选型场景:列表页显示数量,结算页向服务端请求报价,登出时清空当前用户的数据。它用来比较方案的行为,不是某个真实项目的事故复盘。后半部分再看企业公开资料。尤其是“大厂都用哪个”这个问题:公开案例能证明一个团队在某个时期的做法,通常无法证明整家公司今天的全部代码库都采用同一方案。
先分清三种状态
输入框是否展开、当前 Tab 是哪个,通常由页面自己的State管理,setState()就很合适。购物车、登录用户这类跨页面数据,需要明确共享范围。商品列表、报价等接口数据,还要处理加载、失败、缓存、刷新和过期。
| 状态 | 建议归属 | 离开页面后是否保留 | 购物车例子 |
|---|---|---|---|
| 页面局部状态 | 当前 Widget 或页面对象 | 通常不保留 | “优惠说明”弹层是否展开 |
| 会话业务状态 | 登录会话范围内的持有者 | 切页保留,登出清空 | 当前用户的购物车商品与数量 |
| 服务端数据的客户端视图 | Repository 与负责展示的状态持有者 | 依缓存、刷新规则决定 | 结算报价的加载、成功、失败 |
同一个数据可以穿过几层,但应明确可修改的真实来源。例如支付价格由服务端确认,客户端的quote是展示用的快照;不能因为几个页面都要显示它,就让每个页面各维护一份可写价格。登录用户也一样:若 Repository 管会话,页面状态应订阅它,而不是再造一个独立的“当前用户”。
Flutter 官方的架构建议强调分离 UI 与数据层、让数据有明确的真实来源,并把ChangeNotifier/Listenable作为一种按场景选用的界面更新方式;官方同时说明状态管理方案可按项目需要选择。Flutter 架构建议、状态管理方案概览
框架要解决的是共享和变化的组织问题。把页面上一个开关接进全局容器会增加清理成本;让三个页面各存一份可修改购物车,又会增加同步成本。两种成本都能在画状态归属图时提前看出来。
常见方案的差别在哪里
| 方案 | 状态怎么组织 | 更合适的场景 | 主要成本 |
|---|---|---|---|
setState/ValueNotifier | Widget 或附近对象直接持有状态 | 页面局部、短生命周期的交互 | 跨页面共享、依赖组织要自己设计 |
Provider +ChangeNotifier | 将对象放进 Widget 子树,通过context读取或监听 | 已有ChangeNotifier,希望逐步规范共享状态 | 作用域与订阅范围依赖 Widget 树;异步状态要自行建模 |
| Riverpod | 用 provider 声明状态和依赖,通过ref组合、读取和监听 | 接口数据多,依赖、刷新和生命周期关系复杂 | 要理解 provider 的重算、缓存、失效与销毁规则 |
| Bloc / Cubit | Cubit 调方法后输出状态;Bloc 接收事件再处理、输出状态 | 多人协作、流程复杂、需要追踪状态变化 | 状态类型和事件建模较多;简单功能容易写重 |
| GetX | Controller、响应式值、依赖注册;还提供路由等能力 | 团队已采用其整套约定 | 注册入口和对象生命周期需要额外约束 |
| MobX | observable、action、computed,界面用Observer订阅 | 喜欢响应式领域模型的团队 | 隐式依赖与生成代码的使用方式需要团队统一 |
| Redux / Fish Redux | Action 经 Reducer 更新状态,强调单向数据流 | 已有 Redux 体系,或复杂业务需要严格约定修改入口 | 模板代码与拆分成本高;不适合只为几个页面引入 |
“状态管理框架”这个叫法本身有点宽。Provider 更偏对象提供与订阅,Riverpod 同时组织依赖和状态,Bloc/Cubit 侧重操作到状态的流转,GetX 还覆盖依赖注册和路由。因此不能只数一段计数器代码的行数:比较时得把依赖注入、接口请求、异常处理和销毁规则一起算进去。
一次点击在各方案里怎么走
假设用户点击“加入购物车”,列表角标和结算页数量都要刷新。下面是常见写法的路径,不代表各库只有这一种写法。
| 方案 | 页面发出操作 | 谁改状态 | 页面如何观察 |
|---|---|---|---|
| Provider | context.read<CartModel>().add(item) | CartModel修改数据后调用notifyListeners() | Consumer、context.watch或context.select |
| Riverpod | ref.read(cartProvider.notifier).add(item) | Notifier 更新自己的状态 | ref.watch(cartProvider);可按需选择订阅值 |
| Cubit | context.read<CartCubit>().add(item) | Cubitemit新状态 | BlocBuilder或BlocSelector |
| Bloc | context.read<CartBloc>().add(ItemAdded(item)) | 事件处理器输出新状态 | BlocBuilder;一次性副作用交给BlocListener |
| GetX | 通过已注册的CartController调用add | Controller 更新 Rx 值或调用update() | Obx或GetBuilder,取决于所选模式 |
到“加一”这一步,差别主要是 API 和约束。需求变成“登录用户切换、报价接口失败、旧响应迟到”后,差别才会进入架构层。
这里有三个容易混淆的点。
Provider 不等于ChangeNotifier。Provider 主要解决对象提供与查找,也可以提供 Repository 等普通对象;ChangeNotifier是常见的通知机制。context.watch会订阅,context.read只读取,context.select可只订阅所需字段。Provider 文档
在购物车里,可以用Provider<CartRepository>提供数据访问对象,再用ChangeNotifierProvider<CartModel>提供 UI 要观察的模型。把后者放在登录会话之下,跨页面能复用,登出时又有明确的销毁边界。最常见的失误是把购物车放在商品详情页下:页面一退出,模型就没了。另一个失误是整页watch一个很大的模型,优惠券字段变化也重建商品列表;这时应把订阅缩到真正使用的字段。
Riverpod 不是把 Provider 的context改名为ref。它把依赖和状态定义放在 provider 图中,提供异步状态与生命周期能力。自动销毁也有配置差异:代码生成形式默认启用,手写 provider 需要按所用 API 明确设置。不能看到“Riverpod 会自动清理”就假设所有状态都会在页面离开时释放。Riverpod Refs、自动销毁
在同一个例子里,cartRepositoryProvider可以提供仓库,cartProvider持有购物车,报价 provider 依赖商品列表或购物车版本。依赖变化时,报价可以重新计算;加载和错误也有对应的异步状态表示。这对“登录用户 → 购物车 → 报价”这样一串依赖很顺手。不过,依赖重算和缓存失效必须被设计过:报价要不要在页面切走后保留、登出时哪些 provider 要失效,都不是默认值能替业务回答的。Riverpod 的代码生成是可选的;官方也提醒,若项目本来不用代码生成,只为 Riverpod 引入它要衡量构建时间。Riverpod 代码生成说明
BLoC 是设计模式,flutter_bloc是具体库。Cubit 适合用方法表达操作,Bloc 适合将事件与处理过程显式化;两者都可以在业务层输出状态。是否需要事件层,要看“发生了什么”本身是否值得建模,而不是看项目人数是否超过某个数字。Bloc 官方文档
购物车数量更新时,CartCubit.add()已经足够清楚。结算流程若涉及选择地址、锁库存、请求报价、提交订单、支付回调,事件名和状态类型能帮助团队复盘“哪个动作把页面带到了这里”。界面重建用BlocBuilder,弹提示或导航这类一次性反应用BlocListener。代价是要维护事件、状态及其映射;如果所有页面只有一个布尔开关,硬套完整 Bloc 会比需求本身还复杂。flutter_bloc 组件说明
GetX 把状态、依赖和路由放在一套工具里,能减少接线工作;代价是团队必须讲清Get.put注册的对象归谁所有、何时删除。MobX 的Observer会跟踪构建时读取的可观察值;Redux 则要求状态变化经过约定的 Action/Reducer 路径。它们都能做大型应用,但需要匹配团队既有的写法。GetX、MobX Flutter 集成、flutter_redux
GetX 项目里,我会把页面对象放进对应路由的 Binding,把登录会话对象的创建与重置写成一条明确规则。Get.find()用起来方便,却也容易让依赖藏在方法内部,单测时很难替换。MobX 适合围绕observable与computed写响应式模型,Observer会跟踪构建时用到的值;维护时要留心实际订阅了什么。Redux 的 Action/Reducer 路径最明确,但购物车每加一个字段也可能牵动多处定义。Fish Redux 是闲鱼基于 Redux 思路的工程化框架,不能把它与flutter_redux当作同一个库。
get_it常和这些方案一起出现,它负责查找或注入对象,本身不负责通知 Widget 重建。把“对象在哪里拿到”和“状态怎样变化”分开,依赖结构会更容易读。
真正难的场景:旧报价晚于新报价返回
把问题具体化。购物车版本为 1,结算页发出请求 A;用户随即多加一件商品,版本变成 2,又发出请求 B。B 先返回,界面显示新报价;A 后返回。如果 A 直接写入状态,金额就回退到旧购物车的价格。
| 时间 | 购物车版本 | 请求 | 正确的界面行为 |
|---|---|---|---|
| T1 | 1 | 发出 A | 显示加载中,或保留旧价格并标记待更新 |
| T2 | 2 | 发出 B | 当前展示目标改为版本 2 |
| T3 | 2 | B 返回 | 接受 B,对应版本 2 |
| T4 | 2 | A 返回 | 忽略 A,不能覆盖 B |
这一问题与使用notifyListeners()、emit()、.obs还是ref.watch()无关。需要在业务层确定写回条件:请求发出时记录购物车版本和登录会话标识,返回时两者仍匹配才接受结果;如果网络库支持取消,也可以主动取消旧请求。取消能省资源,版本校验仍能保护无法取消或已经返回的请求。
下面是框架无关的伪代码,只展示写回规则,不是可直接运行的 Dart 实现:
requestQuote(): requestedSession = currentSession requestedCartVersion = cartVersion result = await repository.quote(cartItems) if requestedSession != currentSession: return if requestedCartVersion != cartVersion: return quoteState = Success(result)无论用哪套库,都还要定义失败时是否保留旧价格、重试是否沿用当前购物车、提交订单时是否重新向服务端确认金额。状态管理库可以表达和分发这些结果,不能替代业务规则,更不能让客户端报价成为支付真值。
生命周期与测试,比语法短更重要
购物车属于登录会话,报价请求属于结算页或一次结算尝试。把两者都做成应用级单例,会让登出后残留旧用户数据;把两者都挂在结算页,又会让页面返回时丢失购物车。作用域应该跟数据的业务寿命一致。
Provider 和 Bloc 常借助 Widget 树放置对象;由对应 Provider 创建的对象可随该作用域释放。Riverpod 还要看 provider 是否自动销毁、是否被其他 provider 继续监听,以及是否被缓存。GetX 则要核对 Binding、注册方式与清理设置。库能执行销毁,前提是你把对象放在正确的边界。页面离开时若底层请求仍在进行,还要取消请求或阻止它写回失效状态。Provider、Riverpod 自动销毁、flutter_bloc 组件说明、GetX
我会用同一组用例检验候选方案:登出后购物车为空;先返回 B、后返回 A 时仍显示 B;报价失败有重试入口;结算页退出后不再更新页面状态;注入假 Repository 时能稳定复现这四种情况。Provider 可直接测模型,Riverpod 可覆盖 provider,Bloc/Cubit 可验证状态序列,GetX/MobX/Redux 也能测业务对象,但必须处理各自的注册或订阅清理。测试入口不同,业务断言应当相同。
也不要给这些库排脱离场景的“性能榜”。Widget 重建范围、列表项数量、状态更新频率和实际绘制工作都会影响结果。context.select、Riverpod 的选择性订阅、BlocSelector、Obx都能缩小更新范围;同样,也都能因作用域过大或状态拆分不当而产生多余重建。需要优化时先用 DevTools 找到具体重建和耗时,再改订阅或数据模型。Flutter DevTools
“现在各大厂都在用哪个”:公开资料能核实到什么
| 公开案例 | 可以确认的事 | 不能据此推断的事 |
|---|---|---|
| 阿里巴巴闲鱼 | 闲鱼技术团队开源了基于 Redux 思路的 Fish Redux,仓库说明其面向中大型 Flutter 应用。 | 不能推断阿里巴巴所有 Flutter 项目现在仍用 Fish Redux。 |
| Jimdo | 团队在 2018 年的技术文章中说明:Boost 应用早期用 MVP,随着代码增长转向 BLoC,理由包括 Presenter 过大、复用不足,以及数据流更适合 Flutter。 | 文中讲的是 BLoC 架构思路,不能直接写成“Jimdo 使用flutter_bloc包”,也不能代表其今天的技术栈。 |
| Very Good Ventures | 团队在 2021 年的文章中说明偏好flutter_bloc:希望团队积累同一套经验,让状态流转可预测、便于测试;其历史公开模板也集成了 Bloc。 | 这不代表它交付的每个客户项目都强制使用 Bloc;原文也说会尊重客户已有方案。 |
| AppFlowy | 截至本文核对时,其公开 Flutter 源码可见flutter_bloc引用及多个 Bloc/Cubit 类。 | 源码能证明该仓库这部分的使用,不能单凭 import 解释团队为什么选它,也不能代表所有模块。 |
| Flutter 官方示例 | Compass 架构示例用ChangeNotifier更新 ViewModel,并用 Provider 做依赖注入;文档明确说同样的结构也可换成 Riverpod、Bloc 等。 | 官方示例不是 Google 所有生产应用的选型公告。 |
把案例放在一起看,比统计“谁用了几次”更有用。
闲鱼公开的 Fish Redux 将可预测状态容器与组件化放在同一个框架中。这是大规模业务团队的一种解法:多个页面、多人维护时,状态变化入口和组件边界要能被统一约束。不过,仓库 README 只能证明团队发布了这个框架及其设计目标;某个具体产品当前是否仍使用它,需要更近的产品代码或团队说明。Fish Redux 仓库
Jimdo 的文章更像一次架构迁移记录。团队写明 Boost 应用先采用熟悉的 MVP,随后发现 Presenter 越来越大、复用不理想,于是选择 BLoC。值得学的是其判断过程:新框架是否真的解决当前代码的组织问题,而不是先把所有页面改成最新流行的包。Jimdo 工程文章,2018 年
Very Good Ventures 选择flutter_bloc的理由来自交付团队的工作方式:让不同项目共享经验、代码审查有共同语言、状态变化便于测试。原文也明确说,客户已有其他方案时会沿用客户的技术选择。因此这更接近“团队默认方案”,并非它所接触的大型客户都用了 Bloc 的证据。VGV 工程文章,2021 年
AppFlowy 提供了一个时间更近、可以直接看代码的例子。其公开编辑器页面同时引用flutter_bloc、DocumentBloc等对象,这比“听说某公司用 Bloc”更可核实。但源码呈现的是使用事实,设计动机仍需团队自己的文章或说明;我们不能替它编一套选型理由。AppFlowy 编辑器源码
Flutter 官方 Compass 示例则提醒我们,架构原则和状态管理库是两个层次。示例以 ViewModel、Repository、Service 分工,用ChangeNotifier更新 UI、Provider 做依赖注入;官方说明状态更新也可换成 Riverpod、Bloc 等。这里可借鉴的是数据层与 UI 层的边界,不能把示例用包误读成“Google 内部统一选型”。Compass 架构案例、依赖注入说明
没有可靠的公开资料能给出“各大厂当前状态管理框架占比”或一个统一赢家。公司内部常有多个 Flutter 团队、多个历史版本,一个公开仓库也只能说明它所覆盖的范围。本文也不把 pub.dev 下载量、GitHub Star 当作企业采用率:它们衡量的是包或仓库的活动,无法识别哪些下载来自生产应用、更无法识别所属公司。
如果有人说“某大厂都用 GetX”或“现在大厂全面转 Riverpod”,我会先要项目仓库、团队技术文章或演讲,并看发表时间、具体应用和依赖版本。缺少这些条件,结论最多是社区观察。
我的选型建议
已有项目能稳定交付时,我先查实际痛点。某个页面刷新范围太大,就缩小订阅范围;接口结果乱序,就给请求加版本或取消机制;登录态泄漏,就重画作用域与销毁边界。只有这些问题反复出现,而且现有方案让团队很难统一修复时,迁移才有价值。
新项目可以按需求规模逐级选择:
| 项目条件 | 我会先试什么 | 什么情况会改变选择 |
|---|---|---|
| 主要是页面内交互 | setState、ValueNotifier | 状态开始跨多个页面共享,再抽出持有者 |
已有大量ChangeNotifier与 Provider | 保留 Provider,整理作用域和订阅 | 异步依赖与缓存规则越来越难维护,再评估 Riverpod |
| 新项目接口多、数据依赖链长 | Riverpod,先做一个有加载、错误和失效的功能 | 团队已有成熟 Bloc 规范,迁移培训成本超过收益 |
| 订单、支付、审批等流程有明确事件 | Bloc;简单页面用 Cubit | 只有少量状态且事件层没有额外业务价值,回到 Cubit 或更轻方案 |
| 团队已采用 GetX、MobX 或 Redux | 沿用既有实践,补充测试与生命周期约定 | 新功能持续受到隐式依赖或模板成本拖累,再局部试迁移 |
这不是按公司大小排队。十个人开发的复杂交易链可能很需要显式事件;上百人公司的简单配置页也只需局部状态。决定因素是业务流转、现有代码、团队熟悉程度和可验证的维护成本。
迁移时最危险的是让旧框架和新框架各保存一份可修改购物车。稳妥顺序是先抽出CartRepository和业务状态,指定唯一的状态持有者;挑一条完整路径迁移,例如“打开结算页 → 请求报价 → 修改数量 → 返回旧结果”;用同一组测试验证后,再扩大范围。新旧框架可以暂时并存,但同一份业务数据不能同时有两个写入入口。
最终的选型评审,我会请团队拿同一条业务链做演示:登录后加载购物车、快速修改商品、旧报价迟到、登出清空数据。请每个候选方案回答状态的真实来源、错误如何表示、旧请求如何失效、依赖如何替换做测试。答得清楚,再谈写法是否顺手;答不清楚,换一套 API 也不会让状态自动正确。