这是「Flutter 状态管理:从选型到落地」系列的第一篇。后两篇分别讲建模过程和工程细节。代码已整理成完整可跑的 package。
一、一句常听到的对话
"你们项目用 Riverpod 还是 Bloc?" "ChangeNotifier,配着 Provider 用。" "那不是很原始吗……"
最后那句话里藏着一个默认假设:工具越高级,代码质量越好。
但状态管理这个话题被讨论得太多了,多到掩盖了真正的问题。真实情况是:同一个团队用 Riverpod 写出了比 ChangeNotifier 更乱的代码,也比比皆是;反过来,一个结构清晰的 ChangeNotifier 方案,可维护性完全能扛住几十个页面的项目。
工具决定上限,结构决定下限。大部分项目的痛点在下限,不在上限。
二、先把「状态管理」这个词拆开
这个词太笼统了,笼统到讨论时常常是各方在说不同的事。实际它至少包含三类需求:
| 类型 | 例子 | 主要矛盾 |
|---|---|---|
| ① 状态共享 | 用户信息、主题、购物车,跨页面跨 Widget 访问 | 依赖注入、作用域、内存泄漏 |
| ② 状态派生 | 从商品列表算出总价、从筛选条件算出结果集 | 计算缓存、依赖追踪、重算时机 |
| ③ 异步状态流转 | 请求中 → 成功 / 空 / 失败 → 重试 → 刷新 | 流程编排、生命周期、并发 |
Riverpod 和 Bloc 的主战场在 ①②。它们在这方面确实强:编译期安全、依赖追踪精确、派生状态自动重算。
但在实际业务里,出现频率最高的是 ③,而且 ③ 本质上是个流程编排问题,换工具帮不上太多忙。
看看没有封装的列表页典型写法:
class OrderListPage extends StatefulWidget { const OrderListPage({super.key}); @override State<OrderListPage> createState() => _OrderListPageState(); } class _OrderListPageState extends State<OrderListPage> { List<Order> orders = []; bool loading = true; bool loadingMore = false; bool hasMore = true; String? error; int page = 1; @override void initState() { super.initState(); _load(); } Future<void> _load() async { setState(() { loading = true; error = null; }); try { final res = await api.orders(1); if (!mounted) return; setState(() { orders = res; loading = false; page = 1; hasMore = res.length >= 20; }); } catch (e) { if (!mounted) return; setState(() { loading = false; error = e.toString(); }); } } Future<void> _loadMore() async { if (loadingMore || !hasMore || loading) return; setState(() => loadingMore = true); try { final res = await api.orders(page + 1); if (!mounted) return; setState(() { orders.addAll(res); page += 1; loadingMore = false; hasMore = res.length >= 20; }); } catch (e) { if (!mounted) return; setState(() => loadingMore = false); } } @override Widget build(BuildContext context) { if (loading) return const Center(child: CircularProgressIndicator()); if (error != null) { return Center(child: Column(children: [ Text(error!), ElevatedButton(onPressed: _load, child: const Text('重试')), ])); } if (orders.isEmpty) return const Center(child: Text('暂无数据')); return NotificationListener<ScrollNotification>( onNotification: (n) { if (n.metrics.extentAfter < 120) _loadMore(); return false; }, child: RefreshIndicator( onRefresh: _load, child: ListView.builder( itemCount: orders.length + 1, itemBuilder: (c, i) => i == orders.length ? _footer() : OrderTile(orders[i]), ), ), ); } }
代码不算错,但三个问题同时存在:
问题一:重复。 下一个列表页会把这 60 行再抄一遍,改一个细节要改 N 个文件。
问题二:生命周期是散的。 if (!mounted) return 出现在每个 await 后面,漏一个就是"页面退出后 setState 崩溃"。注意这里用的是 mounted,它检查的是 State 是否还挂在树上,但请求返回时 mounted 仍可能为 true(页面还在但已不关心结果)——真要处理"请求过期",mounted 是不够的。
问题三:并发是假的。 loadingMore 那个判断看起来在防重复,但下拉刷新和加载更多之间没有任何协调——用户在加载更多时下拉刷新,两个请求会同时写 orders,后回来的覆盖先回来的。
这三个问题,换成 Riverpod 或 Bloc 一个都不会自动消失。
它们属于"谁来管流程"的问题,而每一种状态管理工具都把这个决定权留给了你。
三、把方案摆到光谱上

