Flutter 状态管理:ChangeNotifier 加一层封装,够用且不简陋

作者:ezview_uniview日期:2026/9/21

这是「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 的一部分,不是生态的一部分

ChangeNotifierpackage: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 你需要同时理解 EventStateBlocBlocProviderBlocBuilderBlocListenerBlocConsumeremitCubiton<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 不会报错,只会让界面在某次快速操作后显示成四不像。

短板二:生命周期要自己管

ChangeNotifierdispose() 之后调用 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,全局配一次,单页可就近覆盖。

三种粒度的通知。 区分"状态变了"、"数据变了"、"只是局部忙碌",让重建范围精确可控。


小结

这一篇想传递的判断是:

  1. 状态管理工具的主战场(共享、派生)和业务页面的主战场(IO 流程)不是同一件事,容易错配。
  2. ChangeNotifier 被低估了 —— SDK 内置意味着零依赖零迁移,机制简单意味着完全可控,而它天然支持局部刷新的能力,是 setState 做不到的。
  3. 裸用 ChangeNotifier 确实会写脏,但短板的解法是加封装,不是换工具。
  4. 换不换工具,取决于复杂度来自状态图还是 IO 流程

下一篇讲建模:怎么从业务页面反推出这三个基类、五个状态,以及为什么这个建模过程比代码本身更重要。


系列文章:


Flutter 状态管理:ChangeNotifier 加一层封装,够用且不简陋》 是转载文章,点击查看原文


相关推荐


Linux 网络编程--套接字选项
raindayinrain2026/9/13

1.套接字选项概览 Linux 提供三级设置接口: int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen); int getsockopt(int sockfd, int level, int optname, void *optval, socklen_t *optlen); level(级别) S


看 react-bits,不要只看“酷炫”:一套阅读动画交互组件库的框架
吴琼琼2026/9/5

看 react-bits,不要只看“酷炫”:一套阅读动画交互组件库的框架 开源项目的第一印象往往很快形成:截图足够吸睛、演示足够流畅、热度数字足够醒目,于是开发者很自然地想问——能不能直接用? 对于 react-bits 这样一个动画交互式 React 组件库,这个问题尤其常见。公开讨论中,“约 36K stars 的酷炫组件”是一个容易吸引注意的角度,但它不应被解读为性能、兼容性、维护质量或生产可用性的保证。 如果只停在“效果很酷”,很容易错过这类项目更有价值的部分:它把动画与交互作为可复用的


解密Prompt系列72. 多模态大模型进化史:从"翻译官"到"原生双语大脑"
风雨中的小七2026/8/28

最近被多模态的效果圈粉,感觉距离那个"OCR 糊成一团、指令理解弱到离谱、幻觉高到吓人"的时代也没过去多久,但多模态模型的效果已经发生了飞跃式的进步。 这篇文章我们来系统梳理这背后的"进化轨迹":模型骨架如何一步步演变、位置编码如何从一维延伸到三维、图片分辨率的难题如何被逐步破解,以及多模态训练策略背后最关键的两个反直觉发现。 一、多模态骨架的三次进化 在进入细节之前,先建立一个整体认知框架:多模态模型的架构演进,本质上是在回答同一个问题—— "图片和文字,到底应该在哪里、用什么方式融合?"


Go 编程实战:Map——使用 Key-Value 管理键值数据
程序员爱钓鱼2026/8/20

上一篇我们学习了 Slice。Slice 非常适合保存一组动态数据,例如: users := []string{"Tom", "Jack", "Lucy"} 但是 Slice 主要通过数字下标访问元素: users[0] users[1] 实际开发中,我们经常希望通过用户名、商品编号、配置名称等直接查找数据。例如: "Tom" -> 90 "Jack" -> 85 "port" -> 8080 "host" -> localhost 这种“一个 Key 对应一个 Value”的数据结构,就


【计算机毕业设计】基于Hadoop的智慧政务大数据可视化平台设计与实现
xiaotianyuanma2026/8/7

