本节摘要:状态管理是 Flutter 社区火药味最浓的话题,本节把争论变成一张对比表。先划清 setState 的能力边界与 InheritedWidget 的底座作用,再让 Provider、Riverpod、Bloc 依次亮相,最后给轻记账一个明确选择并说明理由。读完你将带走一套选型方法,而不只是一个答案。
第 3 章立过规矩:状态放最小范围。但有些状态天然超出单个组件——登录用户、主题开关、跨页共享的账单列表。当 setState 开始出现这些信号,就到了该上提状态的时候:
信号一:跨组件传递要"击鼓传花"。 用户信息在根节点,要用它的组件在第五层,中间三层被迫接收并转发一个它自己不用的参数。链条越长越脆,中间任何一层重构都可能断链。
信号二:同一份数据在多处副本。 账单列表在首页存了一份,统计页又算了一份,记账后要记得改两处——漏改一处就是数据不一致的缺陷单。
信号三:刷新范围失控。 最小组件原则被打破,改一个开关把整页 setState,列表几千行跟着重建——这是第 6 章性能账单上最常见的一笔。
注意:这些信号说的是"状态该上提",还不是"必须装库"。上提的第一站是 Flutter 自带的 InheritedWidget——第 3 章的 Theme.of 走的就是这条路:把数据放在树的上游,后代按需领取,数据变了自动通知。它免费、无依赖,但 API 偏底层(要手写.of 方法与更新通知),于是社区方案都是把它包出好用的脸。理解了这一点,"状态管理方案"就不再神秘:它们是在 InheritedWidget 底座上,对"怎么改、怎么通知、怎么组织"给出不同答案的封装。

Provider 的答案是"把 InheritedWidget 说成人话"。ChangeNotifier 持有状态,notifyListeners 一声令下,监听者重建;Provider 负责把对象放进树里、按类型取出。它的舒服在于跟 Flutter 原生心智最贴:你写的仍是普通的可变通知对象。代价是组织能力弱——状态一多,"哪个页面听哪个 Provider"靠约定,写错也能跑,错到运行时才炸。
Riverpod 的答案是"状态即函数,依赖即参数"。每个状态是一个 Provider 声明,读别的状态就是引用别的 Provider,整张依赖图在编译期就检查完毕;测试时换实现只需覆盖一个声明,不用架起整棵 Widget 树。代价是概念前置量大了些:ref、family、autoDispose 都要先学。
Bloc 的答案是"把纪律写死在结构里"。一切变化以事件进入,一切界面状态以流输出,中间的迁移函数可枚举、可回放、可测。对大团队,这套结构本身就是审计线索;对两个人的项目,每个状态写三件套(事件、状态、Bloc)的仪式感就是负担。
选型的诚实建议:方案之争的影响远小于数据流纪律之争(下一节的内容)。轻记账按形状选:小团队、状态来源中等、看重测试——选 Riverpod;但如果你的团队已有 Provider 经验且项目两年内不会超十个模块,Provider 完全够用,换库省不下一天的工期。
用 Riverpod 把账单列表立为全局状态,注意它如何同时解掉三个信号:
import 'package:flutter_riverpod/flutter_riverpod.dart'; // 账单仓库:数据来源的唯一入口,第 5 章在这里接入网络(先给内存假数据) final billRepositoryProvider = Provider<BillRepository>((ref) { return MemoryBillRepository(seed: demoBills); }); // 账单列表状态:读仓库初始化,提供记账与删除动作 final billListProvider = NotifierProvider<BillListNotifier, List<Bill>>(BillListNotifier.new); class BillListNotifier extends Notifier<List<Bill>> { @override List<Bill> build() => ref.read(billRepositoryProvider).loadAll(); void add(Bill bill) { ref.read(billRepositoryProvider).save(bill); state = [...state, bill]; // 不可变更新:新列表替换旧列表 } void remove(String id) { ref.read(billRepositoryProvider).delete(id); state = state.where((b) => b.id != id).toList(); } } // 派生状态:统计页要的分类合计,直接由账单列表算出,永不与列表不一致 final categoryTotalsProvider = Provider<Map<String, double>>((ref) { final totals = <String, double>{}; for (final b in ref.watch(billListProvider)) { totals[b.category] = (totals[b.category] ?? 0) + b.amount; } return totals; });
界面侧消费时用 ConsumerWidget,watch 什么就听什么——统计页 watch 的是派生的 categoryTotals,账单列表变化时它自动重算,而它不关心的字段变化一个帧都不浪费:
class StatsPage extends ConsumerWidget { const StatsPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final totals = ref.watch(categoryTotalsProvider); return CategoryPieCard( slices: [ for (final e in totals.entries) CategorySlice(e.key, e.value, colorOf(e.key)), ], ); } }
回看三个信号的解法:击鼓传花被 ref 消灭(任何层都能领);多处副本被派生 Provider 消灭(统计永远从列表现算);刷新失控被 watch 粒度控制(听小不听大)。库只是工具,纪律才是方案——下一节把纪律本身讲透。
工具定下来了。下一节不谈库,谈纪律:无论用什么方案,数据该沿什么方向流、模型该长成什么样。