聊 Flutter 状态管理,几乎是每个从 demo 走向真实项目的开发者绕不过去的一道坎。我见过不少朋友,基础语法学完,官方计数器也跑通了,真要动手做个像样的 App,立刻卡在同一个问题上:两个页面怎么共享同一份数据?数据变了,界面为什么没有跟着变?这几个问题背后,指向的都是 Flutter 状态管理。这篇内容我会从最朴素的 setState 讲起,把状态管理的来龙去脉、常见方案选型、以及我自己在生产环境里踩过的坑一次说清楚,适合刚学完 Flutter 基础、准备开始做完整项目的开发者,也适合那些已经在用某种方案但总感觉哪里不对劲的进阶玩家。
1. 先看清楚问题:setState 是从什么时候开始不够用的
很多人刚接触 Flutter 时,第一个状态相关 API 就是setState。官方默认的计数器项目用setState改一个数字再刷新界面,逻辑非常直白,也很容易理解:调用setState之后,Flutter 会重新执行该 State 对象的build方法,UI 随之更新。这套机制在单个页面的小 demo 里几乎无懈可击——代码量少,逻辑直观,不需要引入任何额外概念。
但当你的应用开始出现“多个页面需要共享同一份数据”的需求时,setState的局限性会立刻暴露出来。举个我经常拿来举例的场景:首页有个购物车入口,右上角显示商品数量;商品详情页里有个“加入购物车”按钮。用户在详情页点按钮,首页角标要跟着变。如果你只靠setState做这件事,代码会变成什么样?你需要有一个全局变量保存购物车数量,详情页修改后想方设法通知首页重新build。最简单的办法可能是用事件总线广播,或者把首页的GlobalKey扔到全局,再通过它拿到 State 对象手动调用setState。这些方案在数据链路简单时还能跑,一旦状态多了,你会发现代码里到处是硬编码的全局引用,改一个功能连着炸一片。
另一个被很多人忽略的问题是setState的性能边界。setState的语义是“重建整个 widget 的 build 方法”,如果这个 widget 下面挂着几百个节点,哪怕你只改了一个数字,整棵子树都要重新执行 build。Flutter 的重建机制本身有高效的 diff 来减少真正变化的渲染开销,但 build 方法里那些非轻量级任务——比如网络请求、复杂计算、频繁的对象创建——每次重建都要重复跑一遍,这就是性能劣化的温床。
所以,状态管理是要解决的核心问题不是“这个变量应该放在哪里”,而是数据变化之后,到底哪些界面需要更新,以及怎么让这个更新过程可控、可预测、不泄漏。我个人的经验是:只要你的应用开始出现两个以上页面共享可变数据,或者任意两个非父子组件需要互相通信,你就应该认真考虑引入一套正经的状态管理方案,而不是继续靠setState和全局变量硬撑。
1.1 状态的本质:数据与界面之间的契约
我们常说的“状态”,本质上是应用运行过程中的一份数据快照。用户在搜索框输入文字,这个字符串是状态;购物车里的商品列表,这个数组是状态;用户在哪个 Tab 页,这个页码也是状态。所谓状态管理,管理的就是这份数据和 UI 之间的映射关系:数据发生变化时,哪些组件要重新渲染;组件上发生用户交互时,数据要如何更新;数据更新的完整生命周期里,是否会产生重复、遗漏或泄漏。
我习惯用一个“台账”的类比来解释:你的应用就是一间办公室,状态就是办公室里随时可能被修改的各类台账。setState像是你有事就扯着嗓子喊一声,整个办公室的人都停下来把手头工作重做一遍,小团队还能应付,人一多必然乱套。状态管理方案则像是给每类台账配了专职的负责人和订阅机制,只有真正关心某份台账的人,才在那个台账被修改时得到通知并更新自己手头的工作。
理解了这个本质,再看各种状态管理方案就会清晰很多:它们无非是围绕“数据存放”“变更通知”“订阅更新”这三个环节,给出了不同风格的设计。
1.2 当全局变量不再是答案
有些读者可能觉得:那我用全局变量不就行了?写个Map放全局,哪里用哪里取,再配合一个全局事件广播来通知刷新。这种方式在小项目里确实能跑,但坑非常多。全局变量本身没问题,Flutter 的InheritedWidget底层某种意义上也类似一个全局树状存储,问题出在“无约束的全局访问”。任何地方都能改这份数据,意味着你无法追踪数据是在何时、被谁改成了什么样,排查问题全靠猜。
再加上状态管理还有一个隐藏维度:生命周期。全局变量的生命周期和应用进程绑定,但界面上某个组件可能已经被销毁。如果数据更新后去通知一个已经销毁的组件更新 UI,轻则空异常,重则内存泄漏。状态管理方案通常都内置了生命周期管理和依赖销毁机制,这也是它相比手写全局变量更可靠的根本原因。
2. 把状态分类,一半的问题已经有了答案
先别急着挑状态管理库,第一步应该做的是把项目里的状态分个类。Flutter 官方文档里经典的分类是 ephemeral state(临时状态/局部状态)和 app state(应用状态/共享状态),这个分类方式我建议每个人都认真理解一下。
临时状态,指的是只属于某一个 widget、不需要被其他组件共享、页面销毁后状态随之消失的数据。比如表单输入框里正在编辑的文本、一个折叠面板的展开/收起状态、BottomNavigationBar 当前选中的 Tab 索引。这类状态用StatefulWidget配合setState解决是最合适的,引入状态管理框架反而是过度设计。
应用状态,指的是需要在多个 widget 之间共享、或者需要跨页面持久保留的数据。比如用户登录信息、购物车数据、应用主题设置、通知开关列表等。这类状态才需要状态管理框架介入。
我见过不少新手把两种状态混为一谈,结果就是每个输入框都要经过全局 store,项目复杂度直接被拉高一个量级。反过来的问题也有——把应该全局共享的状态塞在某个页面的 State 里,然后通过路由传参硬传,页面深了就传得怀疑人生。分类是件小事,但如果分不清哪些状态该集中管理、哪些不该,后面怎么做都不顺。
2.1 判断状态类型的三条实用标准
我总结了三个问题,每次拿不准时就拿它们套一下:
- 这个状态是否同时被两个及以上互不嵌套的组件使用?
- 用户从这个页面离开再回来,这个状态是否应该保留?
- 改掉这个状态后,是否有页面之外的逻辑需要联动响应?
只要第一条成立,它大概率是应用状态,值得考虑放进状态管理容器。如果只有第二条成立,可能是缓存型状态,具体看数据的体量和重建成本再决定。第三条是很多人容易忽略的,也是后面讲异步踩坑时的高发区:比如修改状态后要触发网络请求、本地存储写入、或者埋点上报,这些逻辑如果写死在某个页面的setState里,耦合度会相当高。
2.2 状态收敛:别急着写代码,先把状态清单列出来
实操层面我还有一个习惯:新项目开工前,先把核心状态的清单列出来。每一条的状态包括名字、类型、初始值、谁修改它、谁订阅它。这个清单不用很正式,写在笔记里都行,但它能逼着你把“状态”从模糊的感觉变成明确的决策。很多项目后期改状态管理方案,根本原因是前期没有做这一步,导致方案选型跟着感觉走,代码写到一半发现撑不住。
3. 内置能力其实比你想的强:InheritedWidget 和 ChangeNotifier
很多新手以为 Flutter 自己不带状态管理,必须上第三方库,其实这是个误解。Flutter 框架本身提供了一套非常完整的底层机制,第三方库大多只是在这套机制上做了封装。
Flutter 采用的是“状态自上而下流动”的组件树模型。父 widget 可以通过构造参数把数据传给子 widget,这是最简单也最无聊的方式——逐层手动传递,组件一多就写着烦。InheritedWidget的提出,就是为了解决“深层组件要获取祖先数据”的问题。它的核心机制是:一个InheritedWidget挂在组件树的某个节点上,往下的任意子节点都可以通过context.dependOnInheritedWidgetOfExactType获取到它的引用,并且当这个InheritedWidget的数据发生变化时,依赖它的子组件会自动重建。
这个机制是 Flutter 状态管理的基石,Provider 的底层就是它。但直接用InheritedWidget写业务逻辑,代码会显得笨重,因为你需要自己创建 InheritedWidget 的子类、处理updateShouldNotify、管理数据更新。这就是ChangeNotifier登场的地方。
ChangeNotifier是 Flutter SDK 自带的一个类,实现了一个非常标准的观察者模式:你有数据对象继承它,调用notifyListeners()时,所有注册的监听者会收到通知。配合ListenableBuilder(早期叫AnimatedBuilder,现在推荐用前者)可以很方便地把数据和 UI 绑定在一起。我用一段示例来说明这组内置方案的用法。
首先定义数据模型:
class CartModel extends ChangeNotifier { final List<CartItem> _items = []; List<CartItem> get items => List.unmodifiable(_items); int get totalCount => _items.fold(0, (count, item) => count + item.count); double get totalPrice => _items.fold(0, (sum, item) => sum + item.price * item.count); void add(CartItem item) { _items.add(item); notifyListeners(); } void remove(String id) { _items.removeWhere((item) => item.id == id); notifyListeners(); } }这里的关键点是所有修改都会在最后调用notifyListeners(),这是通知机制的中枢。UI 层消费它:
ListenableBuilder( listenable: cartModel, builder: (context, child) { return Text('购物车总量:${cartModel.totalCount}'); }, )当cartModel调用notifyListeners()后,这个Text会自动重建。这套组合的实现成本极低、逻辑非常清晰,如果项目规模不大,我甚至觉得可以不用第三方库。
那为什么还要有 Provider、Riverpod、Bloc 这些库呢?因为ChangeNotifier+ListenableBuilder只解决了“数据变更后 UI 怎么更新”的问题,但没解决“这个 model 实例放在哪里、如何被不同页面共享、什么时候被释放”。你自己用InheritedWidget去挂载和查找 model,写多了就想要个更顺手的工具类——这就是 Provider 诞生的背景。
3.1 InheritedWidget 的查找机制决定了状态作用域
InheritedWidget有一个非常重要的特性需要理解:数据是沿着组件树从上往下“作用”的。同一个InheritedWidget挂在根节点,全应用都能访问;挂在某个路由页面下,就只有这个页面及其子组件能访问。这个特性让状态的作用域变得可控制。
我在实践中经常利用这个特性来做“页面级状态隔离”。举个例子:一个订单填写页,里面分为收货地址模块、商品清单模块、优惠券选择模块。这些模块之间需要共享“当前订单草稿”这个状态,但这个订单草稿完全没必要放到全局 store 里。此时可以在订单页面顶部挂一个InheritedWidget,让三个模块都能读到同一份草稿,而其他页面完全感知不到它。等用户提交订单后,这个页面出栈,状态随之被 GC 回收,不需要手动清理。
这种状态作用域的思考方式,在很多第三方状态库中仍然适用。Riverpod 的ProviderScope和 Bloc 的BlocProvider本质上都是在做“状态挂载到组件树哪一层”这个决策。
3.2 用 ValueNotifier 处理单一值状态
这里顺带提一个很实用但容易被忽略的小工具:ValueNotifier。它是ChangeNotifier的一个轻量变体,专门用于包装单个值。比如你要跟踪当前选中的商品 SKU、一个进度条的百分比、Tab 切换的索引,直接用ValueNotifier比建立一个完整的ChangeNotifier子类要清爽得多。
final currentIndex = ValueNotifier<int>(0); ValueListenableBuilder<int>( valueListenable: currentIndex, builder: (context, value, child) { return Text('当前索引:$value'); }, )ValueListenableBuilder和ListenableBuilder的区别在于前者直接从ValueNotifier里拿值,少一层手动读取。这种小而美的内置方案,在很多场景下比引入大而全的状态管理框架更合适。
4. 主流方案横评:Provider、Riverpod、Bloc、GetX,到底该学哪个用哪个
当应用规模进一步扩大,你确实需要一个更完善的状态管理框架。目前社区里讨论最多的是 Provider、Riverpod、Bloc、GetX 这四个,我把它们放在一起做个对比,并给出选型建议。
| 方案 | 底层机制 | 学习曲线 | 调试体验 | 第三方依赖 | 适用项目 |
|---|---|---|---|---|---|
| Provider | InheritedWidget 封装 | 平缓 | 好,依赖官方 DevTools | 一个轻量包 | 中小型项目,团队 Flutter 基础参差不齐时 |
| Riverpod | 编译安全 + 依赖注入 | 中等 | 极好,可脱离 Widget 树测试 | 单一包 | 中型项目,希望代码更可测试、可维护 |
| Bloc | 事件驱动 + 状态机 | 较陡 | 极好,事件流清晰可回溯 | 两个关联包 | 大型项目,需要严格统一团队规范 |
| GetX | 全局单例 + 响应式 | 平缓 | 一般,全局访问难以追踪 | 单一包但侵入性强 | 小型敏捷项目,追求开发速度 |
Provider 是 Flutter 官方在文档里推荐过的方案,过去几年一直是社区默认选择。它的核心思路是把你的 model 对象放进InheritedWidget里,然后通过Provider.of或Consumer读取,简单直接。Provider 的 API 设计非常贴近 Flutter 的既有模式,理论上你只需理解 InheritedWidget 和 ChangeNotifier,就能很自然地写出 Provider 代码。正因为这个原因,它对刚入门的人格外友好。
Riverpod 是 Provider 作者后来推出的新方案。它最大的改进是不再依赖 Flutter 的 Widget 树和 BuildContext,状态定义在顶层,通过全局的 ProviderScope 注入。这个设计带来了两个明显好处:一是编译器可以静态检查状态依赖关系,很多类型错误在编译期就能发现,而不是运行期崩溃;二是状态完全脱离 Widget 的创建和销毁,即便不在任何页面里也能取用和测试。代价是需要理解Provider、StateProvider、FutureProvider、NotifierProvider这一套概念,初期容易觉得繁琐。
Bloc 走的是另一条极端的路——事件驱动。它要求你定义事件(Event)和状态(State),业务逻辑集中在 Bloc 内,通过事件流触发状态变更。这种模式的最大价值是“单向数据流”约束得特别严格,团队协作时每个状态变化都有明确的触发原因,非常适合大团队和复杂业务。但代价是模板代码多,一个小功能往往要写 Event、State、Bloc、UI 四份代码,如果项目不大,这种严谨反而是拖累。
GetX 是四个方案里开发效率最高的一个,它把状态管理、路由管理、依赖注入都整合在一起,写起来非常爽,Rx变量改一下 UI 自动更新。但它的实现很依赖全局单例,你可以在任何地方直接读写状态,这带来的问题是我前面说过的“无约束全局访问”:项目越大,越难定位状态是谁改的。此外它的侵入性比较强,一旦用了,项目中到处都会是它的 API,将来想换方案代价极大。
4.1 我的选型建议:先按团队状态,再按项目大小
我不太建议个人开发者为了追求潮流选方案,更建议根据团队情况和项目规模来定。如果你是个人开发或者团队里 Flutter 基础刚起步,Provider 是足够稳妥的起点。它的逻辑容易理解,调试链路短,社区资料最多,遇到问题搜一下基本都有答案。
如果项目规模已经明显变大,比如多个业务模块、多人维护同一个代码仓库,我会优先考虑 Riverpod 或 Bloc。Riverpod 胜在写起来直白、编译期检查给力、单元测试方便。Bloc 胜在流程约束强,适合那种业务链路很长、状态分支很多的模块,比如支付流程、多步骤表单。
至于 GetX,我更推荐在赶原型、做 POC 演示时使用。它的开发效率确实是四者里最高的,用来快速验证产品想法很好。但正式产品要谨慎,尤其是团队协作场景,全局单例带来的可追踪性问题会在后期慢慢反噬你。
4.2 不要被框架绑架:状态管理方案是可替换的
一个很容易被忽略的事实是:状态管理方案是你项目里的“技术债务的一部分”,它不是不可更换的。如果写了两个月发现当前方案不好用,完全可以换。但这不代表你可以一开始随意选,因为更换成本依然存在——所有依赖当前方案的状态定义、注入方式、UI 订阅代码都要重写。
比较务实的做法是:把业务逻辑和状态管理框架解耦。具体来说,就是让 model 层只依赖 Dart 原生能力(比如 ChangeNotifier),不主动导入 Provider 或 Bloc 的 API,然后在应用入口处用哪套框架做注入和订阅。这样要换框架时,业务代码基本不用动,只改注入那一层。这个习惯我在踩过几次全量重写的坑之后才慢慢养成。
5. 实战:用 Provider 从零搭一个购物车模块
理论讲再多,不如动手写一段完整代码。下面我用 Provider 实现一个购物车模块,包含商品列表、加入购物车、购物车角标和总价展示。为了让思路完整,我会按照“模型层 → 状态层 → 注入层 → UI 层”的顺序来写。
5.1 模型层与状态层
首先是商品模型:
class Product { final String id; final String name; final double price; final String imageUrl; const Product({ required this.id, required this.name, required this.price, required this.imageUrl, }); }然后是购物车条目模型:
class CartItem { final Product product; int count; CartItem({required this.product, this.count = 1}); }接下来是购物车状态类。这里需要注意一个设计选择:我是直接修改CartItem.count,还是每次增删都返回新的列表?ChangeNotifier本身对这两种方式都没有限制,但从可预测性角度,我更喜欢“不可变更新”的风格——每次改动都产生新对象,方便以后接入时间旅行调试等功能。不过这个小例子里为了可读性,我选择了直接修改count并调用notifyListeners:
class CartModel extends ChangeNotifier { final Map<String, CartItem> _itemMap = {}; List<CartItem> get items => _itemMap.values.toList(); int get totalCount { var count = 0; for (final item in _itemMap.values) { count += item.count; } return count; } double get totalPrice { var price = 0.0; for (final item in _itemMap.values) { price += item.product.price * item.count; } return price; } void addProduct(Product product) { final existing = _itemMap[product.id]; if (existing != null) { existing.count++; } else { _itemMap[product.id] = CartItem(product: product); } notifyListeners(); } void removeItem(String productId) { _itemMap.remove(productId); notifyListeners(); } void clear() { _itemMap.clear(); notifyListeners(); } }用Map的 key 来保证同一个商品在购物车里只有一条记录,这是从电商业务经验里提炼出来的设计——购物车通常按商品聚合,而不是一个商品占一行。
5.2 注入层:用 MultiProvider 统一注册
在应用的顶层注入 CartModel。我用MultiProvider一次性注册多个 Provider,这样后面再增加其他状态模型时不用改动嵌套结构:
void main() { runApp( const ProviderScope( child: MyApp(), ), ); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MultiProvider( providers: [ ChangeNotifierProvider(create: (_) => CartModel()), ], child: MaterialApp( title: '购物车 Demo', home: ProductListPage(), ), ); } }这里有个隐藏细节:CartModel的创建是在create回调里完成的,而不是在 Provider 外部直接CartModel()。这个区别不是代码风格问题,而是生命周期问题。当这个 Provider 对应的 Widget 从组件树中移除时,Provider 会调用创建对象的dispose方法。如果你的CartModel有监听器或流订阅,这个自动销毁机制能有效避免泄漏。
5.3 UI 层:三种读取方式各有用武之地
Provider 提供了三种读取状态的方式,我结合场景一一解释。
第一种是context.watch<T>(),在 build 中读取状态并订阅变化。当状态变化时,使用watch的 widget 会重建。适合直接依赖状态的 UI 组件:
class CartIcon extends StatelessWidget { @override Widget build(BuildContext context) { final cart = context.watch<CartModel>(); return Badge( label: Text('${cart.totalCount}'), child: const Icon(Icons.shopping_cart), ); } }第二种是context.read<T>(),在事件回调中读取状态但不订阅变化。事件回调里不应该触发重建,所以用read是正确选择:
onPressed: () { context.read<CartModel>().addProduct(product); },第三种是Consumer<T>,它把状态读取和 widget 树重建范围控制在更小粒度。当一个页面上同时有多个watch时,任何一个状态变化都会导致整个页面重建,这是不必要的开销。用Consumer包裹真正依赖状态的那小段 widget,可以避免这种无差别重建:
Consumer<CartModel>( builder: (context, cart, child) { return Text('总价:¥${cart.totalPrice.toStringAsFixed(2)}'); }, )实际项目中我通常是组合使用:页面大骨架用Consumer包裹局部区域,事件回调里用read,页面顶部需要很多状态参与布局时再用watch。这个组合既保证了响应性,也把重建范围控制得比较合理。
5.4 完整加购链路
商品列表页的加购按钮逻辑如下:
ListView.builder( itemCount: products.length, itemBuilder: (context, index) { final product = products[index]; return ListTile( title: Text(product.name), subtitle: Text('¥${product.price}'), trailing: IconButton( icon: const Icon(Icons.add_shopping_cart), onPressed: () { context.read<CartModel>().addProduct(product); }, ), ); }, )这个链路非常直观:按钮点击 →read<CartModel>()拿到状态对象 → 调用addProduct→ 内部修改数据并notifyListeners()→ 所有依赖该状态的组件收到通知并重建。全程没有手动传参、没有全局变量、没有组件间直接通信,数据流方向完全可控。
6. 生产环境里的状态管理踩坑记录
纸上谈兵的部分结束,下面分享一些我在真实项目里踩过的坑。这些坑绝大多数不是框架本身的 bug,而是使用者对状态管理的生命周期和重建机制理解不到位导致的。我把它们写下来,希望能帮你省掉一些定位问题的时间。
6.1 跨异步方法使用 BuildContext 导致的崩溃
这是 Flutter 开发里最高频的崩溃原因之一。场景长这样:用户点击“加载数据”按钮,你await一个网络请求,请求回来后你在回调里使用了页面传入的BuildContext去读取 Provider 或弹出 SnackBar。问题在于:如果用户在这个网络请求期间已经把页面关掉,那个BuildContext已经失效,再用它就会抛异常。
解决方案是两步走:第一,异步方法执行前先判断context.mounted(Flutter 3.7+ 支持);第二,在 lint 层面开启use_build_context_synchronously规则,它会静态提示哪些异步后使用 context 的位置存在风险。我自己是两条都做,lint 规则负责早期拦截,mounted检查负责兜底。
onPressed: () async { final success = await _fetchData(); if (!context.mounted) return; if (success) { context.read<CartModel>().clear(); } }6.2 ChangeNotifier 泄漏:创建了却没人负责销毁
ChangeNotifier有一个隐患:只要还有监听者引用它,它就活得好好的;但如果你创建的ChangeNotifier没有任何对象最终调用它的dispose,它会一直留在内存里。用 Provider 时,只要遵守“在create中创建、不要把对象在外面 new 完再传进来”这个原则,Provider 会自动帮你 dispose。
但如果你的ChangeNotifier是自己手动管理的,比如在某个StatefulWidget的initState里ChangeNotifier(),然后在页面上注册监听,千万记得在dispose里移除监听器和调用ChangeNotifier.dispose()。Leak 的典型表现是用户反复进出页面后,内存占用持续上涨,直到 OOM。
6.3 把大对象塞进全局状态,导致无关页面全部重建
这个问题在 Provider 和 GetX 里都容易遇到。有些开发者习惯把用户信息、购物车、商品列表、主题设置、甚至网络客户端全部放到一个全局状态对象里,图省事。结果就是:用户只是改了一个主题色,所有依赖这个全局对象的页面全部重建,哪怕它们只关心其中一个字段。
正确做法是拆分状态对象,按领域建模。用户信息和购物车不要放同一个ChangeNotifier里,各自独立成模块,谁依赖谁去订阅。这个拆分在状态管理里叫做“状态粒度”,粒度越粗,重建范围越大,性能损耗越大;粒度越细,代码越清晰,性能越好。
6.4 调试状态变化的实用技巧
状态管理方案选得再好,不会调试照样抓瞎。我分享一下平时的调试三板斧。
第一板斧是日志追踪。在每次notifyListeners()前后打印状态对象的摘要信息,能看到状态被修改的顺序。我用一个简单扩展方法:
extension DebugCartModel on CartModel { void debugNotify(String action) { // ignore: avoid_print print('[$action] totalCount=${totalCount}, totalPrice=${totalPrice}'); notifyListeners(); } }第二板斧是 DevTools 里的 Provider Inspector。如果你的项目用的是 Provider,打开 Flutter Inspector 就能看到整棵 Provider 树,点击任意 Provider 可以看到它的实例类型和当前值,对排查“我这个状态到底取到没有”非常有帮助。
第三板斧是单元测试。用 Provider 的状态类因为依赖被抽象出来,直接在纯 Dart 环境里创建状态实例、调用方法、断言值即可。这一点是代码可测试性的直接红利,建议至少在购物车这类核心状态上补几个单测,防止未来改功能时把老逻辑改崩。
6.5 关于“空安全”的提醒
写状态管理代码时,空安全是绕不开的。定义状态模型时,我建议所有字段都严格要求非空,除非业务明确需要区分“未加载”和“空数据”。可用int?加null来区分,否则就用普通类型。这个习惯能让编译器帮你挡住大量“状态为 null 导致崩溃”的问题。另一个比较实用的技巧是:对FutureProvider或StreamProvider的加载态、错误态、数据态分别建模,别用一null表达所有异常场景,否则 UI 层的判空逻辑会写得非常痛苦。
最后说个我自己的体会:状态管理方案永远是为业务服务的,不是业务为方案服务的。我见过有人为了用 Bloc 而把所有简单页面强行改造成事件流,代码量翻倍但没有换来任何实际收益。好的状态管理设计应该让代码读起来像在讲故事:数据从哪里来、变化后谁受影响、什么时候释放,一目了然。如果你用了某个方案之后,发现代码比之前更难懂了,不一定是你的问题,也可能是方案和项目不匹配。及时回头调整,比硬着头皮坚持下去要明智得多。