商品列表点了"加入购物车",详情页的角标变了,结算页却还显示旧数量。把三个页面各放一个
count,接下来就得追着页面同步;结算价再走一次接口,问题会更明显:数量和报价到底依赖哪份数据?
一句话结论:Riverpod 用 provider 保存共享状态、用 ref.watch 连起依赖,购物车变化会推动报价重新计算,而 AsyncValue 让结算页明确处理等待、结果和失败。
本文以 flutter_riverpod: 3.4.3 为准,使用手写 provider,不依赖代码生成。示例只演示本地购物车数量和模拟异步报价;真实项目仍需接入商品 ID、服务端报价、库存校验与提交订单流程。前一章的选型地图见系列第一章。
在 pubspec.yaml 中固定版本:
yaml
dependencies:
flutter:
sdk: flutter
flutter_riverpod: 3.4.3
这个依赖提供 Flutter 的 ProviderScope 与 Consumer Widget;下面的示例不使用生成器。
1. 先看这张依赖图
text
列表 / 详情 / 结算页 ──watch──> cartProvider(购物车数量)
priceProvider(异步报价) ──watch──> cartProvider
结算页 ──watch──> priceProvider ──返回──> AsyncValue
按钮点击 ──read cartProvider.notifier──> add()
服务端价格变更通知 ──invalidate priceProvider──> 重新询价
ProviderScope 持有 provider 的状态容器。顶层声明的 provider 是定义,不是一个随页面复制的全局可变值;同一个 ProviderScope 下,三个页面订阅同一份购物车状态。实际路由切换时,只要这些页面仍在该作用域中,读到的就是同一份状态。
这里的"依赖"有两个方向:界面观察状态,报价观察购物车。priceProvider 的函数里写下 ref.watch(cartProvider),数量变化就会让旧报价失效并重新求值,不必从列表页逐个通知结算页。
2. watch 和 read 各放在哪里?
NotifierProvider 负责可修改的购物车数量,Notifier.build() 给出初始值,业务方法负责更新 state。Widget 使用 ref.watch(cartProvider) 订阅数量;按钮回调使用 ref.read(cartProvider.notifier).add() 发起修改。不要用 read 代替界面中的 watch 来"省重建",否则界面失去这条订阅关系。
下面是一段可改造的最小示例。它把三个页面的位置压成同一屏的三行,实际应用可把 CartCount 分别放进列表、详情和结算路由。延迟 300 毫秒和单价 19.9 元只为模拟异步过程,不是服务端实时报价。
dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final cartProvider = NotifierProvider<Cart, int>(Cart.new);
class Cart extends Notifier<int> {
@override
int build() => 0;
void add() => state = state + 1;
}
final priceProvider = FutureProvider.autoDispose<double>((ref) async {
final count = ref.watch(cartProvider);
await Future<void>.delayed(const Duration(milliseconds: 300));
return count * 19.9; // 演示数据:实际项目改为请求服务端报价
});
void main() => runApp(
const ProviderScope(child: MaterialApp(home: CartDemo())),
);
class CartDemo extends ConsumerWidget {
const CartDemo({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) => Scaffold(
appBar: AppBar(title: const Text('购物车演示')),
body: Column(
children: [
for (final place in ['商品列表', '商品详情', '结算页'])
CartCount(place: place),
ElevatedButton(
onPressed: () => ref.read(cartProvider.notifier).add(),
child: const Text('加入购物车'),
),
const CheckoutPrice(),
],
),
);
}
class CartCount extends ConsumerWidget {
const CartCount({super.key, required this.place});
final String place;
@override
Widget build(BuildContext context, WidgetRef ref) =>
Text('$place:${ref.watch(cartProvider)} 件');
}
class CheckoutPrice extends ConsumerWidget {
const CheckoutPrice({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final quote = ref.watch(priceProvider);
return Column(
children: [
quote.when(
data: (total) => Text('参考价:¥${total.toStringAsFixed(2)}'),
loading: () => const Text('询价中...'),
error: (error, stackTrace) => Text('询价失败:$error'),
),
TextButton(
onPressed: () => ref.invalidate(priceProvider),
child: const Text('重新询价'),
),
],
);
}
}
这段代码没有 HTTP、路由、持久化和错误文案映射,也没有处理报价与下单之间的价格变动。接入接口时,让服务端根据购物车明细返回报价;客户端的 count × 19.9 只能帮助看清状态流,不能作为结算依据。
3. 数量改变后,报价发生了什么?
初次构建时,CheckoutPrice 观察 priceProvider,得到的是 AsyncValue<double>。它可能是加载中、已有数据或错误,when 分别渲染这三种情况。点击按钮后,Cart.add() 更新数量;三个 CartCount 收到变化,依赖购物车的 priceProvider 也重新执行。旧报价对应的那次计算不再是当前状态。
ref.invalidate(priceProvider) 解决的是另一种变化:购物车数量没动,但服务端价格可能已经改了。失效会丢弃当前 provider 状态;若结算页仍在监听,就重新计算,若无人监听,就保持销毁,等下次读取再创建。重新加载期间,AsyncValue 可能仍带着旧数据或错误,具体展示策略应由结算页决定;不应把旧报价当成可提交订单的最终价格。
Riverpod 3 默认对失败的 provider 自动重试。因此接入真实报价接口后,错误出现的时机和请求次数还会受重试策略影响。支付前要求一次确定结果的接口,应明确配置重试与超时策略,并让服务端在下单时再次确认价格。
4. 页面离开后,状态何时释放?
示例中的手写 cartProvider 没开启自动释放,适合让多个页面持续共享购物车。priceProvider 使用 .autoDispose:没有监听者后,Riverpod 会等待一帧;仍无人监听时释放它。再次进入结算页会重新求价。对于报价这种容易过期的数据,我倾向先用自动释放,再按业务允许的缓存时间调整。
有两条边界容易混淆。第一,依赖重算也会销毁旧状态 ,即使配置了 keepAlive,旧报价也不会因为"保活"而绕过购物车变化。第二,代码生成的 provider 默认自动释放,可用 @Riverpod(keepAlive: true) 关闭;本文的手写 provider 则显式写 .autoDispose。需要在一次成功请求后临时保留结果时,可以在自动释放的 provider 内调用 ref.keepAlive(),但这也不能代替报价有效期和服务端校验。
如果报价请求支持取消,把取消动作注册到 ref.onDispose。Riverpod 3 在 provider 销毁后继续使用它的 ref 会抛出异常;跨 await 后还要访问 ref,先检查 ref.mounted。本文的模拟请求在 await 后只使用之前捕获的 count,因此没有再次读取 ref。
5. 这套写法适合放在哪里?
列表、详情、结算共同依赖购物车时,让 Notifier 作为修改入口,页面只订阅自己需要的值,比在路由之间传三份数量更容易查清更新路径。异步报价由单独的 FutureProvider 承担,出错时也不会把购物车数量一起抹掉。输入框焦点、当前展开的卡片之类只影响单页的状态,仍可留在 Widget 内。
迁移旧示例时要留意版本:Riverpod 3 将 StateProvider、StateNotifierProvider 和 ChangeNotifierProvider 归入 legacy.dart,本文使用当前主 API 中的 NotifierProvider。这一章最值得记住的路径是:按钮调用修改方法,购物车状态更新,依赖它的报价重算,结算页从 AsyncValue 决定显示什么。