写之前先交代背景。我带Flutter团队这几年,接手过不少“前半程写得很爽、后半程想重构”的项目。这种项目有个共同特征:状态管理没在架构层立规矩。页面级setState、全局单例、跨层级组件的状态共享,全凭个人喜好混着用。等到功能复杂到一定程度,状态复杂度直接反噬开发效率,加个需求要摸半天代码,出个bug要翻十几个文件。
所以这篇文章想聊的核心问题是:Flutter里的状态复杂度,怎么在架构层面提前踩刹车。注意,“刹车”不是指不用状态管理库,也不是指禁止setState,而是指在一开始就把状态的归属、流向、生命周期定义清楚,让复杂度始终处于可控区间。这篇内容对应的场景比较多,包括组件通信、Navigator切换页面后的状态保留、异步状态更新时序这些高频问题,适合正在做中小型Flutter项目的工程师,也适合准备Flutter面试、想系统梳理状态管理思路的朋友。
1. 状态复杂度失控的三个典型信号与底层根因
1.1 信号一:setState满天飞,没人说得清状态归属
我在给团队做代码评审时,最怕看到的不是代码写得烂,而是“每个Widget都在改别人家的状态”。典型画面是这样的:一个商品列表页,自己维护着筛选条件,同时又通过全局变量改着购物车数量;列表项内部的子Widget为了刷新某个角标,直接调用一个静态类的静态方法。全工程搜一下setState,能搜出上百处,分布在几乎每一个文件里。
这种代码在功能少的时候跑着没问题,可一旦业务复杂起来,就会出现一个非常头疼的现象:你永远不知道“当前这份状态从哪来、会被谁改”。有一次我们排查一个线上bug,用户点击“加入购物车”后角标数字时对时错。查到最后,购物车数量在三个地方被修改:列表页的setState、全局单例的方法、以及一个定时器的回调。三个地方改的是同一份业务状态,但彼此之间的时序关系完全没有约束,最后只能靠一个标志位暂时压下去,治标不治本。
1.2 信号二:组件通信全靠Callback和“神级Provider”
第二个信号是组件通信的混乱。很多Flutter开发者在入门时学的第一课就是“父组件传参、子组件回调”,用久了就形成路径依赖:不管什么通信场景,全部用构造函数传参或Callback解决。于是你会看到:
- 一个深层子组件要触发顶层页面的刷新,先把callback层层透传四五层;
- 两个没有父子关系的页面要共享数据,就在某一个父级套一个巨大的Provider,把所有状态都塞进去;
- 跨页面传对象,干脆通过构造函数把整个Model传过去,接收方一旦修改了对象,发送方完全感知不到。
这种写法的本质问题是通信通道没有“路权”的概念。就像一个城市里,自行车、汽车、卡车全挤在同一条乡间小道上,谁想去哪儿都先从这条路过,最后谁也快不起来。模块之间的耦合就在这种一次次的“图省事”里越积越深。
1.3 信号三:页面切走再回来,状态不是丢了就是错了
第三个信号在Flutter项目里极其常见,对应很多人在各个社区搜过的一个问题:“Navigator切换页面后,会丢失状态吗”。答案是:取决于你的状态放哪了。
如果状态放在StatefulWidget的State里,Navigator push了新页面之后,旧页面虽然还在栈中,但在Android系统回收内存等极端情况下可能被销毁,回来后状态就没了;如果状态放在构造函数传进去的对象里,push时传值、pop时再接返回值,勉强能工作,但一旦页面栈嵌套三层以上,这种“传参再接参”的方式就会变成一场灾难。
还有一个更隐蔽的场景:我们用IndexedStack或PageView保留页面状态时,每个页面自身的State确实保住了,但这些页面共享的跨页面状态呢?如果它存在某个被反复重建的父级Widget里,那照样丢。很多团队的bug就是这样出现的——状态放在了一个生命周期层级不对的位置,靠KeepAlive救得了一时,救不了一世。
1.4 这些信号背后的共同根因
把三个信号放在一起看,会发现背后的根因是一致的:架构层面没有回答三个基本问题。
第一,状态是谁的?也就是状态所有权。每份状态应该有一个唯一的“家”,而不是散落在各个Widget里。第二,状态在哪一层?是UI层、应用层,还是数据层?不同层级的状态有着不同的生命周期和修改规则。第三,状态怎么被修改?修改通道是唯一的还是多处的?是同步的还是异步的?异步修改时的执行顺序由什么保证?
一旦这三个问题在项目启动时没有答案,开发者就只能靠临场发挥。临场发挥在项目初期感觉不出代价,等代码到了两万行、三万行,麻烦会集中爆发。这也是我一直强调的:状态复杂度的控制必须在架构层“提前刹车”,而不是等项目变慢了再去“事后补胎”。
2. 第一重刹车:状态所有权划分
2.1 三条规则:单一来源、最小作用域、修改通道唯一
状态所有权是开工前就要讲清楚的第一件事。我把它总结成三条规则,可以直接贴在项目文档里。
规则一:单一来源。每一份业务状态,在整个项目中只能有一个“权威”的存储位置。其余位置上如果出现同一份状态,只能是它的派生或者缓存,不能是独立副本。很多人觉得这句话是废话,但实际上做起来极难。最常见的情况是:页面A有一个用户对象,页面B又单独保存了一份用户对象,两边都以为自己是“最新的”,结果其中一个页面改了昵称,另一个页面还显示旧值。
规则二:最小作用域。状态应该放在“能覆盖所有使用者”的最低层级。一个页面内多个Widget共享的状态,放在页面控制器里;多个页面共享的状态,放到路由或应用级;全App共享的登录态,才放到全局容器。放得太高了,所有Widget都能看到它、依赖它,线程和生命周期问题开始出现;放得太低了,状态跟着页面一起销毁,数据说没就没。
规则三:修改通道唯一。谁拥有状态,谁就拥有修改它的唯一入口。其他Widget要改这份状态,只能通过调用拥有者暴露的方法或事件,不能越过拥有者直接操作。这第三点我单独讲,因为它是绝大多数人在日常编码中最容易忽视的。
拿一个真实例子来说:登录用户信息是全局状态,某个详情页想改用户的昵称。如果它直接拿到全局容器里的User对象改字段,那么所有依赖这个User对象的页面都要响应。问题在于DetailPage的修改发生时,它没有通知“用户信息修改事件”,其他页面的缓存副本也不会同步更新,最终结果是有的页面显示新昵称、有的页面显示旧昵称。这就是副本与权威源脱节造成的经典bug。改成通过UserStore.updateNickname()方法修改,所有副本都从权威源重新拉取,问题才真正解决。
提示:修改通道唯一,不是说所有状态必须写出大量的setter,而是强调“外部不能绕过拥有者直接改数据”。
List.unmodifiable、私有字段加保护性拷贝,都是常用的防御手段。
2.2 四层架构里的状态落位
为了执行这三条规则,我习惯把Flutter项目划分成四个逻辑层,每层放自己该放的状态:
| 层级 | 职责 | 典型状态 | 生命周期 |
|---|---|---|---|
| UI层(Widget/Presentation) | 纯展示、用户交互 | 动画进度、表单输入框当前值、下拉刷新状态 | 随Widget销毁 |
| 应用层(Controller/Service) | 组织业务用例、协调状态 | 页面筛选条件、列表加载状态、购物车会话 | 随用户会话或页面栈 |
| 领域层(Domain/Model) | 业务规则与实体定义 | 订单状态机、价格计算规则 | 与持久化数据一致 |
| 数据层(Repository/DataSource) | 数据读写、缓存策略 | 缓存字典、数据库实例、网络会话 | 随App生命周期 |
这里要特别说明,很多Flutter项目没有应用层,直接把业务状态全放在页面State里,或者一股脑塞进全局的Provider里。前者导致状态生命周期过短,页面切走就被回收;后者导致状态生命周期过长,退出登录了还残留着上一个用户的数据。这两种错位的本质,都是没想清楚状态的“生命周期层级”。
我自己的经验是:先从最简单的分层开始,页面级状态放进页面控制器,跨页面但不跨用户的状态放进应用级容器,跨用户的全局配置才放进全局容器。等团队吃透了这套基础分层,再逐步演进,不要一上来就引入花哨的架构。
2.3 案例:购物车状态到底归谁
用一个具体案例来串一下上面的规则。做电商App时,“购物车商品列表”这份状态应该属于谁?
按最小作用域分析:购物车既出现在商品详情页(角标、加购结果),也出现在TabBar的购物车页面,还出现在结算页。所以它不是“某个页面自己的状态”,而是多个页面共享的应用级状态。于是我们把它放在ShoppingCartController,而不是放在某一个页面State里,更不是放在全局变量里。
接着按修改通道唯一规则:加购、删减、清空、结算,全部对应ShoppingCartController的方法。商品列表页想显示角标数字,只要通过Stream或Listenable监听购物车变更事件,然后重建角标Widget即可。整个过程中,商品列表页不直接修改购物车列表,它只是“监听者”。
// 简化示例:ShoppingCartController 是购物车状态的唯一拥有者 class ShoppingCartController extends ChangeNotifier { final List<CartItem> _items = []; // 外部只能读,不能改内部列表 List<CartItem> get items => List.unmodifiable(_items); int get totalCount => _items.fold(0, (sum, item) => sum + item.count); void add(CartItem item) { _items.add(item); notifyListeners(); // 所有监听者同步刷新 } void remove(String itemId) { _items.removeWhere((item) => item.id == itemId); notifyListeners(); } }这样设计之后,后面再加新页面(比如优惠券页要显示“购物车满199减30”),只需要让新页面也监听同一个ShoppingCartController,不需要再改任何旧的通信逻辑。状态复杂度的增长从“指数级”变成了“线性级”,这也是我在架构上强调所有权划分的直接收益。
3. 第二重刹车:组件通信通道约束
3.1 给通信方式画“路权”:什么场景用什么通道
状态所有权解决了“状态归谁”的问题,接下来要解决“状态怎么流动”的问题,也就是组件通信。
我见过太多项目把所有通信需求都塞给同一种机制:要么全都用Callback,要么全都用全局事件总线。这样做的后果是代码中到处都是隐式依赖,你很难一眼看出某个页面被谁影响。我的做法是先给通信方式分层,每层有固定的“路权”:
| 通信场景 | 推荐通道 | 不推荐 |
|---|---|---|
| 父Widget到子Widget | 构造函数参数、InheritedWidget | 全局总线、EventBus |
| 子Widget到父Widget | 回调函数、事件绑定 | 直接调用父级State方法 |
| 兄弟或跨层组件 | 共享的Controller或Store,配合监听 | 构造函数层层透传 |
| 跨页面、跨模块 | 路由参数加应用级Store | 全局可变单例直接读写 |
| 跨模块通知(不关心返回值) | 事件总线或Stream(谨慎用) | 直接改他人的全局状态 |
这张表解决的是“路径选择”,核心原则是:通信距离越远,越要走正规通道,不能靠私搭乱建。
我见过一个反面例子:一个团队为了省事,在商品列表的某个子组件里,直接通过EventBus发了一个“refreshHomePage”事件,结果全App监听这个事件的页面有五六个。后来新增需求时,只要有人发了这个事件,所有页面一起刷新,性能损耗大不说,还经常出现“我只是点了收藏,结果购物车也跟着loading”的灵异现象。用上面这张表去约束,这个需求应该走“收藏Controller更新,依赖它的页面通过监听刷新”,而不是全局广播。
3.2 强制单向数据流,别搞双向绑定
组件通信通道确定之后,第二层约束是数据流的方向。在Flutter里,我见过很多人试图把状态做成“响应式双向绑定”:Widget改动直接写回Store,Store改动直接刷Widget。初期写着舒服,等业务复杂了,最难查的bug都是一个写回导致另一个Store联动修改,最终把整个Widget树搞混乱。
我更推荐在架构上强制“单向数据流”,即:
用户交互 → Widget派发事件 → Controller处理业务逻辑并更新状态 → Store通知变化 → Widget重建
在这个链路里,Widget永远不直接修改Store里面的状态,它只能“派发意图”。这样做的好处是:所有状态的修改都集中到了Controller,排查问题时只需要看Controller里的逻辑,不需要在几十个Widget里大海捞针。
class CounterView extends StatelessWidget { final CounterController controller; CounterView(this.controller); @override Widget build(BuildContext context) { final count = controller.count; // Widget只读 return Column( children: [ Text('$count'), ElevatedButton( // Widget只负责派发事件,不自己修改状态 onPressed: controller.increment, child: const Text('加一'), ), ], ); } }刚开始团队会觉得这种写法“绕”,多写了好多模板代码。但三个月后,当你要定位“这个数字为什么不对”时,你只需要打开CounterController,而不是在十几个文件里找那行setState,性价比完全不一样。单向数据流的价值不在写代码那一刻,而在改代码那一天。
3.3 异步状态更新:别让回调偷改状态
组件通信通道确定之后,还有一类情况经常让状态管理“翻车”,就是异步更新。很多Flutter初学者都喜欢搜一个问题:“Future的then回调是放入微任务队列吗”。这个问题其实是在探究Dart的事件循环与Future回调的执行时机。顺着这个点往下讲,状态管理里最容易出错的地方恰好就潜藏在这里。
Dart的Future回调会进入微任务队列,在当前同步代码执行完成后立即执行。这意味着,当你发起一个网络请求,然后在then回调里更新状态时,这个更新操作和用户在这期间触发的其他操作,存在一个微妙的时序关系。如果状态没有唯一的修改通道,两个异步回调同时去改同一份状态,就会出现竞态条件。
举个实际例子:用户在一个页面里连续点击两次“加载更多商品”。第一次点击发了一个异步请求,第二次点击又发了一个。假设第二次请求先返回,列表被更新成了“第2页”的数据;随后第一次请求也返回,又把列表更新成“第1页”的数据。界面上最终展示的是第一页,但用户的滚动位置已经到了第二页,整个状态错乱。
在架构上解决这个问题,不是靠“调整回调调度”,而是靠前面说的状态所有权约束:加载状态和商品列表必须归同一个Controller管理,Controller内部用一个请求序列号或版本号来判断“后发请求是否仍然有效”。这样无论Dart的微任务队列怎么调度,最终写入Store的数据都会被Controller的版本检查拦截。架构层的刹车,在这里体现得很具体。
4. 第三重刹车:状态生命周期的语义化定义
4.1 四类生命周期:瞬时、页面、会话、持久
状态所有权定义了“归谁”,通信通道定义了“怎么流”,第三个要解决的是“活多久”。这就是状态的生命周期。
我习惯把Flutter项目的状态分成四类:
| 类型 | 生命周期 | 典型例子 | 存放位置 |
|---|---|---|---|
| 瞬时状态 | 随某个Widget帧内存在 | 动画进度、拖拽偏移量 | StatefulWidget的State |
| 页面状态 | 随页面存在(页面在栈里就在) | 列表筛选条件、下拉刷新状态 | 页面Controller |
| 会话状态 | 随用户一次登录会话存在 | 登录用户信息、购物车、浏览历史 | 应用级Store |
| 持久状态 | 跨会话存在 | 用户偏好设置、本地草稿 | 本地数据库或SharedPreferences |
这个划分的价值在于,它强制你思考“这个状态如果丢了,业务能不能接受”。
页面状态的判断标准是:用户切走再切回来,这个值应该还在吗?如果应该,它就不该放在一个会被销毁的Widget的局部State里,要么提升到页面Controller,要么用KeepAlive机制保住。会话状态的判断标准是:用户退出登录再登录,这个值应该清空吗?如果应该,它就不该在全局单例里一放放一辈子,而应该在登录状态切换时被显式清理。很多App出现“切换账号后看到上一个账号的购物车”这种事故,本质上就是生命周期层级放错了。
持久状态的判断标准是:用户杀掉App再打开,这个值应该保留吗?如果应该,这意味着它需要写盘,不能只放在内存里。我见过有团队把用户购买记录放在内存Map里,结果App一重启数据全没了,用户在设置里看到的“已购记录”永远只有当天的。这就是典型的生命周期层级错配。
4.2 Navigator切换页面后的状态保留:从原理到落地方案
现在回到大家关心的那个问题:“Navigator切换页面后,会丢失状态吗”。要回答清楚,得先理解Flutter导航栈的机制。
在Navigator push一个新页面时,原页面并没有立即销毁,它只是被移到了栈的更深处,其State对象依然活着,所以普通的“切走再切回”不会丢状态。真正会丢状态的情况有三类:
第一类,页面被真正销毁。当Navigator pop掉一个页面,或者外层条件变化导致整棵子树被重建时,该页面的State会被销毁。如果你把业务状态全放在页面State里,那么页面销毁,状态自然跟着消失。第二类,内存压力导致页面被回收。Android系统在低内存时可能销毁不可见页面,Flutter侧表现因引擎配置而异,但底层资源回收后页面State可能被重建。第三类,使用了易失性的状态容器,比如把状态放在了一个会被频繁重建的父级Widget里,或者放在了路由参数传过来的临时对象里。
针对这三类情况,我的建议分三层处理。
第一层,从架构上做状态提升。凡是“回来后必须还在”的状态,提前放到页面Controller或应用级Store,不依赖页面Widget是否存活。这是最根本的解法,也是最被低估的解法。很多人宁可去研究各种KeepAlive技巧,也不愿意花半小时把状态从State里挪出来。
第二层,在页面栈层面用KeepAlive。对于TabBar页、PageView页这种需要保留滚动位置的场景,可以用IndexedStack或AutomaticKeepAliveClientMixin保住页面State。但这里有一个很多人踩过的坑:KeepAlive保住的只是Widget树,不是Controller里的业务状态。如果业务状态放在Controller里,但Controller被创建时的宿主Widget销毁了,那Controller也一起没了。所以KeepAlive只能作为辅助,不能作为“状态还活着”的保证。
第三层,用保存与恢复机制兜底。如果状态确实依赖页面存活,那么至少要实现保存与恢复。Flutter提供了PageStorageKey等机制,可以在页面重建时恢复部分状态。但我的个人建议是:不要把业务核心状态寄托在PageStorage上,它更适合保存Scroll位置这类UI形态。
在代码层面,一个典型的正确姿势是这样:
// 页面Widget只负责UI,把业务状态全部放在Controller里 class GoodsListPage extends StatefulWidget { const GoodsListPage({super.key, required this.controller}); final GoodsListController controller; @override State<GoodsListPage> createState() => _GoodsListPageState(); } class _GoodsListPageState extends State<GoodsListPage> { @override Widget build(BuildContext context) { return Scaffold( body: widget.controller.isLoading ? const Center(child: CircularProgressIndicator()) : ListView.builder( // 根据 controller 里的数据渲染 ), ); } }当页面被pop后,Controller如果由更上层的容器持有,那么下次进入页面时可以直接复用;如果Controller是跟着页面创建的,那也需要在页面重建时重新初始化。这个决策要提前定,不要写到一半才来纠结。
4.3 KeepAlive、IndexedStack和PageStorage:辅助手段的适用边界
最后单独聊一下常见的三个辅助手段,因为网上教程太多,很容易被误用。
IndexedStack适合TabBar这种“几个固定页面切换”的场景,所有Tab页同时活着,切换不重建。代价是所有Tab页的构建成本一次性付清,页面多时会拖累首帧性能,一般不超过三个Tab时体验没问题。
AutomaticKeepAliveClientMixin适合PageView中“滑出屏幕后不希望被回收”的页面。但要注意,它只能保住State对象,和Controller的存活没有必然关系。用了它,页面State不会销毁,但业务状态如果属于外部Controller,还是要靠Controller的生命周期管理。
PageStorageKey适合保存Scroll位置等UI形态。它本质上是一个存储桶,通过Key将状态写入PageStorage,页面重建时恢复。它不适合保存复杂的业务数据,原因很简单:没有一个清晰的清理时机,App数据积累多了容易残留。
这些辅助手段的适用边界可以记住一句话:它们解决的是“UI层状态怎么保活”,解决不了“业务状态生命周期放错了层级”的问题。真正的架构刹车,还是得靠状态提升和明确的生命周期分级。
5. 把刹车装上:团队落地Code Review检查清单
5.1 八个“事故信号”检查项
架构规矩定得再好,落不了地等于零。我自己的经验是:规矩要变成Code Review的检查项,每次拉MR时逐条过。下面这份清单是团队里实际使用的,可以直接抄走:
- 新增状态时,是否明确写了状态属于哪一层(瞬时、页面、会话、持久)?
- 同一个业务状态是否存在两处以上的存储副本?
- 子组件是否只通过回调或事件通知父级,而不是直接修改父级持有的数据?
- 跨页面传递对象时,是传整个Model还是只传必要的ID或参数?
- 页面里的Controller是跟着页面创建,还是从上层容器获取?这符合生命周期设计吗?
- 有没有出现“为了刷新一个Widget而向上透传了超过两层的Callback”?如果有,说明状态层级设计有问题。
- 异步回调更新状态时,有没有做“请求仍然有效”的判断(版本号或序列号)?
- 新加的全局事件会不会影响超过两个页面?会的话,考虑收敛到Store监听。
这八个问题是事故信号的高频来源,但不是全部。团队成熟后可以继续扩充,核心逻辑是:每一次Review都在回答架构层那三个原始问题——状态是谁的、状态在哪层、状态怎么被改。
5.2 落地节奏:别想着一步到位
我也犯过一个错误:新项目启动时,试图把整套架构一次性铺开。结果是团队前两周一直在为架构争论,业务没什么进展。后来调整了节奏,分三步走。
第一步,先定最小约定。只约定状态所有权三条规则、四类状态分类,不强制指定状态管理库。团队用Provider也好、用Riverpod也好,只要状态归属和流向上都按约定来。第二步,在一个模块内试点。挑一个业务相对完整的模块(比如购物车),用Controller加监听加单向数据流的方式完整跑一遍,把问题暴露出来。第三步,复盘后推广。试点结束后集中复盘,把踩过的坑沉淀成团队文档,再逐步推广到其他模块。
很多团队跳过第二步,直接让全员按一个理想架构去改,最后往往以“我们试过但失败了”收场。架构落地这件事,最忌讳的不是慢,而是步子太大扯到蛋,然后失去团队信任。
5.3 顺带说下:这些内容在面试里怎么答
Flutter面试中,“状态管理”是绕不开的高频题。我看了不少人的面试反馈,发现很多人能背出Provider、Riverpod、Bloc的源码区别,却答不好一个最基础的问题:“你为什么选这套方案,它解决的是什么问题”。
其实把上面这套架构思路想清楚,这类问题就很好答。你可以说:我选择状态管理方案,不单纯看它API好不好用,而是看它能不能帮助团队落实状态所有权、通信通道和生命周期设计。比如Riverpod的Provider作用域天然适合“页面状态与会话状态分级”,Bloc的Event-Sink模型天然强制了单向数据流。把这些原理讲出来,比背十篇源码解析更能体现架构能力。
另外一个容易被问到的点是:“Navigator切换页面后,状态怎么保活”。这时候如果你能把“页面Widget生命周期、Controller生命周期、以及KeepAlive的适用边界”分层说清楚,基本就能让面试官确认你真的踩过坑、真的理解Flutter状态管理的本质。
最后分享一个我这两年的个人体会:状态管理的复杂度,从来不是靠“换一个更牛逼的状态管理库”解决的。库只是工具,真正让项目活下来的是架构层的纪律。每当你新增一个状态、每当你准备跨页面传一个参数,都停下来问一句:按我们的约定,这份状态属于谁、它活多久、它会被谁改到?多问几次,项目里的状态复杂度就会始终保持在一个让人睡得着觉的水平。这也是“架构层提前刹车”在我看来最实在的价值。