这张图想说明的不是"谁更好",而是成本和所解决的问题并非线性关系。
Bloc 的学习成本和样板代码接近满格,这是它为「强约束」付出的代价 —— 在需要严格事件溯源、多人协作强制统一写法的场景下,这个代价是值得的。但如果你的项目只是"十几个页面,每个页面请求一次数据显示出来",为它付出这个成本就不划算了。
关键判断:你的复杂度来自「状态图」,还是来自「IO 流程」?
- 状态图复杂(多模块共享、派生关系成环、需要时间旅行调试)→ 右侧方案值得
- IO 流程复杂(分页、刷新、缓存、弱网、失败重试)→ 左侧方案 + 一层封装更对症
第二类才是移动端业务的绝对主力。
四、ChangeNotifier 被低估的三个理由
理由一:它是 SDK 的一部分,不是生态的一部分
ChangeNotifier 在 package:flutter/foundation.dart 里,从 Flutter 1.0 到现在签名没变过。
这件事的实际价值,被经历过依赖升级的人低估了。第三方状态管理库的大版本升级往往是破坏性的:从 Provider 4 升到 6、从 Bloc 7 升到 8,API 都有实质性变化,迁移成本按项目规模放大。而 ChangeNotifier 不需要迁移 —— 它不是依赖,它就是框架本身。
零依赖 = 零版本焦虑 = 零供应链风险。
理由二:它的机制简单到可以完全掌握
整个接口只有三个方法:
void addListener(VoidCallback listener); void removeListener(VoidCallback listener); void notifyListeners();
外加一个 dispose()。就这样。
对比一下,用 Bloc 你需要同时理解 Event、State、Bloc、BlocProvider、BlocBuilder、BlocListener、BlocConsumer、emit、Cubit、on<Event> 的注册机制……
简单不等于简陋,简单意味着所有行为都是可预测的。 线上出问题时,"能在一杯咖啡的时间里把机制在脑子里跑一遍"和"要去翻框架源码",是两个完全不同的处境。
理由三:它天然适合做局部刷新
这一点最被低估,也最值得展开。

