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

Flutter Riverpod:Provider、Notifier、异步状态、生命周期和测试

Riverpod 是一个以 Dart 对象和依赖图为核心的状态管理库。Flutter 集成包 flutter_riverpod 负责把这些对象接入 Widget 树;真正保存状态、解析依赖、缓存结果和管理生命周期的核心是 ProviderContainer

本文示例使用 Dart 3 的现代语法,并采用不依赖代码生成器的写法。这样可以直接看清 Provider 和 Notifier 的运行机制。项目中应根据实际依赖文件锁定 riverpodflutter_riverpodriverpod_annotation 等包的兼容版本;不同大版本可能新增或弃用个别辅助 API,但本文讨论的 Provider、Notifier、异步状态、ProviderScope 和 ProviderContainer 是 Riverpod 的核心模型。


一、先建立整体模型:状态不属于 Widget

在没有状态管理库时,一个页面常见的结构是:

Widget
 ├─ 创建数据对象
 ├─ 发起请求
 ├─ 保存 loading/data/error
 ├─ 监听变化
 └─ 在 dispose 中清理资源

这种写法的问题不是“不能运行”,而是数据生命周期与 Widget 生命周期强绑定:

  • 页面销毁后,数据是否应该销毁不明确;
  • 多个页面需要同一份数据时,容易重复请求;
  • 请求、错误处理和重试逻辑散落在多个页面;
  • 单元测试必须创建 Widget,测试成本较高。

Riverpod 将这个结构拆成三部分:

Provider:描述如何创建或计算一个对象
Container:保存 Provider 的实例、缓存和生命周期
Consumer:从 Container 读取状态,并把变化连接到 Widget

可以把某个 Provider 看成一个惰性的函数:

P:(依赖状态)当前状态P : (依赖状态) \rightarrow 当前状态

但它不是每次读取都重新执行的普通函数。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);
});

这里的因果关系是:

  1. configuredApiClientProvider 读取 apiBaseUrlProvider
  2. Container 记录这条依赖边;
  3. apiBaseUrlProvider 发生变化时,配置后的 API Client 需要重新计算;
  4. 只用 ref.read 则不会建立这条响应式依赖边。

Provider 适合:

  • API Client;
  • Repository;
  • 配置对象;
  • 日志器、路由服务等无状态对象;
  • 从其他 Provider 计算出的同步值。

它不适合直接保存会变化的业务状态。业务状态应交给 NotifierAsyncNotifier 或专门的异步 Provider。


2.2 readwatchlisten 的区别

三种读取方式有不同的因果语义。

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

可以将其抽象为:

P(a)=根据参数 a 创建的一份独立 Provider 实例P(a) = \text{根据参数 } a \text{ 创建的一份独立 Provider 实例}

参数必须具备稳定、正确的相等性语义。字符串、整数、枚举通常适合作为 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 管理:

  1. 第一次有代码读取或监听 Provider;
  2. Container 创建 Notifier;
  3. 调用 build() 取得初始状态;
  4. 后续 watch 读取缓存状态;
  5. 依赖变化、刷新或失效时,build() 可能再次运行;
  6. Provider 被销毁时,关联资源被释放。

因此,不应把“只执行一次”的初始化假设放在 build() 中。例如:

class SettingsNotifier extends Notifier<Settings> {
  @override
  Settings build() {
    // 这里可能因依赖变化而重新执行
    return Settings.defaults();
  }
}

如果初始化包含副作用,例如写文件、发送请求或注册全局监听器,应明确放到对应的生命周期和服务层中,而不是假定 build() 永远只执行一次。


四、异步状态:FutureProvider、StreamProvider 和 AsyncNotifier

异步代码至少有三种状态:

S{loading,data,error}S \in \{\text{loading}, \text{data}, \text{error}\}

如果只用一个 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。形式化地说,若操作序列为 ABA \rightarrow B,最终状态应满足业务定义的顺序关系,而不能简单等于“最后返回的响应”。

常见解决方式有三类:

  1. 串行化操作:前一个请求完成后才允许下一个请求;
  2. 版本号或操作令牌:只接受当前版本对应的响应;
  3. 服务端提供版本条件:例如使用 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 refreshinvalidate 的语义不同

对于:

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 通常使用以下类型之一:

  • ConsumerWidget
  • ConsumerStatefulWidget
  • Consumer
  • ConsumerState

例如:

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 变化时:

  1. apiProvider 被标记为需要重新计算;
  2. profileProvider 因依赖变化而失效;
  3. Profile 请求重新执行;
  4. 监听 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);
  });
}

这里有两个值得注意的事实:

  1. container.read(todosProvider.future) 会等待异步 Provider 成功,失败时 Future 会抛出异常;
  2. 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 变体 页面离开后不需要保留的状态 生命周期更短

一个实用的判断顺序是:

  1. 这是一个服务或派生值吗?使用 Provider
  2. 只有一次读取型 Future 吗?使用 FutureProvider
  3. 是持续事件流吗?使用 StreamProvider
  4. 状态需要由多个业务方法修改吗?使用 Notifier
  5. 初始化或操作本身是异步的吗?使用 AsyncNotifier
  6. 没有监听者时是否应该丢弃缓存?再决定是否 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”时,应沿着这条路径诊断:

  1. Provider 是否位于 ProviderScope 下;
  2. build() 是否真的被读取或监听;
  3. Repository 的 Future 是否完成;
  4. 是否有异常但 UI 没有处理 error
  5. 是否在 autoDispose 后被重复创建;
  6. 是否有一个变化频繁的依赖导致请求不断失效;
  7. 测试中是否遗漏了异步 pump 或 Container 的 override。

当出现“数据偶尔回退”时,应重点检查并发操作和异步完成顺序,而不是先假设 Riverpod 丢失了状态。状态回退通常来自多个 Future 以不同顺序完成后,Notifier 直接采用了过期响应。


结语:Riverpod 的核心是显式数据流

Provider 负责声明依赖和创建对象,Notifier 负责集中表达状态变化和业务命令,AsyncValue 负责区分加载、成功和失败,autoDisposekeepAlive 负责缓存边界,ProviderScopeProviderContainer 负责隔离运行环境,override 则让测试可以替换真实依赖。

真正需要掌握的不是某个 Provider 类型的名称,而是以下因果关系:

依赖变化
  → Provider 失效或重建
  → Notifier/Future/Stream 产生新状态
  → Container 更新缓存
  → watch 订阅者重建

当异步请求存在取消、重试、乐观更新或并发写入时,还必须补充业务层的顺序、回滚和一致性规则。Riverpod 可以提供清晰的状态容器和生命周期工具,但不会替应用自动决定网络请求的幂等性,也不会替应用保证跨进程、跨平台或跨重启的数据持久化。


系列导航与关联阅读

官方资料

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