Flutter 基础体系 · 第 53/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。
Flutter Riverpod:Provider、Notifier、异步状态、生命周期和测试
Riverpod 是一个以 Dart 对象和依赖图为核心的状态管理库。Flutter 集成包 flutter_riverpod 负责把这些对象接入 Widget 树;真正保存状态、解析依赖、缓存结果和管理生命周期的核心是 ProviderContainer。
本文示例使用 Dart 3 的现代语法,并采用不依赖代码生成器的写法。这样可以直接看清 Provider 和 Notifier 的运行机制。项目中应根据实际依赖文件锁定 riverpod、flutter_riverpod 和 riverpod_annotation 等包的兼容版本;不同大版本可能新增或弃用个别辅助 API,但本文讨论的 Provider、Notifier、异步状态、ProviderScope 和 ProviderContainer 是 Riverpod 的核心模型。
一、先建立整体模型:状态不属于 Widget
在没有状态管理库时,一个页面常见的结构是:
Widget
├─ 创建数据对象
├─ 发起请求
├─ 保存 loading/data/error
├─ 监听变化
└─ 在 dispose 中清理资源
这种写法的问题不是“不能运行”,而是数据生命周期与 Widget 生命周期强绑定:
- 页面销毁后,数据是否应该销毁不明确;
- 多个页面需要同一份数据时,容易重复请求;
- 请求、错误处理和重试逻辑散落在多个页面;
- 单元测试必须创建 Widget,测试成本较高。
Riverpod 将这个结构拆成三部分:
Provider:描述如何创建或计算一个对象
Container:保存 Provider 的实例、缓存和生命周期
Consumer:从 Container 读取状态,并把变化连接到 Widget
可以把某个 Provider 看成一个惰性的函数:
但它不是每次读取都重新执行的普通函数。Container 会根据 Provider 的身份缓存结果,并在依赖变化时使结果失效或重新计算。
例如:
final appNameProvider = Provider<String>((ref) {
return 'WR BLOG';
});
这里:
Provider<String>声明该 Provider 输出String;- 回调中的
ref用于读取其他 Provider、注册生命周期回调或使当前 Provider 失效; container.read(appNameProvider)会得到"WR BLOG";- 只要该 Provider 没有被覆盖,Container 通常会复用它的结果。
Riverpod 的状态不是 Widget 的字段,也不是全局静态变量。它属于某个 ProviderContainer。Flutter 中的 ProviderScope 会在 Widget 树中创建并管理一个 Container。
void main() {
runApp(
const ProviderScope(
child: MyApp(),
),
);
}
如果 Widget 树中有多个 ProviderScope,每个 Scope 可以拥有自己的 Provider 状态。子 Scope 默认继承父 Scope 的 Provider 配置,也可以使用 overrides 替换特定 Provider。
二、Provider 的基本职责:值、依赖和缓存
2.1 Provider<T>:同步、只读的派生值
最基本的 Provider 用于创建服务对象,或根据其他状态计算派生数据。
final apiClientProvider = Provider<ApiClient>((ref) {
return ApiClient(
baseUrl: 'https://api.example.com',
);
});
final apiBaseUrlProvider = Provider<String>((ref) {
return 'https://api.example.com';
});
当一个 Provider 依赖另一个 Provider 时,应通过 ref.watch 建立依赖关系:
final configuredApiClientProvider = Provider<ApiClient>((ref) {
final baseUrl = ref.watch(apiBaseUrlProvider);
return ApiClient(baseUrl: baseUrl);
});
这里的因果关系是:
configuredApiClientProvider读取apiBaseUrlProvider;- Container 记录这条依赖边;
apiBaseUrlProvider发生变化时,配置后的 API Client 需要重新计算;- 只用
ref.read则不会建立这条响应式依赖边。
Provider 适合:
- API Client;
- Repository;
- 配置对象;
- 日志器、路由服务等无状态对象;
- 从其他 Provider 计算出的同步值。
它不适合直接保存会变化的业务状态。业务状态应交给 Notifier、AsyncNotifier 或专门的异步 Provider。
2.2 read、watch 和 listen 的区别
三种读取方式有不同的因果语义。
ref.watch
watch 表示“读取,并在依赖变化时重新计算当前对象”。
final todosProvider = Provider<List<Todo>>((ref) {
final repository = ref.watch(todoRepositoryProvider);
return repository.cachedTodos;
});
在 Widget 中:
class TodoCountText extends ConsumerWidget {
const TodoCountText({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(todosCountProvider);
return Text('$count');
}
}
watch 会让当前 Provider 或 Widget 订阅状态变化。
ref.read
read 表示“只取当前值,不建立响应式订阅”。
onPressed: () {
ref.read(todosProvider.notifier).reload();
}
按钮回调通常使用 read,因为回调本身不需要在状态变化时重新构建。业务计算是否需要随依赖变化更新,则不能用 read 替代 watch。
常见错误如下:
final wrongProvider = Provider<String>((ref) {
final config = ref.read(configProvider);
return config.endpoint;
});
如果 configProvider 之后变化,wrongProvider 不会因为这条读取自动失效。若该依赖属于计算定义的一部分,应使用 watch。
ref.listen
listen 用于监听变化并执行副作用,例如显示 SnackBar、跳转页面或记录日志:
ref.listen<AsyncValue<List<Todo>>>(
todosProvider,
(previous, next) {
if (next.hasError) {
// 显示错误提示、记录日志等副作用
}
},
);
副作用不应塞进 build 中。build 可能因依赖变化而执行多次,若在那里直接导航或显示 SnackBar,可能重复触发。
2.3 Provider 的身份和 .family
Provider 的缓存不是按“回调产生的值”识别,而是按 Provider 实例及其参数识别。
当同一类逻辑需要不同参数时,可以使用 family:
final userProvider = FutureProvider.family<User, String>((ref, userId) async {
final api = ref.watch(apiClientProvider);
return api.fetchUser(userId);
});
以下两个 Provider 参数不同,因此对应两个缓存实例:
ref.watch(userProvider('u001'));
ref.watch(userProvider('u002'));
可以将其抽象为:
参数必须具备稳定、正确的相等性语义。字符串、整数、枚举通常适合作为 family 参数。若把一个每次都新建且没有稳定 == 的对象作为参数,可能导致缓存命中失败,表现为重复创建和重复请求。
family 解决的是“同一逻辑的参数化实例”,不是自动解决分页、请求去重或无限滚动。后者仍需在 Notifier 或 Repository 中定义明确的数据模型。
三、Notifier:把可变业务状态和操作集中起来
3.1 为什么不用在 Widget 中直接修改状态
一个简单的计数器可以写成:
final counterProvider =
NotifierProvider<CounterNotifier, int>(CounterNotifier.new);
class CounterNotifier extends Notifier<int> {
@override
int build() {
return 0;
}
void increment() {
state++;
}
void decrement() {
state--;
}
}
Widget 使用时:
class CounterPage extends ConsumerWidget {
const CounterPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(counterProvider);
return Column(
children: [
Text('$count'),
FilledButton(
onPressed: () {
ref.read(counterProvider.notifier).increment();
},
child: const Text('加一'),
),
],
);
}
}
数据流是:
flowchart LR
A[用户点击] --> B[Notifier.increment]
B --> C[state = 新值]
C --> D[ProviderContainer 更新状态]
D --> E[watch 该 Provider 的 Widget 重建]
Notifier 的关键点是:
build()返回初始状态;state是当前状态;- 公共方法表达业务操作;
- Widget 只负责展示和发送意图,不直接修改内部状态。
这种结构保证了状态变化路径集中在一个对象中。测试时可以直接测试 increment(),不需要创建页面。
3.2 Notifier 的同步生命周期
Notifier.build() 不等于一次性的构造函数。Notifier 的实例由 Container 管理:
- 第一次有代码读取或监听 Provider;
- Container 创建 Notifier;
- 调用
build()取得初始状态; - 后续
watch读取缓存状态; - 依赖变化、刷新或失效时,
build()可能再次运行; - Provider 被销毁时,关联资源被释放。
因此,不应把“只执行一次”的初始化假设放在 build() 中。例如:
class SettingsNotifier extends Notifier<Settings> {
@override
Settings build() {
// 这里可能因依赖变化而重新执行
return Settings.defaults();
}
}
如果初始化包含副作用,例如写文件、发送请求或注册全局监听器,应明确放到对应的生命周期和服务层中,而不是假定 build() 永远只执行一次。
四、异步状态:FutureProvider、StreamProvider 和 AsyncNotifier
异步代码至少有三种状态:
如果只用一个 bool isLoading 和一个可空数据字段,实际还会出现以下歧义:
- 首次加载和刷新是否相同;
- 错误时是否保留旧数据;
- 空数据和没有数据是否相同;
- 错误堆栈是否被保留。
Riverpod 用 AsyncValue<T> 表示异步状态,常见形态为:
AsyncLoading
AsyncData<T>
AsyncError
4.1 FutureProvider:读取型异步数据
当数据主要是“根据依赖读取”,没有复杂的写入操作时,FutureProvider 很合适:
final userProvider = FutureProvider.family<User, String>((ref, userId) async {
final api = ref.watch(apiClientProvider);
return api.fetchUser(userId);
});
Widget 中应处理三种状态:
class UserView extends ConsumerWidget {
const UserView({
required this.userId,
super.key,
});
final String userId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final userAsync = ref.watch(userProvider(userId));
return userAsync.when(
loading: () => const CircularProgressIndicator(),
error: (error, stackTrace) {
return Text('加载失败:$error');
},
data: (user) => Text(user.name),
);
}
}
FutureProvider 负责:
- 执行 Future;
- 把完成结果包装成
AsyncData; - 把异常和堆栈包装成
AsyncError; - 在依赖变化或 Provider 失效时重新执行。
它不适合把“创建、编辑、删除、重试、乐观更新”等复杂命令全部塞进 Widget。此时应使用 AsyncNotifier。
4.2 StreamProvider:持续事件流
StreamProvider 表示多个异步事件,而不是一次 Future:
final connectionStateProvider = StreamProvider<ConnectionState>((ref) {
final connectivity = ref.watch(connectivityProvider);
return connectivity.states();
});
Stream 的生命周期由 Provider 管理。Provider 不再需要时,Riverpod 会取消订阅;但底层 Stream 是否能真正停止,还取决于 Stream 创建方的实现。
这一区别很重要:
- Riverpod 可以取消对 Stream 的监听;
- 不能保证第三方 SDK 已经停止底层网络、传感器或系统回调;
- 需要使用 SDK 提供的取消 API,或在
ref.onDispose中显式关闭资源。
4.3 AsyncNotifier:带业务命令的异步状态
以下是一个相对完整的待办示例。它包含:
- 初始加载;
- 重新加载;
- 修改单项;
- 异步错误;
- Provider 依赖;
- 状态更新。
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class Todo {
const Todo({
required this.id,
required this.title,
required this.completed,
});
final String id;
final String title;
final bool completed;
Todo copyWith({bool? completed}) {
return Todo(
id: id,
title: title,
completed: completed ?? this.completed,
);
}
}
abstract interface class TodoRepository {
Future<List<Todo>> fetchTodos();
Future<void> setCompleted(String id, bool completed);
}
final todoRepositoryProvider = Provider<TodoRepository>((ref) {
return HttpTodoRepository();
});
final todosProvider =
AsyncNotifierProvider<TodosNotifier, List<Todo>>(TodosNotifier.new);
class TodosNotifier extends AsyncNotifier<List<Todo>> {
@override
Future<List<Todo>> build() async {
final repository = ref.watch(todoRepositoryProvider);
return repository.fetchTodos();
}
Future<void> reload() async {
// AsyncValue.guard 会把成功和异常统一转换为 AsyncValue。
state = const AsyncLoading();
final repository = ref.read(todoRepositoryProvider);
state = await AsyncValue.guard(repository.fetchTodos);
}
Future<void> setCompleted(String id, bool completed) async {
final oldTodos = state.valueOrNull;
if (oldTodos == null) {
// 尚未加载成功时,不进行依赖旧数据的局部修改。
return;
}
final newTodos = [
for (final todo in oldTodos)
todo.id == id ? todo.copyWith(completed: completed) : todo,
];
// 乐观更新:先让界面反映用户操作。
state = AsyncData(newTodos);
try {
final repository = ref.read(todoRepositoryProvider);
await repository.setCompleted(id, completed);
} catch (error, stackTrace) {
// 服务端失败时回滚到修改前的快照。
state = AsyncError(error, stackTrace);
state = AsyncData(oldTodos);
rethrow;
}
}
}
class TodosPage extends ConsumerWidget {
const TodosPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final todos = ref.watch(todosProvider);
return todos.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stackTrace) => Center(
child: Text('请求失败:$error'),
),
data: (items) => ListView(
children: [
for (final todo in items)
CheckboxListTile(
value: todo.completed,
title: Text(todo.title),
onChanged: (value) {
if (value == null) return;
ref
.read(todosProvider.notifier)
.setCompleted(todo.id, value)
.catchError((_) {
// 这里可以显示错误提示。
});
},
),
],
),
);
}
}
上述代码中的状态变化过程是:
初始:AsyncLoading
↓ fetchTodos 成功
AsyncData([Todo...])
↓ 用户勾选
AsyncData(本地修改后的列表)
↓ setCompleted 成功
保持本地列表
失败路径是:
AsyncData(旧列表)
↓ 乐观更新
AsyncData(新列表)
↓ 服务端失败
AsyncError(error, stackTrace)
↓ 回滚
AsyncData(旧列表)
示例中故意把错误状态和回滚分成两个赋值。这样 UI 可能观察到一个非常短的 AsyncError,随后又收到旧数据。如果产品要求“保留旧数据并同时显示错误”,应使用带旧数据的 UI 状态模型,或根据当前 Riverpod 版本支持的 AsyncValue 保留数据能力来设计,而不是依赖连续赋值产生的时序。
更重要的是,乐观更新必须处理并发。假设用户快速点击两次:
请求 A:completed = true
请求 B:completed = false
如果 B 先完成,A 后完成,服务器最终状态可能是 true;但客户端如果按完成顺序直接写入,就可能显示 false。形式化地说,若操作序列为 ,最终状态应满足业务定义的顺序关系,而不能简单等于“最后返回的响应”。
常见解决方式有三类:
- 串行化操作:前一个请求完成后才允许下一个请求;
- 版本号或操作令牌:只接受当前版本对应的响应;
- 服务端提供版本条件:例如使用 revision、ETag 或事务冲突检测。
Riverpod 不会自动替应用决定并发语义。AsyncNotifier 管理的是状态容器,不是分布式一致性协议。
五、异步状态的错误、刷新和重试
5.1 错误必须保留堆栈
不要只写:
catch (error) {
state = AsyncError(error, StackTrace.current);
}
如果原始异常已有堆栈,应该使用捕获到的 stackTrace:
try {
await repository.fetchTodos();
} catch (error, stackTrace) {
state = AsyncError(error, stackTrace);
}
原始堆栈能定位真正的网络调用、JSON 解码或数据库操作位置。生产环境可在 ProviderObserver 或统一错误层中记录错误,但展示给用户的消息不应直接暴露内部堆栈。
5.2 refresh 和 invalidate 的语义不同
对于:
final result = ref.refresh(userProvider(userId));
refresh 会立即使 Provider 失效并重新计算,然后返回新的读取结果。
而:
ref.invalidate(userProvider(userId));
表示丢弃当前状态,之后在再次需要时重新计算。它更接近“标记过期”,不应被理解为立即完成网络请求。
在异步列表页面中,常见选择是:
- 用户下拉刷新:调用 Notifier 的
reload(),可以控制是否保留旧数据显示刷新指示; - 登录用户切换:让依赖用户身份的 Provider 自动失效;
- 保存成功后:
invalidate相关查询 Provider,让下次读取重新获取服务端数据。
5.3 loading 不一定意味着清空旧数据
下面这种写法会让刷新时界面完全变成 loading:
state = const AsyncLoading();
state = await AsyncValue.guard(repository.fetchTodos);
对于首次加载通常合理,但对于刷新可能导致页面闪烁。另一种设计是保留旧数据,同时单独记录刷新状态:
class TodoViewState {
const TodoViewState({
required this.items,
required this.isRefreshing,
this.error,
});
final List<Todo> items;
final bool isRefreshing;
final Object? error;
}
这不是 Riverpod 强制的模型,而是业务界面对“旧数据可用”和“正在刷新”的明确表达。状态模型越清楚,UI 就越不需要猜测 null 的含义。
六、生命周期:何时创建、何时销毁
6.1 普通 Provider 的生命周期
普通 Provider 通常在第一次被读取或监听时创建,并在所属 Container 销毁时释放。
ProviderScope 创建
↓
第一次 watch/read
↓
Provider 实例创建并缓存
↓
继续被使用
↓
ProviderScope 或 Container 销毁
↓
资源释放
普通 Provider 不会因为某个页面暂时不监听就自动销毁。适合放置:
- 应用级 Repository;
- 共享 API Client;
- 会话对象;
- 需要跨页面复用的缓存。
如果服务本身需要释放资源,可以在创建时注册:
final socketProvider = Provider<SocketClient>((ref) {
final socket = SocketClient();
ref.onDispose(() {
socket.close();
});
return socket;
});
ref.onDispose 的意义是:Provider 生命周期结束时执行清理。它不是“Widget 每次重建时执行”,也不是普通异常处理回调。
6.2 autoDispose:没有监听者时释放
如果 Provider 代表页面级或请求级资源,可以启用自动销毁:
final articleProvider =
FutureProvider.autoDispose.family<Article, String>((ref, id) async {
final repository = ref.watch(articleRepositoryProvider);
return repository.fetchArticle(id);
});
当该 Provider 没有监听者后,Riverpod 会在适当的调度周期检查并销毁它。实践中通常表现为不再使用后经过一个 Flutter frame 才清理,而不是 watch 消失的同步瞬间。
autoDispose 适合:
- 详情页独占的数据;
- 搜索词对应的临时请求;
- 页面离开后不需要保留的状态;
- 需要随页面离开取消订阅的 Stream。
但它会改变缓存策略。页面反复进入时,可能重新创建 Provider 并重新请求数据。
6.3 ref.keepAlive():有条件地保留自动销毁 Provider
自动销毁不等于永远不能缓存。某些请求成功后可以保留,失败时允许下次重试:
final articleProvider =
FutureProvider.autoDispose.family<Article, String>((ref, id) async {
final keepAliveLink = ref.keepAlive();
try {
final repository = ref.watch(articleRepositoryProvider);
return await repository.fetchArticle(id);
} catch (_) {
// 失败时关闭保活,后续无人监听时可以销毁。
keepAliveLink.close();
rethrow;
}
});
这里的策略是:
请求成功:保持缓存
请求失败:允许自动销毁,下一次进入重新请求
keepAlive 不是永久缓存协议。它只影响该次 Provider 实例的自动销毁条件;应用进程被系统杀死、Web 页面刷新或 Container 被销毁时,内存状态仍然会消失。
6.4 资源取消和 Provider 销毁不是同一件事
下面的代码只记录 Provider 是否已被销毁:
final searchProvider =
FutureProvider.autoDispose.family<List<String>, String>((ref, query) async {
var disposed = false;
ref.onDispose(() {
disposed = true;
});
final result = await searchRemote(query);
if (disposed) {
// 不再把结果用于业务处理。
}
return result;
});
但这不一定取消已经发出的 HTTP 请求。真正的取消能力取决于 HTTP 客户端是否支持 cancellation token、abort controller 或类似机制。生产代码应把取消句柄接入 ref.onDispose:
final requestProvider =
FutureProvider.autoDispose<String>((ref) async {
final request = CancellableRequest();
ref.onDispose(request.cancel);
return request.execute();
});
CancellableRequest 是示意接口,具体实现必须使用所选网络库提供的真实取消 API,不能把 Future 的完成当作网络请求已经停止。
6.5 生命周期与平台差异
Riverpod 的 Provider 生命周期属于 Dart 进程和 ProviderContainer,不等同于操作系统生命周期。
| 平台 | 需要特别注意的边界 |
|---|---|
| Android | App 进入后台时,Provider 通常不会自动销毁;进程被系统杀死后内存状态全部丢失。 |
| iOS | 后台挂起、系统终止和重新启动是不同事件;不能依赖 Provider 在后台长期执行任务。 |
| 桌面端 | 窗口关闭、应用退出和系统休眠可能触发不同资源清理路径,应显式关闭数据库、Socket 等资源。 |
| Web | 浏览器刷新会重建 Dart 应用和 Container;多个浏览器标签页通常各自拥有独立内存状态。 |
| 所有平台 | ProviderContainer 不会跨 isolate 自动共享;需要消息传递或持久化存储。 |
如果需要跨重启保存状态,应使用数据库、文件、Key-Value 存储或 Web Storage 等持久化方案,并在 Provider 初始化时读取。Riverpod 本身主要管理内存中的依赖和状态,不是持久化数据库。
七、ProviderScope:把状态接入 Flutter
Flutter 页面要读取 Provider,需要位于 ProviderScope 下:
void main() {
runApp(
const ProviderScope(
child: MyApp(),
),
);
}
读取 Provider 的 Widget 通常使用以下类型之一:
ConsumerWidgetConsumerStatefulWidgetConsumerConsumerState
例如:
class ProfileHeader extends ConsumerWidget {
const ProfileHeader({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final profile = ref.watch(profileProvider);
return Text(profile.displayName);
}
}
ConsumerStatefulWidget 适合同时需要 Flutter State 生命周期和 Riverpod 读取能力的场景:
class SearchPage extends ConsumerStatefulWidget {
const SearchPage({super.key});
@override
ConsumerState<SearchPage> createState() => _SearchPageState();
}
class _SearchPageState extends ConsumerState<SearchPage> {
@override
void initState() {
super.initState();
// 可以在这里使用 ref,但不要把需要响应式更新的读取固定在 initState 中。
ref.listenManual(searchStateProvider, (previous, next) {
// 监听副作用
});
}
@override
Widget build(BuildContext context) {
final state = ref.watch(searchStateProvider);
return Text(state.query);
}
}
如果只是展示状态,使用 ConsumerWidget 更直接。不要为了调用一个按钮方法,把整个页面都改造成复杂的 StatefulWidget。
八、依赖图和重建范围
假设有如下依赖:
final tokenProvider = Provider<String>((ref) => 'token');
final apiProvider = Provider<ApiClient>((ref) {
return ApiClient(token: ref.watch(tokenProvider));
});
final profileProvider = FutureProvider<Profile>((ref) {
return ref.watch(apiProvider).fetchProfile();
});
依赖图是:
flowchart TD
T[tokenProvider] --> A[apiProvider]
A --> P[profileProvider]
P --> W[Profile Widget]
当 tokenProvider 变化时:
apiProvider被标记为需要重新计算;profileProvider因依赖变化而失效;- Profile 请求重新执行;
- 监听
profileProvider的 Widget 看到新的AsyncValue。
这是一种响应式数据流,而不是 Widget 树从上到下传递所有参数。
对于大型页面,如果只需要状态中的一个字段,可以使用 select 限制不相关变化造成的重建:
final userName = ref.watch(
userProvider.select((user) => user.name),
);
select 依赖所选结果的比较结果。若 name 没有变化,Widget 通常不会因 User 的其他字段改变而重建。它不是免费的性能魔法:选择器本身会执行,过度复杂的选择器反而增加维护成本。首先应保持状态模型清晰,再对有证据的重建热点进行优化。
九、测试:ProviderContainer、override 和 Widget 测试
Riverpod 测试的核心优势是:业务状态可以脱离 Flutter Widget,通过 ProviderContainer 测试。
9.1 为 Repository 定义可替换接口
先把真实实现和测试替身分开:
class FakeTodoRepository implements TodoRepository {
FakeTodoRepository({
this.shouldFail = false,
});
bool shouldFail;
final List<Todo> todos = const [
Todo(id: '1', title: '学习 Riverpod', completed: false),
];
@override
Future<List<Todo>> fetchTodos() async {
if (shouldFail) {
throw StateError('fetch failed');
}
return todos;
}
@override
Future<void> setCompleted(String id, bool completed) async {
if (shouldFail) {
throw StateError('update failed');
}
}
}
9.2 测试同步 Provider
import 'package:flutter_test/flutter_test.dart';
import 'package:riverpod/riverpod.dart';
void main() {
test('Provider 可以在 Container 中读取', () {
final container = ProviderContainer();
addTearDown(container.dispose);
expect(container.read(apiBaseUrlProvider), 'https://api.example.com');
});
}
每个测试都应创建自己的 ProviderContainer,并在结束时销毁:
final container = ProviderContainer();
addTearDown(container.dispose);
原因是 Container 保存缓存和资源。如果多个测试共享同一个 Container,前一个测试可能把状态、缓存或覆盖配置泄漏给后一个测试。
9.3 测试 AsyncNotifier 的成功和失败
void main() {
test('加载待办成功', () async {
final fakeRepository = FakeTodoRepository();
final container = ProviderContainer(
overrides: [
todoRepositoryProvider.overrideWithValue(fakeRepository),
],
);
addTearDown(container.dispose);
final result = await container.read(todosProvider.future);
expect(result, hasLength(1));
expect(result.single.title, '学习 Riverpod');
expect(
container.read(todosProvider),
isA<AsyncData<List<Todo>>>(),
);
});
test('加载待办失败', () async {
final fakeRepository = FakeTodoRepository(shouldFail: true);
final container = ProviderContainer(
overrides: [
todoRepositoryProvider.overrideWithValue(fakeRepository),
],
);
addTearDown(container.dispose);
expect(
() => container.read(todosProvider.future),
throwsA(isA<StateError>()),
);
final state = container.read(todosProvider);
expect(state.hasError, isTrue);
});
}
这里有两个值得注意的事实:
container.read(todosProvider.future)会等待异步 Provider 成功,失败时 Future 会抛出异常;container.read(todosProvider)读取的是当前AsyncValue,可以检查它是 loading、data 还是 error。
如果测试需要观察每次状态转换,可以显式监听:
test('可以观察异步状态序列', () async {
final container = ProviderContainer(
overrides: [
todoRepositoryProvider.overrideWithValue(FakeTodoRepository()),
],
);
addTearDown(container.dispose);
final states = <AsyncValue<List<Todo>>>[];
final subscription = container.listen<List<Todo>>(
todosProvider,
(previous, next) {
states.add(next);
},
fireImmediately: true,
);
addTearDown(subscription.close);
await container.read(todosProvider.future);
expect(states, isNotEmpty);
expect(states.last.hasValue, isTrue);
});
不同 Riverpod 版本对 listen 的泛型推断和回调类型可能略有差异;如果编译器提示类型不匹配,应以当前版本 API 签名为准,核心测试思想不变:为 Container 注入替身,等待状态收敛,再断言状态和副作用。
9.4 Widget 测试中的 Provider 覆盖
Widget 测试可以覆盖 Repository:
testWidgets('页面显示待办', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
todoRepositoryProvider.overrideWithValue(
FakeTodoRepository(),
),
],
child: const MaterialApp(
home: TodosPage(),
),
),
);
// 第一次 pump 触发异步加载。
await tester.pump();
// 等待 Future 完成和后续重建。
await tester.pumpAndSettle();
expect(find.text('学习 Riverpod'), findsOneWidget);
});
pump() 和 pumpAndSettle() 的区别是:
pump()推进一次 Flutter frame,适合观察 loading 到下一状态的中间过程;pumpAndSettle()反复推进 frame,直到没有待处理动画或微任务;- 如果页面存在持续动画、轮询或未结束的 Stream,
pumpAndSettle()可能一直等待,此时应使用固定次数的pump或测试专用的可控数据源。
十、ProviderObserver:诊断状态变化
当需要记录 Provider 的创建、更新或异常时,可以实现观察器:
class LoggingObserver extends ProviderObserver {
@override
void didUpdateProvider(
ProviderObserverContext context,
Object? previousValue,
Object? newValue,
) {
// 生产环境应过滤敏感信息。
print(
'${context.provider.runtimeType}: '
'$previousValue -> $newValue',
);
}
@override
void providerDidFail(
ProviderObserverContext context,
Object error,
StackTrace stackTrace,
) {
print('Provider failed: $error');
}
}
注册:
void main() {
runApp(
ProviderScope(
observers: [
LoggingObserver(),
],
child: const MyApp(),
),
);
}
诊断时可以重点检查:
- Provider 是否被意外重复创建;
- family 参数是否不稳定;
- 请求是否因依赖变化反复发起;
autoDispose是否导致页面返回时重新加载;- 错误堆栈是否来自原始请求位置。
生产环境不要无条件打印 Token、用户信息或完整响应体。ProviderObserver 能看到状态对象,状态对象可能包含敏感数据。
十一、常见误解和失败表现
11.1 把 Provider 当作全局变量
Provider 的定义通常是顶层变量,但状态属于 Container,不属于这个 Dart 变量本身。因此:
final containerA = ProviderContainer();
final containerB = ProviderContainer();
containerA.read(counterProvider) 和 containerB.read(counterProvider) 是两份独立状态。
这正是测试隔离和多 Scope 覆盖能够成立的原因,也是为什么不能把某个 Container 随意存成全局单例来“绕过” ProviderScope。
11.2 在 build 中用 read 代替 watch
Widget build(BuildContext context, WidgetRef ref) {
final value = ref.read(counterProvider);
return Text('$value');
}
这段代码能显示初始值,但 Counter 变化时 Widget 不会因该读取自动重建。展示状态时通常使用 watch,事件回调中调用方法时使用 read。
11.3 把所有逻辑放进 StateProvider
StateProvider 可以快速保存简单值,例如筛选开关:
final showCompletedProvider = StateProvider<bool>((ref) => false);
但当状态变化需要校验、持久化、网络请求或多个字段协同更新时,直接暴露可写状态会让调用者绕过业务规则。此时应改用 Notifier:
final filterProvider =
NotifierProvider<FilterNotifier, Filter>(FilterNotifier.new);
class FilterNotifier extends Notifier<Filter> {
@override
Filter build() => const Filter(showCompleted: false);
void toggleCompleted() {
state = state.copyWith(
showCompleted: !state.showCompleted,
);
}
}
StateProvider 不是错误 API,但它的适用边界是“简单、局部、无需业务命令的状态”。
11.4 认为 autoDispose 会在 App 进入后台时自动清理
autoDispose 观察的是 Provider 是否还有监听者,不是 Android 或 iOS 的后台状态。页面仍在导航栈中、Widget 仍保持监听时,App 进入后台并不会因此自动销毁 Provider。
需要监听 App 生命周期时,应使用 Flutter 的 WidgetsBindingObserver、平台 API 或专门的生命周期服务,再把结果接入 Provider。
11.5 认为异步请求会自动取消
Provider 被销毁后,Riverpod 可以停止向下游通知并释放订阅,但已经发出的网络请求是否取消取决于底层客户端。没有取消机制的 Future 仍可能在后台完成。
如果请求结果会影响外部资源、写入文件或触发副作用,必须自行设计取消、版本校验或幂等策略。
11.6 在 Provider 中创建不可复用的隐式依赖
final dataProvider = FutureProvider<Data>((ref) async {
final client = ApiClient(); // 隐式创建
return client.fetch();
});
这会让测试难以替换 Client,也让连接池、认证配置和关闭时机不明确。更可维护的方式是把 Client 声明为独立 Provider,再通过 watch 注入:
final clientProvider = Provider<ApiClient>((ref) {
return ApiClient();
});
final dataProvider = FutureProvider<Data>((ref) async {
return ref.watch(clientProvider).fetch();
});
十二、如何选择 Provider 类型
可以根据状态的来源和是否存在业务命令进行判断:
| 类型 | 适合场景 | 是否保存可变业务状态 |
|---|---|---|
Provider<T> |
同步服务、配置、派生值 | 通常不保存 |
Provider.family |
按参数计算同步值 | 每个参数一份实例 |
FutureProvider<T> |
读取一次异步结果 | 由 AsyncValue 表示 |
StreamProvider<T> |
持续事件流 | 由 AsyncValue 表示 |
StateProvider<T> |
简单开关、筛选条件、输入辅助状态 | 可以,但业务能力有限 |
NotifierProvider<N, T> |
同步状态和业务操作 | 是 |
AsyncNotifierProvider<N, T> |
异步初始化、写入、重试和命令 | 是 |
AutoDispose 变体 |
页面离开后不需要保留的状态 | 生命周期更短 |
一个实用的判断顺序是:
- 这是一个服务或派生值吗?使用
Provider; - 只有一次读取型 Future 吗?使用
FutureProvider; - 是持续事件流吗?使用
StreamProvider; - 状态需要由多个业务方法修改吗?使用
Notifier; - 初始化或操作本身是异步的吗?使用
AsyncNotifier; - 没有监听者时是否应该丢弃缓存?再决定是否
autoDispose。
这不是强制规则。例如一个 AsyncNotifier 也可以包装 Stream 或数据库订阅,但应让状态和命令语义保持清晰,不要为了“统一”而把所有 Provider 都改成同一种类型。
十三、跨 Android、iOS、桌面和 Web 的工程边界
Riverpod 运行在 Dart 层,因此 Provider 的依赖解析、watch/read 语义和 Container 生命周期在各平台基本一致。平台差异主要来自 Provider 内部调用的资源:
- Android 和 iOS 的权限、后台执行、进程终止由各自系统控制;
- 桌面端文件、窗口和系统托盘生命周期不等同于 Flutter Widget 生命周期;
- Web 的刷新会重建整个 Dart 应用,内存 Provider 状态不会自动恢复;
- Web、移动端和桌面端使用的网络、文件、数据库插件可能不同;
- isolate 之间不共享同一个 ProviderContainer。
因此,推荐把平台差异隔离在接口之后:
abstract interface class Storage {
Future<String?> read(String key);
Future<void> write(String key, String value);
}
final storageProvider = Provider<Storage>((ref) {
return createPlatformStorage();
});
上层 Notifier 只依赖 Storage 接口。测试时覆盖 storageProvider,就不需要在每个平台上真实访问文件系统或浏览器存储。
十四、把完整数据流落到实际架构
一个包含登录、列表和操作的应用,常见数据流可以表示为:
flowchart TD
UI[Consumer Widget] -->|watch| S[AsyncNotifier 状态]
UI -->|read notifier| C[业务命令]
C --> R[Repository]
R --> N[网络/数据库/平台 API]
N --> R
R --> C
C --> S
S --> UI
Auth[认证 Provider] --> R
Config[配置 Provider] --> R
这条链路中每一层职责不同:
- Widget:展示
AsyncValue,发送用户意图; - Notifier:维护页面或领域状态,协调加载、更新、回滚和并发;
- Repository:抽象数据来源和错误转换;
- 平台 API:执行网络、数据库、文件或系统调用;
- Provider:声明这些对象如何创建以及彼此依赖;
- Container:缓存实例、应用 override,并管理生命周期。
当出现“页面一直 loading”时,应沿着这条路径诊断:
- Provider 是否位于
ProviderScope下; build()是否真的被读取或监听;- Repository 的 Future 是否完成;
- 是否有异常但 UI 没有处理
error; - 是否在
autoDispose后被重复创建; - 是否有一个变化频繁的依赖导致请求不断失效;
- 测试中是否遗漏了异步
pump或 Container 的 override。
当出现“数据偶尔回退”时,应重点检查并发操作和异步完成顺序,而不是先假设 Riverpod 丢失了状态。状态回退通常来自多个 Future 以不同顺序完成后,Notifier 直接采用了过期响应。
结语:Riverpod 的核心是显式数据流
Provider 负责声明依赖和创建对象,Notifier 负责集中表达状态变化和业务命令,AsyncValue 负责区分加载、成功和失败,autoDispose 与 keepAlive 负责缓存边界,ProviderScope 和 ProviderContainer 负责隔离运行环境,override 则让测试可以替换真实依赖。
真正需要掌握的不是某个 Provider 类型的名称,而是以下因果关系:
依赖变化
→ Provider 失效或重建
→ Notifier/Future/Stream 产生新状态
→ Container 更新缓存
→ watch 订阅者重建
当异步请求存在取消、重试、乐观更新或并发写入时,还必须补充业务层的顺序、回滚和一致性规则。Riverpod 可以提供清晰的状态容器和生命周期工具,但不会替应用自动决定网络请求的幂等性,也不会替应用保证跨进程、跨平台或跨重启的数据持久化。
系列导航与关联阅读
- 系列入口:Flutter 完整学习路线:从 Dart 与 Widget 到多端架构和应用发布
- 上一篇:Flutter Provider:ChangeNotifier、依赖范围、重建和测试
- 下一篇:Flutter BLoC:Event、State、转换、并发、持久化和测试
官方资料
本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论