Provider / Riverpod / Bloc:给你的小 App 选一个
小 App 里它们确实差不多——但选错的代价不在第一天,在三个月后。好消息是 2026 年这个问题的答案已经收敛了,不用再看十篇「五大方案对比」纠结。这一章给你一个明确推荐,用引擎让你看清它们「重建了谁」的差别,最后落到你那个学校 App 该抄哪套。
先说结论,省得你翻到最后
新项目、默认答案:Riverpod 3.x(配代码生成)。编译期类型安全、不依赖 BuildContext、测试极简、精确到字段的订阅。你的学校 App 选它,不会错。
Provider:只在两种情况用——你接手的老项目已经在用它,或者你想通过它理解底层的 InheritedWidget(第 15 章)。它没被废弃,但新项目没有理由从它起步。(Riverpod 的作者就是 Provider 的作者,Riverpod 是他对 Provider 缺陷的重写。)
Bloc:团队大、要严格的事件流、要可回放的状态历史(金融、复杂表单流程)时才值得那套仪式感。学校小 App 用它是杀鸡用牛刀。
纯 setState / ValueNotifier:单个页面内部的局部状态,永远够用,别为它引库。
下面解释「为什么」,以及它们在机制上到底差在哪。
亲手跑:四种写法各重建了谁
同一个购物车页面,加一件商品。看四种写法各自触发了多少 Widget 重建——这是它们最本质的差别。
数字很说明问题:setState 让整页 25 个 Widget 重建;后三者都只重建了 2 个——真正依赖购物车数据的那两块(顶栏的件数、按钮的总价)。为什么后三者能做到?因为它们本质上都是第 15 章那台机器:只通知登记过依赖的订阅者。
三个库,逐个看
Provider:InheritedWidget 的语法糖
第 15 章说过它就是 InheritedWidget + 封装。用起来是「提供 + 消费」两步:
// 提供(放在树的高处)
ChangeNotifierProvider(
create: (_) => CartModel(),
child: const MyApp(),
)
// 消费,只重建 Consumer 包住的这一小块
Consumer<CartModel>(
builder: (_, cart, __) => Text('${cart.count} 件'),
)
// 只读、不订阅(在回调里用)
context.read<CartModel>().add(item);
watch(订阅、会重建)和 read(只读、不重建)的区别,就是第 15 章「登记依赖」与否。它的两大痛点:一是运行时才报错(拿错类型、忘了提供,编译能过、跑起来崩),二是一切都要 context(测试、后台逻辑里没有 context 就难受)。Riverpod 就是来修这两点的。
Riverpod 3:把 Provider 的坑逐个填平
Riverpod 3.0(2025 年 9 月发布,2026 年在 3.x 稳定线)是当前的推荐。配合代码生成,长这样:
// 1. 定义(在树外,是个全局变量,但不是全局可变状态)
@riverpod
class Cart extends _$Cart {
@override
List<Item> build() => []; // 初始状态
void add(Item i) => state = [...state, i]; // 改状态 = 给 state 赋新值
}
// 2. 在 App 顶层包一个 ProviderScope(它就是个 InheritedWidget)
runApp(const ProviderScope(child: MyApp()));
// 3. 消费:Widget 混入 ConsumerWidget,用 ref
class CartBadge extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(cartProvider.select((c) => c.length)); // 精确到字段
return Text('$count 件');
}
}
它赢在四点,每一点都对应 Provider 的一个痛:
| Riverpod 的优势 | 解决了 Provider 的什么 |
|---|---|
| 编译期类型安全 | 拿错 provider 直接编译不过,不再运行时崩 |
| 不依赖 BuildContext | 测试里直接 container.read(...),后台逻辑也能读 |
.select() 精确订阅 | 只在你关心的那个字段变时重建,比 Provider 更细 |
内置异步(AsyncValue)、自动缓存/失效 | 加载/成功/失败三态开箱即用(第 3 章的 switch) |
Riverpod 3 相比 2.x 有几处值得知道:统一了 Ref(不再有一堆 WidgetRef/ProviderRef 变体)、失败时自动重试(网络抖动不再直接把界面打成错误态)、Widget 滚出屏幕时自动暂停对应的监听、还有实验性的离线持久化。另外老的 StateNotifier/StateNotifierProvider 已弃用,换成 Notifier/AsyncNotifier——照着上面 @riverpod 那个写法走就是新的。
Bloc:把状态变成「事件 → 状态」的流
Bloc 强制你把每个交互建模成一个事件,把界面建模成一个状态,中间是纯函数式的转换:
// 事件
sealed class CartEvent {}
class ItemAdded extends CartEvent { final Item item; ItemAdded(this.item); }
// Bloc:事件进,状态出
class CartBloc extends Bloc<CartEvent, CartState> {
CartBloc() : super(const CartState.empty()) {
on<ItemAdded>((event, emit) => emit(state.copyWith(...)));
}
}
// 消费
BlocBuilder<CartBloc, CartState>(
builder: (context, state) => Text('${state.items.length} 件'),
)
它的价值是可追溯:每一次状态变化都由一个明确的事件驱动,能记录、能回放、能测。代价是样板多、心智负担重——一个简单交互也要定义事件类、状态类、处理器。对学校小 App,这套仪式感的收益你享受不到,成本却要天天付。
那到底怎么选:一张决策图
| 你的情况 | 选 |
|---|---|
| 状态只在一个页面内部(表单、开关、动画) | setState / ValueNotifier,不引库 |
| 新的中小 App,要跨页共享数据(登录态、购物车、课表缓存) | Riverpod 3.x |
| 接手的老项目已经在用 Provider | 沿用 Provider,别为重构而重构 |
| 大团队、复杂业务流、要严格的状态审计/回放 | Bloc |
| 只想学清楚底层原理 | 手写 InheritedWidget(第 15 章),然后用 Riverpod |
1. 小 App 一上来套 Bloc。因为「大厂都用」「显得专业」。结果一个三页的 App 写了几十个事件类和状态类,改个需求要动五个文件。架构要匹配规模,不是越重越好。
2. 到处 setState,从不分层。另一个极端——所有状态塞进各个页面的 State,业务逻辑和 UI 搅在一起,没法测、没法复用。只要状态需要跨页面或需要单独测,就该把它从 Widget 里拎出来(下一章讲怎么放)。
watch / read / listen:三个动词别用错
不管 Provider 还是 Riverpod,消费状态都有三个动作,用错是新手最常见的 bug:
| 动作 | 作用 | 用在哪 | 用错的后果 |
|---|---|---|---|
watch | 读值 + 订阅,值变就重建 | build 方法里 | 在回调里 watch → 莫名其妙的重建 |
read | 读一次值,不订阅 | 事件回调里(onTap 等) | 在 build 里 read → 值变了界面不更新 |
listen | 值变时执行副作用(弹窗、导航) | build 里,但不返回 UI | 用 watch 代替 → 副作用触发不对 |
一句话记:build 里要显示的值用 watch,回调里改数据前拿的引用用 read,「登录成功后跳首页」这种副作用用 listen。Riverpod 的 lint 插件会在你用错时直接标黄,装上它能省很多事。
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(cartProvider).length; // 显示 → watch
ref.listen(authProvider, (prev, next) { // 副作用 → listen
if (next.isLoggedIn) context.go('/');
});
return FilledButton(
onPressed: () => ref.read(cartProvider.notifier).add(item), // 回调 → read
child: Text('加入购物车($count)'),
);
}
provider 之间可以互相依赖——这就是免费的 DI
第 15 章说 Riverpod 底层是 InheritedWidget,但它比 Provider 强的一点是:provider 能 watch 另一个 provider,形成一张依赖图。这正好替代了你在 Android 用 Hilt 做的事:
@riverpod
Dio dio(Ref ref) => Dio(BaseOptions(baseUrl: '...'));
@riverpod
CourseApi courseApi(Ref ref) => CourseApi(ref.watch(dioProvider)); // 依赖 dio
@riverpod
CourseRepository courseRepo(Ref ref) =>
CourseRepository(ref.watch(courseApiProvider)); // 依赖 api
@riverpod
Future<List<Course>> courseList(Ref ref) =>
ref.watch(courseRepoProvider).getCourses(); // 依赖 repo
dio → api → repository → 数据 provider → UI,整条依赖链自动装配,测试时替换任意一环(overrideWith 塞个假的)都不影响别的。你不用再单独学一个 DI 框架——这是 Riverpod 相对 Provider 一个常被低估的优势,尤其对写惯了 Hilt 的 Android 开发。
你在 Android 里用的架构,几乎能一一映射过来:
| Android / Compose | Flutter |
|---|---|
ViewModel + StateFlow | Riverpod 的 Notifier + state |
collectAsStateWithLifecycle() | ref.watch(provider) |
viewModel.onEvent(...) | ref.read(provider.notifier).method() |
| Hilt 依赖注入 | Riverpod 本身就是 DI(provider 之间可互相依赖) |
| MVI(事件 → 状态) | Bloc |
Riverpod 的 Notifier 就是你的 ViewModel——持有状态、暴露方法、和 UI 解耦、能脱离 UI 测试。它甚至比 Android 的 ViewModel 更进一步:Riverpod 本身就是一套依赖注入,provider 可以 ref.watch 另一个 provider,等于免费拿到了 Hilt 那一层。你「ViewModel 持有状态、UI 只负责渲染和转发事件」的整套习惯,直接搬,一行架构图都不用改。
这一章的一句话
2026 年这个问题有明确答案:新的小 App 默认 Riverpod 3.x——它就是你熟的 ViewModel + StateFlow,还顺带给了依赖注入和三态异步;Provider 是它的前身(老项目才用),Bloc 是给大团队的重型方案,而它们在机制上都是第 15 章那台「只通知订阅者」的 InheritedWidget。
选好了工具,还剩一个更难的问题——它比「用哪个库」重要得多:一个状态,到底该放在哪一层?放页面里、提升到父级、还是抽进 Repository?下一章把这件事讲清楚,顺便给你一份能直接抄进学校 App 的分层骨架。