Flutter 状态管理框架对比(二):Provider 怎样管住一份购物车

商品列表点了"加入购物车",详情页的角标却没变;到了结算页,价格请求还没回来,数量又加了一件。这时得查清楚:购物车由谁持有,界面如何找到它,旧请求回来时该怎么办?

一句话结论:用 Provider 把同一份购物车放在三个页面共同的祖先节点,ChangeNotifier 负责发出变化,Widget 按自己真正需要的字段订阅,并让价格请求的结果与发起时的购物车保持一致。

本文以 provider: 6.1.5+1 为准(2026-09-29 查询 pub.dev 版本 API)。重点是它与 ChangeNotifier 的常见搭配:作用域、读取与局部重建、对象销毁、异步结果。例子用内存里的假价格仓库说明状态流转,不包含真实接口、路由体系和订单提交。

1. 先摆好对象:Provider 和 ChangeNotifier 各管哪一段?

沿用第一章的购物车:列表、详情、结算三个页面显示同一个数量;结算页再请求一次总价。关系可以先压成下面这条路径:

text 复制代码
用户点击 → CartModel.add() → 修改数量并 notifyListeners()
                               ↓
ChangeNotifierProvider 持有 CartModel → 依赖它的 Widget 重建
                               ↓
结算页请求价格 → loading / success / failure → 再次通知界面

CartModel 是状态持有者,决定何时修改数据和通知监听者。ChangeNotifierProvider 创建、向下提供并在退出作用域时释放这个对象;它不替业务判断价格是否过期。BuildContext 负责在当前 Widget 的祖先链上寻找 provider。因此,三个页面如果要共享一份购物车,provider 必须放在它们共同的祖先处。本例放在 MaterialApp 外层,导航进入详情或结算页时仍能读到同一个对象。

这也是 Provider 和 ChangeNotifier 最容易混淆的地方:前者负责把对象放进 Widget 树并管理依赖,后者只是可监听的状态对象。Provider 也能提供普通对象;购物车用 ChangeNotifier,是因为这里确实需要通知界面。

2. 四种读取方式,分别会让谁重建?

在 build() 中读同一个 CartModel,写法不同,订阅范围也不同。

写法 适合的位置 变化时会怎样
context.watch<CartModel>() 构建依赖整个模型的界面 模型通知后,当前 Widget 的 build() 重新执行
context.select<CartModel, int>((cart) => cart.count) 只显示数量的角标 选出的数量变化时,当前 Widget 才重建
Consumer<CartModel>(builder: ...) 把监听收进一小块子树 builder 所在区域响应通知;可用 child 留住不依赖状态的部分
context.read<CartModel>() 按钮回调等一次性操作 获取对象,不建立监听关系

例如列表页的购物车角标只要 count,就用 select。详情页可以把数量文字包进 Consumer,让旁边的商品说明留在监听范围之外。结算页同时关心数量、价格和请求状态,用 watch 读整个模型更直观。这里说的"局部重建"是 Widget 的构建范围;它不等于每次通知都只改动屏幕上的几个像素,也不保证所有构建都没有成本。

有个常见反例:在 build() 里用 read 取 count,页面第一次看起来正常,之后数量却不更新。read 不会订阅。反过来,按钮回调只为调用 add(),用 read 就够了;不要在 initState() 里用 watch 建立依赖。

3. 最小例子:数量共享,价格按当前数量请求

先在 pubspec.yaml 中加入 provider: 6.1.5+1。下面的代码面向支持 Dart 3 switch 表达式的 Flutter 项目,可放在 lib/main.dart 中作教学演示;价格仓库只延迟返回 数量 × 1999 分,不发网络请求,也不会主动抛错。代码未在本文环境中运行。接入真实接口时要替换仓库,并按服务端规则处理金额、库存、用户可见的错误提示和取消请求。

dart 复制代码
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

enum PriceStatus { idle, loading, success, failure }

abstract class PriceRepository {
  Future<int> fetchTotalCents(int count);
}

class FakePriceRepository implements PriceRepository {
  @override
  Future<int> fetchTotalCents(int count) async {
    await Future<void>.delayed(const Duration(milliseconds: 500));
    return count * 1999;
  }
}

