Flutter 基础体系 · 第 52/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。

Flutter Provider:ChangeNotifier、依赖范围、重建和测试

Provider 是 Flutter 生态中常用的依赖注入与状态访问工具。它本身不定义状态模型,也不会把任意对象自动变成“响应式状态”;通常的组合是:

  • ChangeNotifier:保存可变状态,并在状态变化后通知监听者;
  • ChangeNotifierProvider:把对象放入 Widget 树,负责提供依赖和管理生命周期;
  • context.watchcontext.readcontext.selectConsumerSelector:声明当前 Widget 如何读取状态,以及读取后是否需要在通知到来时重建。

理解这套组合,关键不在于记住几个 API,而在于分清四件事:

  1. 状态对象在哪里创建、由谁销毁;
  2. 哪些 Widget 依赖这个对象;
  3. notifyListeners() 触发哪些 Widget 重建;
  4. 测试时如何替换依赖、验证状态变化和 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();
  }
}

这里有三个独立动作:

  1. _value++ 修改内存中的状态;
  2. notifyListeners() 向已注册的监听者发送同步通知;
  3. 监听者根据自己的依赖关系决定是否调用 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(w)=[a1,a2,a3,]A(w) = [a_1, a_2, a_3, \ldots]

其中 a₁ 是最近的祖先。若某个祖先提供了类型为 T 的对象,则:

lookup(w,T)=ak.datalookup(w, T) = a_k.data

其中 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 之上;
  • ProductPageProductTileCartButtonCartPage 都能查找到它;
  • ProductTile 只选择某个商品的数量;
  • CartButton 只选择总数量;
  • CartPage 通过 watch 读取整个模型的 totalQuantity
  • 点击按钮时通过 read 获取模型并执行命令,但按钮本身不会因为读取命令而订阅状态。

三、createvalue 与对象生命周期

1. create 用于 Provider 创建的新对象

最常见的写法是:

ChangeNotifierProvider(
  create: (_) => CartModel(),
  child: const ShoppingApp(),
)

此时 Provider 通常负责:

  1. 在需要时创建 CartModel
  2. 把它提供给后代 Widget;
  3. 当 Provider 从树中移除时,对该对象执行清理。

ChangeNotifier 没有必须覆写的 dispose 方法,但如果模型持有 StreamSubscriptionTimerTextEditingController 或其他资源,应释放它们:

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,但仍应显式处理初始化失败。


四、watchreadselect 的真实区别

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,选择函数为:

f:SPf: S \rightarrow P

其中 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

UserModelcreate 回调可以通过自己的上下文读取位于它之前的 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();
    }
  }
}

这里的条件是:

requestId完成=requestId当前requestId_{完成} = requestId_{当前}

只有最新请求满足条件,才允许写入状态。它解决的是“旧结果覆盖新结果”,不是网络取消;旧请求仍可能继续消耗资源。

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 变化,usernameLargeStaticPanel 所在的 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 测试通常分三层:

  1. 不依赖 Flutter 框架的模型单元测试;
  2. 使用真实 Provider 的 Widget 测试;
  3. 使用替代依赖验证错误、加载和边界状态。

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);
  });
}

这个测试验证状态转移:

quantity(book)=0add(book)quantity(book)=1quantity(book)=0 \xrightarrow{add(book)} quantity(book)=1

并验证无效删除不会改变状态,也不会发送不必要的通知。是否要求“状态未改变时不通知”是模型的设计约定,不是 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);
  });
}

每一步的含义是:

  1. pumpWidget 创建 Provider 和 Widget 树;
  2. 初始模型数量为零,所以应该找到 数量:0
  3. tap 执行按钮回调,回调通过 read 修改模型;
  4. notifyListeners() 标记 ProductTileselect 依赖;
  5. pump 推进测试框架的构建和绘制流程;
  6. 最终文本变为 数量: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 的查找规则相同;
  • watchreadselect 的订阅含义相同;
  • 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 不会因通知自动重建。

诊断方法:检查显示值的代码是否使用了 watchselectConsumerSelector

误解二:每次 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 解决的是依赖可见性和通知连接,不负责替代数据库、缓存、路由状态或平台生命周期管理。


十三、如何选择依赖范围和重建边界

可以按以下因果关系设计:

  1. 先确定状态的所有者;
  2. 把 Provider 放在所有使用者的最近共同祖先附近;
  3. 在只执行命令的地方使用 read
  4. 在显示状态的地方使用 watchselect
  5. 如果一个 Widget 读取了互不相关的多个字段,拆分读取边界;
  6. 如果对象已经由外部管理,使用 value 并明确销毁责任;
  7. 如果模型依赖其他服务,通过构造函数和 Provider 显式注入;
  8. 如果存在异步任务,处理失败、取消、过期结果和 dispose
  9. 用模型单元测试验证状态转移,再用 Widget 测试验证 Provider 查找和界面响应。

最终可以把一次状态变化描述为:

用户操作
  ↓
context.read<T>() 取得模型
  ↓
模型方法校验并修改内部状态
  ↓
notifyListeners()
  ↓
watch/select/Consumer/Selector 依赖被检查
  ↓
选择值变化的依赖重建
  ↓
Widget 根据新状态生成界面

其中每一步都有独立的失败可能:

  • read 失败:Provider 不在作用域内或类型不匹配;
  • 模型方法失败:业务校验、网络或解析错误;
  • 未通知:状态已变但界面不更新;
  • 订阅范围过大:无关 Widget 频繁重建;
  • 选择值不稳定:select 优化失效;
  • 生命周期错误:对象泄漏、重复订阅或异步结果写入已销毁模型;
  • 测试缺少 pump:断言发生在 Widget 尚未完成更新之前。

理解这些边界后,Provider 就不再只是“在上下文里拿一个对象”的语法,而是一套围绕 Widget 树组织对象所有权、状态通知、依赖范围和可验证行为的机制。


系列导航与关联阅读

官方资料

本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。