商品列表点了"加入购物车",详情页的角标却没变;到了结算页,价格请求还没回来,数量又加了一件。这时得查清楚:购物车由谁持有,界面如何找到它,旧请求回来时该怎么办?
一句话结论:用 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 时,我们会继续使用这份购物车,重点看依赖定义和异步状态怎样改变。