class CartModel extends ChangeNotifier {
  CartModel(this._repository);

  final PriceRepository _repository;
  int count = 0;
  int? totalCents;
  PriceStatus priceStatus = PriceStatus.idle;
  String? priceError;
  int _requestId = 0;
  bool _disposed = false;

  void add() {
    count++;
    _requestId++; // 购物车变了,旧价格请求的结果失效。
    totalCents = null;
    priceStatus = PriceStatus.idle;
    priceError = null;
    notifyListeners();
  }

  Future<void> loadPrice() async {
    final requestId = ++_requestId;
    final countAtStart = count;
    priceStatus = PriceStatus.loading;
    priceError = null;
    notifyListeners();

    try {
      final cents = await _repository.fetchTotalCents(countAtStart);
      if (_disposed || requestId != _requestId) return;
      totalCents = cents;
      priceStatus = PriceStatus.success;
    } catch (error) {
      if (_disposed || requestId != _requestId) return;
      totalCents = null;
      priceError = error.toString();
      priceStatus = PriceStatus.failure;
    }
    notifyListeners();
  }

  @override
  void dispose() {
    _disposed = true;
    _requestId++;
    super.dispose();
  }
}

void main() {
  runApp(
    ChangeNotifierProvider(
      create: (_) => CartModel(FakePriceRepository()),
      child: const MaterialApp(home: ProductListPage()),
    ),
  );
}

class ProductListPage extends StatelessWidget {
  const ProductListPage({super.key});

  @override
  Widget build(BuildContext context) {
    final count = context.select<CartModel, int>((cart) => cart.count);
    return Scaffold(
      appBar: AppBar(title: Text('商品列表 · 购物车 $count')),
      body: Column(
        children: [
          ElevatedButton(
            onPressed: () => context.read<CartModel>().add(),
            child: const Text('加入购物车'),
          ),
          ElevatedButton(
            onPressed: () => Navigator.push(
              context,
              MaterialPageRoute<void>(builder: (_) => const ProductDetailPage()),
            ),
            child: const Text('商品详情'),
          ),
          ElevatedButton(
            onPressed: () => Navigator.push(
              context,
              MaterialPageRoute<void>(builder: (_) => const CheckoutPage()),
            ),
            child: const Text('去结算'),
          ),
        ],
      ),
    );
  }
}

class ProductDetailPage extends StatelessWidget {
  const ProductDetailPage({super.key});

  @override
  Widget build(BuildContext context) => Scaffold(
        appBar: AppBar(title: const Text('商品详情')),
        body: Column(
          children: [
            Consumer<CartModel>(
              builder: (_, cart, __) => Text('购物车数量:${cart.count}'),
            ),
            ElevatedButton(
              onPressed: () => context.read<CartModel>().add(),
              child: const Text('加入购物车'),
            ),
          ],
        ),
      );
}

class CheckoutPage extends StatelessWidget {
  const CheckoutPage({super.key});

  @override
  Widget build(BuildContext context) {
    final cart = context.watch<CartModel>();
    final priceText = switch (cart.priceStatus) {
      PriceStatus.idle => '价格待查询',
      PriceStatus.loading => '正在查询价格...',
      PriceStatus.success => '总价:${cart.totalCents} 分',
      PriceStatus.failure => '查询失败:${cart.priceError}',
    };
    return Scaffold(
      appBar: AppBar(title: const Text('结算')),
      body: Column(
        children: [
          Text('购物车数量:${cart.count}'),
          Text(priceText),
          ElevatedButton(
            onPressed: cart.priceStatus == PriceStatus.loading
                ? null
                : () => context.read<CartModel>().loadPrice(),
            child: const Text('查询最新价格'),
          ),
        ],
      ),
    );
  }
}

操作从按钮回调进入 CartModel;模型修改字段后调用 notifyListeners(),依赖它的 Widget 收到变化。结算页发起价格请求时先显示 loading,成功或失败后再通知一次。金额用整数"分"表示,是为了避开示例中的浮点金额歧义;真实结算金额仍以服务端返回为准。

