卷 IV · 状态CH 16深度 16/24

Provider / Riverpod / Bloc:给你的小 App 选一个

直觉过境 「状态管理选哪个都差不多,先跑起来再说。」 改一下

小 App 里它们确实差不多——但选错的代价不在第一天,在三个月后。好消息是 2026 年这个问题的答案已经收敛了,不用再看十篇「五大方案对比」纠结。这一章给你一个明确推荐,用引擎让你看清它们「重建了谁」的差别,最后落到你那个学校 App 该抄哪套。

重建范围对比Riverpod 3选型决策ViewModel 对照

先说结论,省得你翻到最后

◆ 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 的新东西(如果你看过旧版教程)

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 开发。

⇄ Compose 对照 · 你其实已经会了

你在 Android 里用的架构,几乎能一一映射过来:

Android / ComposeFlutter
ViewModel + StateFlowRiverpod 的 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 的分层骨架。