1.系统介绍 随着数字化政务建设的持续推进,传统政务服务存在流程繁琐、信息分散、数据处理效率低等问题,难以满足公众便捷办事与政府高效管理的需求。为实现政务服务智能化、数据管理可视化,本文设计并实现了基于 Hadoop 的智慧政务大数据可视化平台,助力政务服务数字化转型。 平台采用 Java 语言开发,基于 SpringBoot+Vue 前后端分离架构,结合 MySQL 数据库与 Hadoop 大数据框架构建。平台分为用户端与管理员端,用户端提供注册登录、政策推荐、服务预约、在线咨询、数据统计


第13篇:《把PDF变成AI能懂的"密码":我用向量数据库建了个知识库》
第一行代码HW2026/7/29

承上:上一篇我们把文档切成了高质量的小碎片,但它们还只是文本。AI不认识文本,只认识数字。今天,我们要把这些文本碎片变成一串串数字——向量,存入向量数据库,让AI真正能"理解"你的私有知识。 1. 先搞懂:什么是Embedding? 1.1. 用后端老鸟的类比 假设你是数据库管理员,要给1000本书建立索引: 传统索引: "Java编程思想" → 按书名倒排 → J字母开头 → 第3排第5本 向量索引: "Java编程思想" → 转成一串数字[0.23, -0.15, 0.78, ...]


Python 函数式编程:从思想到实践
卷无止境2026/7/21

函数式编程(Functional Programming,FP)是一种把"计算"看作数学函数求值的编程范式——它不像面向对象那样关注"对象状态",而是强调用函数来描述数据的变换过程。Python 并非纯函数式语言,但它对这套思想的支持相当完善,掌握它能让你的代码更简洁、更易测试、更少 bug。下面我们一层一层把这件事讲清楚。 🧭 核心思想:函数式编程在想什么? 函数式编程的哲学核心只有一句话:数据流过一系列纯函数,产生结果,过程中不改变任何外部状态。 这和流水线工厂很像——每道工序只做一件


Kotlin Flow 深入解析:`stateIn()` 的真正核心,其实是 SharingStarted
潜龙勿用之化骨龙2026/7/13

很多 Android 开发者使用 stateIn() 时,代码几乎都是以下这种: stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5000), initialValue = UiState.Loading ) 这其实已经是标准写法了。 实际上: stateIn 做的事情只有两件: 1、把冷流升级为热流 2、通过 SharingStarted 控制上游生命周期 这里最关


Claude Sonnet 5 上线:别再让 Claude Code 一律烧 Opus
鲁大猿2026/7/5

Claude Sonnet 5 上线:别再让 Claude Code 一律烧 Opus 6 月 30 日,Anthropic 发布 Claude Sonnet 5。 如果你平时用 Claude Code,这条消息不应该只理解成“又来了一个更强模型”。 它真正影响的是一个更具体的团队决策:以后 Claude Code 到底什么时候用 Sonnet,什么时候用 Opus,什么时候必须让人接管? 我的判断很直接: 不要全员默认 Opus。 也不要一听 Sonnet 5 便宜、上下文长,就把所有 Ag


你好,我叫Token——AI世界里最忙的搬砖工
Kfaino2026/6/27

码农的AI翻身之旅(一) 你好,我叫Token——AI世界里最忙的搬砖工 大家好。 我叫 Token。 别看我名字洋气,其实我就是个打零工的。 AI世界里,所有人都认识ChatGPT,认识DeepSeek,认识Claude,认识Gemini…… 可是,没有几个人认识我。 然而,没有我,他们一句话都说不出来。 我的出生 某一天。 一个程序员打开了ChatGPT。 他说: 帮我写一个Spring Boot项目。 于是,我出生了。 准确来说,不是我一个。 而是一大群兄弟。 因为AI眼里,根本没

首页编辑器站点地图

本站内容在 CC BY-SA 4.0 协议下发布

Copyright © 2026 聚合阅读