ChangeNotifier 做了一件事:把「通知」和「重建范围」解耦了。
- 通知是全局广播 ——
notifyListeners()会回调所有订阅者 - 但重建范围由你在哪里写 builder 决定
也就是说,同一个 ViewModel 可以挂 5 个监听者,每个监听者只重建自己那一小块 UI:
// 只有这个 Text 会随 vm 变化重建 VmBuilder<OrderVM>( vm: vm, builder: (ctx, vm) => Text('共 ${vm.count} 条订单'), )
而 setState 做不到这种分离 —— 它一调用,整个 build() 重新执行,下面所有子 Widget 全部重建。
这不是"每次重建都慢",而是当页面里有长列表、大图、复杂布局时,重建成本会被放大几十上百倍。 这才是"页面卡顿"的真正来源。
ChangeNotifier 的局部刷新能力不是额外做的优化,而是这套机制自带的 —— 你只要把 builder 包在需要更新的位置就行。
五、但裸用 ChangeNotifier,确实会写脏
前面说了三个优势,现在说三个真实短板。不承认短板的推荐没有可信度。
短板一:状态没有约束
裸的 ChangeNotifier 里,任何字段都能随便改,没有统一的状态概念。最常见的退化是加一堆 bool:
bool isLoading = false; bool isEmpty = false; bool hasError = false;
三个 bool 有 8 种组合,其中 5 种是非法状态(比如 isLoading && hasError 同时为 true 时,UI 该显示什么?)。这类 bug 不会报错,只会让界面在某次快速操作后显示成四不像。
短板二:生命周期要自己管
ChangeNotifier 在 dispose() 之后调用 notifyListeners() 会直接抛异常:
A ChangeNotifier was used after being disposed.
而异步请求不知道页面已经销毁了。用户点进详情页又秒退,请求 2 秒后返回 —— 崩溃。
有人会用 mounted 判断,但 mounted 只在 State 内部可用,ViewModel 层拿不到它。于是这个判断就散落在每个 await 后面,漏一个就是一个线上崩溃。
短板三:并发完全没有防护
这是最隐蔽的一个。用户的真实操作序列往往不是设计者想的那样:
- 下拉刷新还没回来,又下拉了一次
- 正在加载更多,突然下拉刷新
- 搜索框连续输入,发了 5 个请求
这些情况下会有多个请求同时在飞。而网络不保证先发先到 —— 晚发出的请求完全可能先回来。于是:
请求1发出(慢) 请求2发出(快) 请求2返回 → 渲染了新数据 请求1返回 → 用旧数据覆盖了新数据 ← 界面显示错误内容
这个 bug 在办公室 Wi-Fi 下永远不会出现,只在弱网下必现。所以它能活到线上,而且极难复现。
六、这三个短板,恰好是可以被封装掉的
关键认知在这里:
短板的解法不是换工具,而是加一层封装。
- 状态没约束 → 定义一个
ViewState枚举,把 8 种组合收敛成 5 种合法状态 - 生命周期没保障 → 在基类里持有
_disposed标志,所有通知入口统一拦截 - 并发没防护 → 基类给每个请求打序号,返回时校验,过期直接丢弃
这三件事,加上"异常统一收敛",就是一套完整的状态管理底座。而它是可以用不到 200 行代码写完的。
有了这层底座之后,业务代码会变成这样:
class OrderListVM extends RefreshListViewModel<Order> { @override Future<List<Order>> fetchPage(int page) => api.orders(page); @override void onSilentError(Object error, String? message) => Toast.show(message); }
9 行。 下拉刷新、上拉加载更多、分页推进、失败重试、空视图、加载中、生命周期安全、并发防护 —— 全部白拿。
七、那什么时候真的该换?
给出客观标准,ChangeNotifier 不是万能的。以下场景应该认真考虑 Riverpod 或 Bloc:
| 场景 | 为什么 ChangeNotifier 不合适 |
|---|---|
| 需要编译期依赖安全 | ChangeNotifier 靠运行时查找,漏注入是运行时错误 |
| 派生状态形成复杂依赖图 | 手动管理重算时机容易漏,Riverpod 的自动追踪更省心 |
| 需要时间旅行 / 状态回放调试 | 这需要不可变状态 + 事件记录,ChangeNotifier 是可变模型 |
| 多人协作需要强制统一写法 | 强约束是 Bloc 的价值,ChangeNotifier 太自由反而成了风险 |
| 跨 5 个以上模块共享同一份状态 | 作用域管理会变得难缠,Riverpod 的 provider 体系更稳 |
反过来,如果你的项目符合这些特征,ChangeNotifier + 封装是更经济的选择:
- 页面数量在几十个量级,以"请求-展示-交互"为主
- 状态生命周期基本等于页面生命周期
- 团队规模不大,靠约定和 code review 就能维持一致
- 不想为了状态管理引入一个需要长期跟进升级的依赖
八、这层封装要做成什么样
后面的两篇会把这套东西完整实现出来。先给整体设计:
三类页面,三个基类。 把业务页面穷举一遍会发现它们收敛到三种形态:
| 形态 | 基类 | 子类只需实现 |
|---|---|---|
| 单实体展示(详情页) | DetailViewModel<T> | Future<T?> loadData() |
| 可刷新列表(信息流) | RefreshListViewModel<T> | Future<List<T>> fetchPage(int page) |
| 纯列表(无分页) | SimpleListViewModel<T> | Future<List<T>> fetchList() |
一个状态机,五个状态。 idle / loading / success / empty / error,把非法组合从类型层面消灭。
一份全局配置。 空视图、加载中文案、加载中图、失败视图、footer 图、刷新头实现 —— 收口到 StateKitConfig,全局配一次,单页可就近覆盖。
三种粒度的通知。 区分"状态变了"、"数据变了"、"只是局部忙碌",让重建范围精确可控。
小结
这一篇想传递的判断是:
- 状态管理工具的主战场(共享、派生)和业务页面的主战场(IO 流程)不是同一件事,容易错配。
- ChangeNotifier 被低估了 —— SDK 内置意味着零依赖零迁移,机制简单意味着完全可控,而它天然支持局部刷新的能力,是 setState 做不到的。
- 裸用 ChangeNotifier 确实会写脏,但短板的解法是加封装,不是换工具。
- 换不换工具,取决于复杂度来自状态图还是 IO 流程。
下一篇讲建模:怎么从业务页面反推出这三个基类、五个状态,以及为什么这个建模过程比代码本身更重要。
系列文章:
- (一)ChangeNotifier 加一层封装,够用且不简陋 ← 本篇
- (二)三类页面,一套抽象:状态管理框架的建模过程
- (三)从能跑到能扛:状态框架绕不开的四个异步难题