Flutter 状态管理框架对比(三):Riverpod 如何连起购物车与异步价格

商品列表点了"加入购物车",详情页的角标变了,结算页却还显示旧数量。把三个页面各放一个 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 决定显示什么。

参考资料

相关推荐
蓝宝石的傻话1 小时前
onvif-go(mickeyzzc/onvif-go v2)完整参考手册:从建工程到写出自己的 NVR 接入
开发语言·后端·golang
Elastic 中国社区官方博客1 小时前
使用 Lucene 搜索你的 Bean —— Elasticsearch
大数据·开发语言·人工智能·elasticsearch·搜索引擎·全文检索·lucene
Sayai2 小时前
Neo4j 内嵌模式(Embedded)实战:Java 嵌入式 vs 服务端部署的写入性能对比与 GC 调优
java·开发语言·性能优化·neo4j·图数据库
lyz2468592 小时前
vue3页面因参数未声明而报错的常犯错误
前端·javascript·vue.js
Hilaku2 小时前
我重写了整个项目,但没人感谢我!
前端·javascript·程序员
“AI国潮设计-小江”2 小时前
【SDXL实战】用AI生成“财神爷蛋糕×英歌舞人物”潮汕国潮甜品IP,附Prompt与批量生成思路
开发语言·人工智能·python·aigc
繁华的地方不一定留下你的脚印2 小时前
C++线程如何安全停止与唤醒:std::jthread、stop_token与condition_variable
开发语言·c++
caoerzhong2 小时前
跨境海外仓怎么管:JeeWMS 开源 Java 仓库管理系统打通头程、海外仓与尾程
java·开发语言·开源
码云数智-大飞2 小时前
新手写 Python 代码,如何规范命名、减少 Bug
开发语言·python·php