Flutter 基础体系 · 第 52/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。
Flutter Provider:ChangeNotifier、依赖范围、重建和测试
Provider 是 Flutter 生态中常用的依赖注入与状态访问工具。它本身不定义状态模型,也不会把任意对象自动变成“响应式状态”;通常的组合是:
ChangeNotifier:保存可变状态,并在状态变化后通知监听者;ChangeNotifierProvider:把对象放入 Widget 树,负责提供依赖和管理生命周期;context.watch、context.read、context.select、Consumer、Selector:声明当前 Widget 如何读取状态,以及读取后是否需要在通知到来时重建。
理解这套组合,关键不在于记住几个 API,而在于分清四件事:
- 状态对象在哪里创建、由谁销毁;
- 哪些 Widget 依赖这个对象;
notifyListeners()触发哪些 Widget 重建;- 测试时如何替换依赖、验证状态变化和 Widget 响应。
一、先建立运行模型:状态、依赖和重建不是同一个概念
1. ChangeNotifier 是通知机制,不是完整的状态管理方案
ChangeNotifier 维护一组监听器。当状态发生变化时,调用 notifyListeners():
import 'package:flutter/foundation.dart';
class CounterModel extends ChangeNotifier {
int _value = 0;
int get value => _value;
void increment() {
_value++;
notifyListeners();
}
}
这里有三个独立动作:
_value++修改内存中的状态;notifyListeners()向已注册的监听者发送同步通知;- 监听者根据自己的依赖关系决定是否调用
build。
因此,notifyListeners() 不会自动重建整个应用,也不会自动重建所有使用了相同类型的 Widget。它只通知当前注册的监听者;Provider 再把这些监听关系与 Widget 生命周期连接起来。
如果忘记调用通知:
void incrementWithoutNotification() {
_value++;
}
CounterModel.value 确实已经变成新值,但依赖它的 Widget 不会因为这次修改而自动重建。下次由于其他原因发生重建时,Widget 可能突然显示出新值,这会造成“状态偶尔更新”的错觉。
反过来,如果状态没有变化却频繁通知:
void setValue(int next) {
_value = next;
notifyListeners();
}
那么监听者仍可能重建。ChangeNotifier 默认不会比较新旧状态,也不会判断这次通知是否真的影响了某个监听者。
2. Widget 重建不等于底层 RenderObject 全部重建
Flutter 中,build 是根据当前配置生成 Widget 子树的过程。父 Widget 重建时,Flutter 会根据新旧 Widget 的类型和键等信息进行子树匹配;这不等于所有底层元素、RenderObject 和平台资源都被销毁重建。
Provider 相关的“重建”通常指:
ChangeNotifier.notifyListeners()
↓
Provider 识别监听了该对象的依赖
↓
相关 Element 被标记为需要构建
↓
下一帧或当前调度流程中再次执行 build
这意味着:
- 监听状态的 Widget 可能重建;
- 没有监听状态的 Widget 不应因该通知而重建;
- 重建本身不代表状态对象被重新创建;
build应保持可重复执行,不能依赖“只运行一次”。
3. Provider 的依赖查找基于 Widget 树
Provider 把依赖对象放入 Widget 树。下面的结构表示 CounterPage 可以找到 CounterModel:
MaterialApp
└── ChangeNotifierProvider<CounterModel>
└── CounterPage
└── Text / Button
查找时,Provider 从当前 Widget 的上下文向祖先方向寻找匹配的 Provider。通常先找到距离当前上下文最近的同类型 Provider。
这带来一个形式化的依赖范围:
设 Widget 为 w,Widget 树中从 w 向上的祖先序列为:
其中 a₁ 是最近的祖先。若某个祖先提供了类型为 T 的对象,则:
其中 k 是满足“aₖ 提供 T”的最小下标。
所以,Provider 的作用范围由“提供者节点的位置”决定,而不是由类名或文件位置决定。
二、一个可运行的完整示例
下面的示例使用 provider 包。项目需要在 pubspec.yaml 中加入与当前稳定版本兼容的 provider 依赖,然后执行 flutter pub get。
示例实现一个商品数量模型:
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
void main() {
runApp(
ChangeNotifierProvider(
create: (_) => CartModel(),
child: const ShoppingApp(),
),
);
}
class CartModel extends ChangeNotifier {
final Map<String, int> _items = <String, int>{};
int quantityOf(String productId) => _items[productId] ?? 0;
int get totalQuantity {
return _items.values.fold(0, (sum, quantity) => sum + quantity);
}
void add(String productId) {
final current = quantityOf(productId);
_items[productId] = current + 1;
notifyListeners();
}
void remove(String productId) {
final current = quantityOf(productId);
if (current == 0) {
return;
}
if (current == 1) {
_items.remove(productId);
} else {
_items[productId] = current - 1;
}
notifyListeners();
}
}
class ShoppingApp extends StatelessWidget {
const ShoppingApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: const ProductPage(),
routes: <String, WidgetBuilder>{
'/cart': (_) => const CartPage(),
},
);
}
}
class ProductPage extends StatelessWidget {
const ProductPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('商品'),
actions: const <Widget>[
CartButton(),
],
),
body: ListView(
children: const <Widget>[
ProductTile(productId: 'book', title: '书籍'),
ProductTile(productId: 'keyboard', title: '键盘'),
],
),
);
}
}
class ProductTile extends StatelessWidget {
const ProductTile({
required this.productId,
required this.title,
super.key,
});
final String productId;
final String title;
@override
Widget build(BuildContext context) {
final quantity = context.select<CartModel, int>(
(cart) => cart.quantityOf(productId),
);
return ListTile(
title: Text(title),
subtitle: Text('数量:$quantity'),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
IconButton(
onPressed: quantity == 0
? null
: () => context.read<CartModel>().remove(productId),
icon: const Icon(Icons.remove),
),
IconButton(
onPressed: () => context.read<CartModel>().add(productId),
icon: const Icon(Icons.add),
),
],
),
);
}
}
class CartButton extends StatelessWidget {
const CartButton({super.key});
@override
Widget build(BuildContext context) {
final total = context.select<CartModel, int>(
(cart) => cart.totalQuantity,
);
return IconButton(
tooltip: '购物车',
onPressed: () => Navigator.of(context).pushNamed('/cart'),
icon: Badge(
isLabelVisible: total > 0,
label: Text('$total'),
child: const Icon(Icons.shopping_cart),
),
);
}
}
class CartPage extends StatelessWidget {
const CartPage({super.key});
@override
Widget build(BuildContext context) {
final total = context.watch<CartModel>().totalQuantity;
return Scaffold(
appBar: AppBar(title: const Text('购物车')),
body: Center(
child: Text('总数量:$total'),
),
);
}
}
这个例子中:
main创建一个CartModel,并把它放在ShoppingApp之上;ProductPage、ProductTile、CartButton和CartPage都能查找到它;ProductTile只选择某个商品的数量;CartButton只选择总数量;CartPage通过watch读取整个模型的totalQuantity;- 点击按钮时通过
read获取模型并执行命令,但按钮本身不会因为读取命令而订阅状态。
三、create、value 与对象生命周期
1. create 用于 Provider 创建的新对象
最常见的写法是:
ChangeNotifierProvider(
create: (_) => CartModel(),
child: const ShoppingApp(),
)
此时 Provider 通常负责:
- 在需要时创建
CartModel; - 把它提供给后代 Widget;
- 当 Provider 从树中移除时,对该对象执行清理。
ChangeNotifier 没有必须覆写的 dispose 方法,但如果模型持有 StreamSubscription、Timer、TextEditingController 或其他资源,应释放它们:
class SearchModel extends ChangeNotifier {
SearchModel() {
_timer = Timer.periodic(
const Duration(seconds: 1),
(_) => notifyListeners(),
);
}
Timer? _timer;
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
}
如果模型启动了异步任务,也应在 dispose 时让任务结果不再修改已销毁对象。例如:
class ProfileModel extends ChangeNotifier {
bool _disposed = false;
String? _name;
Object? _error;
String? get name => _name;
Object? get error => _error;
Future<void> load() async {
try {
final result = await Future<String>.delayed(
const Duration(milliseconds: 100),
() => 'Ada',
);
if (_disposed) {
return;
}
_name = result;
notifyListeners();
} catch (error) {
if (_disposed) {
return;
}
_error = error;
notifyListeners();
}
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
这段代码只能防止“异步结果回来后继续更新已销毁模型”。它不能取消所有类型的异步操作。对于支持取消的网络客户端,应优先使用取消令牌、请求取消器或订阅取消机制,而不是只依靠 _disposed 标记。
2. value 用于已经存在、由外部管理的对象
如果模型已经在外部创建:
final cart = CartModel();
runApp(
ChangeNotifierProvider.value(
value: cart,
child: const ShoppingApp(),
),
);
value 表示 Provider 接收一个现有实例。典型场景是把已有对象放入一个新的子树,例如列表项、路由或测试环境。
不要把“需要 Provider 创建和销毁的新对象”随意写成:
ChangeNotifierProvider.value(
value: CartModel(),
child: const ShoppingApp(),
)
问题不只是写法风格,而是生命周期语义容易错位:Provider 可能不会按 create 的语义管理这个对象,外部代码也可能同时认为自己拥有它。实际项目中应根据所有权选择:
Provider 创建对象 → create
外部已经拥有对象 → value
3. lazy 影响创建时机,不改变作用域
Provider 默认通常会延迟创建对象,直到后代真正读取它。需要在 Provider 插入树时立即创建时,可以设置:
ChangeNotifierProvider(
lazy: false,
create: (_) => CartModel(),
child: const ShoppingApp(),
)
lazy: false 只改变实例化时机,不改变:
- 对象的类型;
- 依赖可见范围;
- 通知和重建规则;
- 对象最终是否需要释放。
如果模型构造函数会访问必须在运行时才准备好的资源,延迟创建可能有益;如果需要尽早启动初始化,则可以使用 lazy: false,但仍应显式处理初始化失败。
四、watch、read 和 select 的真实区别
1. read:读取一次,不建立重建依赖
final cart = context.read<CartModel>();
cart.add('book');
read 用于执行操作或读取一次性数据。它不会让当前 Widget 因 CartModel.notifyListeners() 而重建。
典型使用位置:
onPressed: () {
context.read<CartModel>().add('book');
}
这里按钮的点击回调需要找到模型,但回调本身不需要因为数量变化而重新构建。
read 不是“性能更快的 watch”。如果 Widget 的 build 方法读取了会显示在界面上的值,却使用 read:
@override
Widget build(BuildContext context) {
final total = context.read<CartModel>().totalQuantity;
return Text('$total');
}
那么点击加号后,当前 Widget 不会因模型通知而更新。它只有在其他原因导致重建时,才可能显示新数量。
2. watch:订阅对象通知
final cart = context.watch<CartModel>();
return Text('${cart.totalQuantity}');
watch 建立依赖:当前 Widget 使用 CartModel。模型调用 notifyListeners() 后,该 Widget 会被标记为需要重建。
如果一个 Widget 读取了模型的大量字段,watch 会让这些字段共享同一个重建边界:
final cart = context.watch<CartModel>();
return Column(
children: <Widget>[
Text('${cart.totalQuantity}'),
Text(cart.quantityOf('book').toString()),
Text(cart.quantityOf('keyboard').toString()),
],
);
其中任何一个商品数量变化,都会使包含这段代码的 Widget 重建。
3. select:订阅投影后的值
select 不订阅整个模型的逻辑含义,而是订阅回调返回的值:
final total = context.select<CartModel, int>(
(cart) => cart.totalQuantity,
);
设模型状态为 S,选择函数为:
其中 P 是被选择的投影值。模型通知后,Provider 重新计算 f(S),并比较新旧投影值。如果选择结果相等,依赖该选择结果的 Widget 通常不需要因为这次变化而重建;如果不相等,则重建。
例如:
final bookQuantity = context.select<CartModel, int>(
(cart) => cart.quantityOf('book'),
);
当 keyboard 数量变化时,bookQuantity 不变,因此这个 ProductTile 没有理由因键盘变化而重建。
选择值最好是稳定、可比较的值:
final title = context.select<UserModel, String>((user) => user.name);
如果每次选择都创建一个没有正确相等语义的新对象,选择优化可能失效:
final pair = context.select<Model, List<int>>(
(model) => <int>[model.left, model.right],
);
这里每次都会产生新的 List。即使两个元素的内容相同,List 默认也主要按对象身份比较。应改为选择基础值、不可变值对象,或使用具备正确值相等语义的类型。
4. Consumer:把重建边界放到指定子树
等价的 Provider 读取也可以写成:
Consumer<CartModel>(
builder: (context, cart, child) {
return Text('${cart.totalQuantity}');
},
)
Consumer 的意义不仅是换一种语法,还能显式缩小重建范围:
Column(
children: <Widget>[
const ExpensiveStaticHeader(),
Consumer<CartModel>(
builder: (context, cart, child) {
return Text('总数量:${cart.totalQuantity}');
},
),
],
)
只有 Consumer 的 builder 子树依赖 CartModel。不过 ExpensiveStaticHeader 是否实际重新执行 build,还取决于它在 Widget 树中的位置、父级重建方式和子 Widget 复用;不能把 Consumer 当成对所有布局成本的绝对隔离。
child 参数用于传入不依赖模型的子树:
Consumer<CartModel>(
child: const ExpensiveStaticHeader(),
builder: (context, cart, child) {
return Column(
children: <Widget>[
child!,
Text('总数量:${cart.totalQuantity}'),
],
);
},
)
Provider 不会因模型通知而重新执行 child 的构造逻辑。这里的优化前提是 child 确实不需要读取当前模型。
5. Selector:同时控制选择和子树边界
Selector<CartModel, int>(
selector: (context, cart) => cart.totalQuantity,
builder: (context, total, child) {
return Text('总数量:$total');
},
)
Selector 是显式版本的“选择投影 + 重建子树”。它适合希望把选择逻辑和构建边界写在一起的场景。
五、依赖范围:同类型覆盖、嵌套和多依赖
1. 最近的 Provider 会覆盖更远的 Provider
MultiProvider(
providers: <SingleChildWidget>[
ChangeNotifierProvider(create: (_) => ThemeSettings()),
],
child: Builder(
builder: (context) {
return ChangeNotifierProvider(
create: (_) => ThemeSettings(),
child: const SettingsPage(),
);
},
),
)
SettingsPage 查找 ThemeSettings 时,会得到内层实例,而不是外层实例。可以把它理解为词法作用域中的遮蔽:
外层 ThemeSettings
└── 内层 ThemeSettings
└── SettingsPage → 内层实例
这在测试替换依赖、路由级状态和多租户界面中有用,但也可能造成误判:两个类名相同的模型并不一定是同一份状态。
2. Provider 只对后代可见
下面的写法会失败:
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (_) => CartModel(),
child: Text(
'${context.watch<CartModel>().totalQuantity}',
),
);
}
context 属于当前 Widget 对应的上下文,而 Provider 是当前 build 返回的子节点。当前上下文在查找祖先时看不到自己刚刚返回的后代 Provider。
可以使用 Builder 获得位于 Provider 下方的新上下文:
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (_) => CartModel(),
child: Builder(
builder: (context) {
return Text(
'${context.watch<CartModel>().totalQuantity}',
);
},
),
);
}
更常见的方式是把读取逻辑放在独立的后代 Widget 中。
3. MultiProvider 只是嵌套 Provider 的书写形式
MultiProvider(
providers: <SingleChildWidget>[
Provider<ApiClient>(create: (_) => ApiClient()),
ChangeNotifierProvider(
create: (context) => UserModel(context.read<ApiClient>()),
),
],
child: const App(),
)
它表达了依赖顺序:
ApiClient
↓
UserModel(ApiClient)
↓
App
UserModel 的 create 回调可以通过自己的上下文读取位于它之前的 ApiClient。如果两个 Provider 之间存在构造依赖,应让被依赖的 Provider 先声明,并确保它位于依赖者的祖先范围。
4. 用 ProxyProvider 表达对象间的派生依赖
如果一个对象需要随着另一个依赖变化而更新,可以使用 ProxyProvider 或其 ChangeNotifier 变体。示意代码:
MultiProvider(
providers: <SingleChildWidget>[
Provider<ApiClient>(
create: (_) => ApiClient(),
),
ChangeNotifierProxyProvider<ApiClient, UserModel>(
create: (context) => UserModel(context.read<ApiClient>()),
update: (context, api, user) {
user ??= UserModel(api);
user.updateApi(api);
return user;
},
),
],
child: const App(),
)
update 可能被多次调用,所以 UserModel 必须能处理重复更新依赖。不能在每次 update 都无条件创建新模型,否则模型内部状态、监听关系和异步任务可能被反复丢弃。
六、ChangeNotifier 的状态变化、异步和错误路径
1. 状态修改应集中在模型方法中
不推荐让 Widget 直接修改模型的内部字段:
// 不推荐:模型暴露可变字段
class BadModel extends ChangeNotifier {
final List<String> items = <String>[];
}
如果外部执行:
context.read<BadModel>().items.add('x');
模型无法知道发生了修改,除非调用者还记得手动调用通知,而这会把状态不变量分散到各处。
更可靠的接口是:
class GoodModel extends ChangeNotifier {
final List<String> _items = <String>[];
List<String> get items => List.unmodifiable(_items);
void add(String item) {
_items.add(item);
notifyListeners();
}
}
模型方法可以集中保证:
- 输入是否合法;
- 状态是否真的改变;
- 多个字段是否必须同时更新;
- 修改后何时通知;
- 异步操作是否仍然有效。
2. 通知是同步发出的,异步不会自动顺序化
ChangeNotifier 的通知调用是同步的,但异步任务的完成顺序可能与发起顺序不同:
Future<void> search(String keyword) async {
_loading = true;
notifyListeners();
final result = await repository.search(keyword);
_items = result;
_loading = false;
notifyListeners();
}
如果用户先搜索 a,再搜索 ab,而 a 的请求后返回,旧结果可能覆盖新结果。Provider 不会自动解决这个竞态。
一种简单的请求代次方案是:
class SearchModel extends ChangeNotifier {
SearchModel(this.repository);
final SearchRepository repository;
int _requestId = 0;
bool _loading = false;
List<String> _items = <String>[];
Object? _error;
bool get loading => _loading;
List<String> get items => List.unmodifiable(_items);
Object? get error => _error;
Future<void> search(String keyword) async {
final requestId = ++_requestId;
_loading = true;
_error = null;
notifyListeners();
try {
final result = await repository.search(keyword);
if (requestId != _requestId) {
return;
}
_items = result;
_loading = false;
notifyListeners();
} catch (error) {
if (requestId != _requestId) {
return;
}
_error = error;
_loading = false;
notifyListeners();
}
}
}
这里的条件是:
只有最新请求满足条件,才允许写入状态。它解决的是“旧结果覆盖新结果”,不是网络取消;旧请求仍可能继续消耗资源。
3. 不要在 build 中无条件启动修改状态的操作
下面的代码可能导致构建期间反复触发状态变化:
@override
Widget build(BuildContext context) {
context.read<ProfileModel>().load();
return const Text('加载中');
}
build 可能执行多次。每次执行都调用 load(),可能产生重复请求、重复通知,甚至在构建流程中修改依赖,导致异常或难以预测的时序。
可以在 StatefulWidget 的 initState 中延迟到首帧后启动:
class ProfilePage extends StatefulWidget {
const ProfilePage({super.key});
@override
State<ProfilePage> createState() => _ProfilePageState();
}
class _ProfilePageState extends State<ProfilePage> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) {
return;
}
context.read<ProfileModel>().load();
});
}
@override
Widget build(BuildContext context) {
final model = context.watch<ProfileModel>();
if (model.error != null) {
return Text('加载失败:${model.error}');
}
if (model.loading) {
return const CircularProgressIndicator();
}
return Text(model.name ?? '无名称');
}
}
如果初始化只应执行一次,也可以把初始化放在模型创建后,由拥有它的层负责调用,但必须避免因为父 Widget 重建而重复创建或重复初始化。
4. notifyListeners() 不是异常通道
网络错误、解析错误和业务错误应成为模型可观察状态的一部分:
enum LoadStatus { idle, loading, success, failure }
class DataModel extends ChangeNotifier {
LoadStatus _status = LoadStatus.idle;
Object? _error;
LoadStatus get status => _status;
Object? get error => _error;
Future<void> load() async {
_status = LoadStatus.loading;
_error = null;
notifyListeners();
try {
await Future<void>.delayed(const Duration(milliseconds: 10));
_status = LoadStatus.success;
notifyListeners();
} catch (error) {
_status = LoadStatus.failure;
_error = error;
notifyListeners();
}
}
}
notifyListeners() 只表示“可观察状态可能变了”,并不会把异常自动显示在界面,也不会替代 try/catch。如果异步方法的异常需要让调用者处理,应在模型中记录状态后重新抛出,或者定义清楚由模型吞掉并暴露错误状态的约定。
七、重建范围的推导与诊断
考虑以下界面:
class Dashboard extends StatelessWidget {
const Dashboard({super.key});
@override
Widget build(BuildContext context) {
final model = context.watch<DashboardModel>();
return Column(
children: <Widget>[
Text(model.username),
Text('${model.unreadCount}'),
const LargeStaticPanel(),
],
);
}
}
只要 DashboardModel 通知,Dashboard.build 就会再次执行。即便只有 unreadCount 变化,username 和 LargeStaticPanel 所在的 build 代码也会再次运行。
可以将读取拆分:
class Dashboard extends StatelessWidget {
const Dashboard({super.key});
@override
Widget build(BuildContext context) {
return const Column(
children: <Widget>[
UsernameText(),
UnreadCountText(),
LargeStaticPanel(),
],
);
}
}
class UsernameText extends StatelessWidget {
const UsernameText({super.key});
@override
Widget build(BuildContext context) {
final username = context.select<DashboardModel, String>(
(model) => model.username,
);
return Text(username);
}
}
class UnreadCountText extends StatelessWidget {
const UnreadCountText({super.key});
@override
Widget build(BuildContext context) {
final count = context.select<DashboardModel, int>(
(model) => model.unreadCount,
);
return Text('$count');
}
}
此时:
username变化只影响UsernameText;unreadCount变化只影响UnreadCountText;LargeStaticPanel没有订阅DashboardModel。
这不是“Widget 越细越好”的规则,而是依赖边界的推导结果:一个 Widget 读取多少状态,就有多少状态变化可能使它重建。
常见诊断方法
在怀疑重建过多时,可以先证明问题,而不是直接改写结构:
class DebugWidget extends StatelessWidget {
const DebugWidget({super.key});
@override
Widget build(BuildContext context) {
debugPrint('DebugWidget build');
return Text('${context.watch<CartModel>().totalQuantity}');
}
}
还可以使用 Flutter DevTools 的 Widget 重建相关调试能力和性能工具观察实际行为。需要注意:
build日志只能说明 Dart 层 build 执行了;- 它不能单独证明发生了昂贵的布局、绘制或平台调用;
- 优化应基于实际测量,而不是仅凭重建次数推断性能问题。
八、Provider 的生命周期与 Widget 生命周期
Provider 对象的生命周期通常由 Provider 节点控制,而不是由某个读取它的 Widget 控制:
Provider 插入树
↓
模型创建
↓
后代 Widget 读取并建立依赖
↓
模型通知,依赖 Widget 重建
↓
Provider 被移除
↓
模型 dispose
如果一个页面通过路由退出,页面子树和其中的 Provider 可能被移除,Provider 创建的模型也会被销毁。若希望多个页面共享模型,应把 Provider 放在这些页面的共同祖先处。
例如:
MaterialApp(
home: ChangeNotifierProvider(
create: (_) => CartModel(),
child: const ProductPage(),
),
)
CartPage 如果通过 ProductPage 内的路由进入,并且仍位于该 Provider 子树下,就可以读取同一个 CartModel。但如果导航到一个不在该子树中的独立 Navigator 或根级路由,原 Provider 不一定可见。
嵌套 Navigator、Shell 路由、对话框和 Overlay 是常见边界。判断方法不是看“页面是否从这个页面打开”,而是检查实际插入的 Widget 子树中,读取上下文是否位于 Provider 后代范围内。
九、测试:分别验证模型、依赖注入和重建
Provider 测试通常分三层:
- 不依赖 Flutter 框架的模型单元测试;
- 使用真实 Provider 的 Widget 测试;
- 使用替代依赖验证错误、加载和边界状态。
1. 直接测试 ChangeNotifier
ChangeNotifier 的状态逻辑不一定需要 Widget 测试:
import 'package:flutter_test/flutter_test.dart';
void main() {
test('add increments product quantity and total', () {
final model = CartModel();
expect(model.quantityOf('book'), 0);
expect(model.totalQuantity, 0);
model.add('book');
expect(model.quantityOf('book'), 1);
expect(model.totalQuantity, 1);
});
test('remove at zero is a no-op', () {
final model = CartModel();
var notificationCount = 0;
model.addListener(() {
notificationCount++;
});
model.remove('book');
expect(model.quantityOf('book'), 0);
expect(notificationCount, 0);
});
}
这个测试验证状态转移:
并验证无效删除不会改变状态,也不会发送不必要的通知。是否要求“状态未改变时不通知”是模型的设计约定,不是 ChangeNotifier 自动保证的行为;如果业务依赖该约定,就应测试它。
2. 验证通知次数时要注意资源清理
test('notifies once for one successful mutation', () {
final model = CartModel();
var notifications = 0;
void listener() {
notifications++;
}
model.addListener(listener);
model.add('book');
expect(notifications, 1);
model.removeListener(listener);
model.dispose();
});
测试中手动添加的监听器应移除;拥有 ChangeNotifier 的测试也应在不再使用时 dispose。否则复杂测试套件中可能留下资源和监听关系,使后续测试行为受污染。
3. Widget 测试验证 Provider 查找和界面更新
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
void main() {
testWidgets('button updates displayed quantity', (tester) async {
await tester.pumpWidget(
ChangeNotifierProvider(
create: (_) => CartModel(),
child: const MaterialApp(
home: ProductTile(
productId: 'book',
title: '书籍',
),
),
),
);
expect(find.text('数量:0'), findsOneWidget);
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
expect(find.text('数量:1'), findsOneWidget);
});
}
每一步的含义是:
pumpWidget创建 Provider 和 Widget 树;- 初始模型数量为零,所以应该找到
数量:0; tap执行按钮回调,回调通过read修改模型;notifyListeners()标记ProductTile的select依赖;pump推进测试框架的构建和绘制流程;- 最终文本变为
数量:1。
如果只调用 tap 而不推进异步帧,测试可能还没执行重建就开始断言。pump 的作用不是“让 Provider 工作”,而是让 Widget 测试环境处理已经产生的更新。
4. 用预构造模型替换 Provider
测试一个特定初始状态时,可以提供现有实例:
testWidgets('renders existing cart state', (tester) async {
final model = CartModel()..add('book');
await tester.pumpWidget(
ChangeNotifierProvider.value(
value: model,
child: const MaterialApp(
home: CartPage(),
),
),
);
expect(find.text('总数量:1'), findsOneWidget);
model.dispose();
});
这里的模型由测试代码创建,因此测试代码负责释放它。不要让生产代码中的全局单例进入这类测试,否则测试之间会共享状态,顺序变化可能改变结果。
5. 用接口替换网络依赖
ChangeNotifier 直接依赖具体 HTTP 客户端时,测试会变慢且不稳定。可以先抽象仓储:
abstract interface class SearchRepository {
Future<List<String>> search(String keyword);
}
class FakeSearchRepository implements SearchRepository {
@override
Future<List<String>> search(String keyword) async {
return <String>['$keyword-result'];
}
}
模型接收接口:
class SearchModel extends ChangeNotifier {
SearchModel(this.repository);
final SearchRepository repository;
List<String> _items = <String>[];
List<String> get items => List.unmodifiable(_items);
Future<void> search(String keyword) async {
_items = await repository.search(keyword);
notifyListeners();
}
}
测试可以验证异步完成后的状态:
test('loads search results from repository', () async {
final model = SearchModel(FakeSearchRepository());
await model.search('flutter');
expect(model.items, <String>['flutter-result']);
model.dispose();
});
依赖注入的价值在这里很具体:测试控制输入和完成时机,能够区分“Provider 查找错误”“模型状态错误”和“真实网络故障”。
十、测试中常见的 Provider 错误
1. ProviderNotFoundException
如果出现找不到 Provider 的异常,按以下顺序检查:
context.watch<CartModel>()
要求:
- 当前
context位于ChangeNotifierProvider<CartModel>的后代; - 泛型类型一致;
- 没有在 Provider 的父上下文中提前读取;
- 测试的
pumpWidget包含了完整 Provider 包装; - 嵌套路由或对话框没有脱离原来的 Provider 子树。
错误示例:
Widget build(BuildContext context) {
final cart = context.watch<CartModel>();
return ChangeNotifierProvider(
create: (_) => CartModel(),
child: Text('${cart.totalQuantity}'),
);
}
这里读取发生在内层 Provider 的祖先上下文上。修复方式是把读取 Widget 放到 Provider 的 child 子树中。
2. Provider 类型不一致
下面是两个不同类型:
Provider<CartModel>(create: (_) => CartModel())
Provider<ChangeNotifier>(create: (_) => CartModel())
通过 context.read<CartModel>() 查找时,第二种并不等同于第一种。Provider 的类型参数参与依赖查找;声明什么类型,就应按什么类型读取。
3. 测试中错误使用 .value
如果测试创建实例:
final model = CartModel();
再使用:
ChangeNotifierProvider.value(
value: model,
child: ...
)
测试代码仍然拥有这个实例,最后应释放它。反过来,如果把一个已经由生产 Provider 管理的实例再次包装,并让多个层都认为自己负责销毁,就可能出现生命周期冲突。
十一、平台差异:Provider 规则基本一致,资源边界不同
Provider 和 ChangeNotifier 运行在 Dart/Flutter Widget 层,因此 Android、iOS、桌面和 Web 的依赖查找、监听和重建语义没有因平台而改变:
- 最近祖先 Provider 的查找规则相同;
watch、read、select的订阅含义相同;ChangeNotifier.dispose仍应释放模型持有的资源;- Widget 测试不依赖真实 Android 或 iOS 界面。
差异主要来自模型所连接的平台资源:
- Android、iOS 可能涉及生命周期暂停、后台恢复、权限和原生插件回调;
- 桌面可能涉及窗口、文件系统、键盘鼠标和多窗口结构;
- Web 可能涉及浏览器刷新、标签页生命周期、URL、网络限制和 JavaScript 互操作;
- 不同平台的网络、文件、通知和硬件插件可能具有不同的错误与恢复路径。
因此,不应把“Provider 状态仍在内存中”误认为“应用一定仍然活跃”。例如移动端应用进入后台后,操作系统可能暂停进程或回收进程;进程被终止后,内存中的 ChangeNotifier 不会自动恢复,持久化状态需要另行保存和加载。
如果模型监听 AppLifecycleState、平台流或插件回调,应在 dispose 中取消订阅:
class LifecycleModel extends ChangeNotifier {
LifecycleModel() {
WidgetsBinding.instance.addObserver(_observer);
}
final WidgetsBindingObserver _observer = _LifecycleObserver();
@override
void dispose() {
WidgetsBinding.instance.removeObserver(_observer);
super.dispose();
}
}
实际实现中,观察者对象还需要把回调转发给模型;上例重点是生命周期原则:注册观察者和释放观察者必须成对出现。不能把 Android、iOS、桌面或 Web 的后台行为假定为完全一致。
十二、常见误解和失败表现
误解一:调用 notifyListeners() 就会刷新所有页面
实际情况是,只有建立了对应依赖的 Widget 才会被 Provider 标记。使用 read 的 Widget 不会因通知自动重建。
诊断方法:检查显示值的代码是否使用了 watch、select、Consumer 或 Selector。
误解二:每次 build 都会创建新的模型
下面的 Provider 会由 Provider 生命周期管理模型实例:
ChangeNotifierProvider(
create: (_) => CartModel(),
child: const ProductPage(),
)
只要这个 Provider 节点仍被 Flutter 复用,模型不会因为后代 Widget 的普通重建而自动重新创建。真正会导致重建的原因包括 Provider 节点被移除、键或类型变化造成节点替换,以及上层结构发生实际变化。
误解三:select 会深度比较所有对象
select 依赖选择值的相等判断。它不是通用深度比较器。选择不可变基础值或正确实现值相等的对象,才能让选择语义稳定。
误解四:ChangeNotifier 能自动处理并发
Dart UI isolate 中的代码通常按事件循环执行,ChangeNotifier 的监听通知也不是跨 isolate 的并发同步机制。异步操作仍然可能乱序返回;多个 isolate 之间也不会共享同一份普通 Dart 对象。请求代次、取消机制、状态版本号和明确的错误状态仍由业务模型负责。
误解五:把所有状态都放进一个全局模型更简单
一个巨大的全局 ChangeNotifier 会使所有依赖共享同一个通知源,最终造成:
- 不相关界面难以隔离;
select投影越来越复杂;- 测试需要构造大量无关依赖;
- 生命周期和权限边界变得模糊。
状态放置的位置应由使用范围决定:
单个控件内部状态 → StatefulWidget 或其他局部机制
同一页面共享状态 → 页面子树中的 Provider
多个功能共享状态 → 更高层 Provider
跨重启持久化状态 → Provider 之外增加持久化存储
Provider 解决的是依赖可见性和通知连接,不负责替代数据库、缓存、路由状态或平台生命周期管理。
十三、如何选择依赖范围和重建边界
可以按以下因果关系设计:
- 先确定状态的所有者;
- 把 Provider 放在所有使用者的最近共同祖先附近;
- 在只执行命令的地方使用
read; - 在显示状态的地方使用
watch或select; - 如果一个 Widget 读取了互不相关的多个字段,拆分读取边界;
- 如果对象已经由外部管理,使用
value并明确销毁责任; - 如果模型依赖其他服务,通过构造函数和 Provider 显式注入;
- 如果存在异步任务,处理失败、取消、过期结果和
dispose; - 用模型单元测试验证状态转移,再用 Widget 测试验证 Provider 查找和界面响应。
最终可以把一次状态变化描述为:
用户操作
↓
context.read<T>() 取得模型
↓
模型方法校验并修改内部状态
↓
notifyListeners()
↓
watch/select/Consumer/Selector 依赖被检查
↓
选择值变化的依赖重建
↓
Widget 根据新状态生成界面
其中每一步都有独立的失败可能:
read失败:Provider 不在作用域内或类型不匹配;- 模型方法失败:业务校验、网络或解析错误;
- 未通知:状态已变但界面不更新;
- 订阅范围过大:无关 Widget 频繁重建;
- 选择值不稳定:
select优化失效; - 生命周期错误:对象泄漏、重复订阅或异步结果写入已销毁模型;
- 测试缺少
pump:断言发生在 Widget 尚未完成更新之前。
理解这些边界后,Provider 就不再只是“在上下文里拿一个对象”的语法,而是一套围绕 Widget 树组织对象所有权、状态通知、依赖范围和可验证行为的机制。
系列导航与关联阅读
- 系列入口:Flutter 完整学习路线:从 Dart 与 Widget 到多端架构和应用发布
- 上一篇:Flutter 焦点与键盘:FocusNode、快捷键、遍历和输入法
- 下一篇:Flutter Riverpod:Provider、Notifier、异步状态、生命周期和测试
官方资料
本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论