这里多出的 _requestId 有实际用途:如果发出价格请求后又加了一件商品,add() 会让旧请求失效。旧请求即使随后返回,也不能把旧数量的总价写到新购物车上。dispose() 后仍可能有尚未完成的 Future,_disposed 检查保证不会再调用 notifyListeners()。这只是忽略过期结果,并没有真正取消底层请求。假仓库永远成功;要观察失败界面,可以在演示时让 fetchTotalCents() 抛出异常。

4. 作用域和生命周期,通常在哪里踩坑?

本例的 ChangeNotifierProvider(create: ...) 拥有新建的 CartModel,它从 Widget 树移除时会调用模型的 dispose()。页面之间 Navigator.push()、pop() 不会移除这个位于 MaterialApp 外层的 provider,所以回到列表仍是同一份购物车。若只让结算流程共享状态,可以把 provider 下移到该流程共同的祖先,让对象随流程结束而释放。

create 用于创建并交给 provider 管理的新对象。如果手里已有一个仍由别处管理的实例,应按 Provider 文档 使用 ChangeNotifierProvider.value(value: existing);随意把已有实例放进 create,可能在 provider 移除时被意外销毁。反过来,用 .value 创建新对象,也会让所有权不清楚。

找不到 provider 时,先检查调用处的 BuildContext:它是否真的位于 provider 的子树里?最典型的错误是刚创建 provider,就用它上方的 context 去 read;此时应在 provider 的子树内构建消费者,或把 provider 提到更高处。ProviderNotFoundException 往往指向作用域,而不是 notifyListeners() 失灵。

另一个陷阱是忘记通知:字段变了但没有调用 notifyListeners(),订阅者就不知道变化。也别把每一个输入框焦点、按钮展开状态都塞进共享的购物车模型。只在当前页面使用的状态留在本地,购物车模型才能保持清楚的职责。

5. 什么时候该缩小监听范围?

先看一次通知会影响谁,再决定是否优化。假如结算页显示数量、价格和错误,watch 整个模型是合理的;列表页只需要数量,用 select 更贴近实际依赖。Consumer 适合把监听收进页面的一小块区域。不要为追求"零重建"把每行文字都拆成独立组件:先用 Flutter DevTools 观察重建和耗时,再处理真正的热点。

到这里,Provider 的边界也很清楚了:它让对象在 Widget 树中可取得、可监听、可释放;异步结果属于哪一版购物车、失败后怎样重试、价格以谁为准,仍要由业务模型和服务端规则回答。下一章换 Riverpod 时,我们会继续使用这份购物车,重点看依赖定义和异步状态怎样改变。

参考资料

相关推荐
GitCode官方2 小时前
开源鸿蒙跨平台直播|Flutter HCPP 开源鸿蒙实践:让原生视图回到系统合成层
flutter·华为·harmonyos
光影少年3 小时前
RN与Flutter架构区别
前端·flutter·react native·react.js·架构·node.js
m0_7381858217 小时前
Flutter 鸿蒙化实战:flutter_app_minimizer_plus 适配 OpenHarmony,一键最小化应用
flutter·华为·harmonyos·鸿蒙
动物园猫18 小时前
Flutter 鸿蒙实战:用 webview_flutter 三方库在 鸿蒙 中内嵌真实网页
flutter·华为·harmonyos
m0_7381858220 小时前
Flutter 鸿蒙化实战:flutter_app_badger 适配 OpenHarmony,应用角标
flutter·华为·harmonyos·鸿蒙
yuezhilangniao20 小时前
FVM 与 Flutter 环境配置完全指南:从安装到日常使用
flutter
gnip1 天前
Flutter GetX 三件套开发规范(Skill)
前端·flutter
m0_738185821 天前
Flutter 鸿蒙化实战:flutter_blue_plus 适配 OpenHarmony,蓝牙扫描连接开箱即用
flutter·华为·harmonyos·鸿蒙
传奇开心果编程1 天前
【Flutter入门练中学】第3课:滚动与列表
android·学习·flutter·ui·ios