关于 state-management-riverpod
面向 Flutter 开发的 Riverpod 状态管理技能,提供状态建模、依赖注入和生命周期处理规范。强调不可变状态、单一仓库写入路径及 Riverpod 3.x 用法,并附有检查脚本。
主要功能
- 01以不可变状态组织功能
- 02通过仓库统一持久化写入
- 03使用 Provider 注入依赖
- 04规范读取、副作用与生命周期
- 05提供 Riverpod 3.x 测试参考
- 06脚本检查旧式 Provider 用法
适用场景
- 01为 Flutter 功能设计状态与 ViewModel
- 02让持久化变更在提交后更新界面
- 03为实体或会话管理自动释放状态
- 04在提交代码前检查旧式 Provider
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ zakariaf/ flutter-skills --skill state-management-riverpod安装后在 Claude Code 中输入/state-management-riverpod确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
examples/composition_root.darttask_list_feature.dartreferences/ownership-and-lifecycle.mdreads-and-side-effects.mdriverpod3-api-and-testing.mdscripts/ban-legacy-providers.sh详细介绍
状态管理(Riverpod)
状态和依赖注入由同一种机制负责:Riverpod 3.x。每个功能都有一个 Notifier/AsyncNotifier/StreamNotifier ViewModel,用于公开一个不可变状态值;简单的 ConsumerWidget 读取该值。Widget 仅持有临时 UI 状态;所有持久化变更都通过一个仓库(唯一写入路径)进行。本技能先介绍与状态库无关的核心原则,再介绍 Riverpod 的“具体做法”,最后附上适用于官方 Flutter 指南技术栈的 Provider/ChangeNotifier 附录。
请根据当前任务阅读相应参考资料:
references/ownership-and-lifecycle.md— 所有权表(应使用哪种 Provider 形式)、family/autoDispose/onDispose 规则、组合根依赖注入。references/reads-and-side-effects.md— watch 与 read、listen、select 的区别,闭包过期问题,返回void的操作方法,以及ref.mounted/BuildContext守卫。references/riverpod3-api-and-testing.md— Riverpod 3.x API 变更(旧版 API 的迁移位置、overrideWithValue、重试、ProviderContainer.test)、2023 年教程中的错误内容,以及测试接缝。
提交 PR 前运行 scripts/ban-legacy-providers.sh。
不可妥协的规则
无论使用哪种状态库,以下规则都适用。
- 每个功能使用一个 ViewModel 管理一个不可变状态值。 状态必须是具有值相等性的值类型(
freezed/sealed +copyWith),绝不能是可变字段集合。跨功能/共享状态应放在仓库中,而不是 ViewModel 里——两个 ViewModel 管理同一事实,就会产生两个彼此不一致的事实。 - 状态保持私有;只能通过意图方法修改。 Widget 不得直接访问状态并改动字段。视图调用
notifier.rename(id, name),绝不直接设置状态。这是状态变更的唯一入口,因此每次变更都能在一个地方进行审查。 - 每次状态转换都必须赋予一个新值。 基于值相等性的监听器会按值进行比较——原地修改实例后又将同一引用重新赋值,会导致变更被忽略,UI 也会悄然过期。始终使用
copyWith创建新实例。 - 推导,不存储。 可从现有状态计算得出的值(总数、筛选后的数量、连续记录天数)应使用 getter 或流投影,绝不能作为第二个字段存储——存储推导值会产生第二个事实来源,并逐渐与实际状态不一致。
- 唯一写入路径。 每次持久化变更都必须通过仓库的一个方法完成;该方法应以事务方式持久化,并且仅在提交完成后返回。UI 更新应由流重新发出数据触发,而不是通过乐观地重新发布数据触发。先持久化再发布,可从结构上保障崩溃安全,而非依赖纪律约束。
- 单向数据流。 意图 → ViewModel → 仓库/服务 → 新状态 → 视图。视图绝不修改读取到的数据,也绝不绕过链路向上短路。数据向下流动;事件向上流动。
- 依赖注入的抽象,绝不自行构造。 ViewModel 接收其仓库/服务/时钟;绝不通过
new创建实际协作者,也绝不持有BuildContext。这样才能使用伪对象进行测试,并按不同风味替换实现。 - 将异步状态建模为显式状态,而不是零散标志。 使用一个
AsyncValue(或状态枚举 + 数据 + 错误)并穷尽所有情况进行渲染——绝不散布isLoading/hasError布尔值,以免编码出只设置了一部分的无效状态。 - 删除时级联清理引用。 删除实体时必须清除所有对它的引用(分配、外键、选择状态)——仍指向已删除父项的项目会埋下崩溃隐患。绝不静默丢弃孤儿;应明确地重新归属或取消分配。
- 状态逻辑中不得调用
DateTime.now()。 “现在”必须通过注入的Clock(来自package:clock)提供,并通过clockProvider暴露,以确保依赖时间的行为可在测试中确定(clockProvider.overrideWithValue(Clock.fixed(t)))。绝不调用DateTime.now(),也绝不自建ClockService。service-boundary-and-native负责提供clockProvider接缝。
Riverpod:选择合适的形态
根据其拥有的内容选择 Provider 形态(完整表格见 references/ownership-and-lifecycle.md):
| 拥有的内容 | 形态 |
|---|---|
| 基于实时查询的功能级展示状态 | StreamNotifier,其 build() 返回仓库流 |
| 非流式的功能级展示状态 | 基于单个不可变值的 Notifier / AsyncNotifier |
| 数据库的响应式投影 | 基于仓库/DAO 流的 StreamProvider(绝不存储) |
注入的协作者(仓库、服务、Clock) | 普通 Provider(DI) |
| 实体级/会话级状态 | Notifier/AsyncNotifier/StreamNotifier .family + .autoDispose |
默认使用手写 Provider(StreamNotifierProvider/AsyncNotifierProvider(...)、.autoDispose/.family 修饰符)。@riverpod 代码生成是可选的便利功能,而非默认方式(见参考资料)。仅使用 Riverpod 3.0 API——基类为 Notifier/AsyncNotifier/StreamNotifier;autoDispose/family 是 Provider 修饰符,绝不是 AutoDispose*/*Family 基类(已在 3.0 中移除)。
// features/task — one StreamNotifier ViewModel: build() returns the LIVE repo stream,
// so a committed write re-emits and the UI updates with no manual republish (D6).
class TaskListNotifier extends StreamNotifier<TaskListState> {
@override
Stream<TaskListState> build() {
final filter = ref.watch(taskFilterProvider); // in-session filter, its own Notifier
return ref.watch(taskRepositoryProvider).watchAll() // the live source of truth
.map((tasks) => TaskListState(tasks: tasks, filter: filter));
}
// Durable act routes through the single write path. void so the call site never drops
// the Future; no `state = ...` — the committed write makes watchAll() re-emit.
void complete(TaskId id) =>
unawaited(ref.read(taskRepositoryProvider).markComplete(id).catchError(_report));
void _report(Object error, StackTrace stack) {/* surface via a logger — never swallow */}
}
final taskListNotifierProvider =
StreamNotifierProvider.autoDispose<TaskListNotifier, TaskListState>(
TaskListNotifier.new);
// The reversible in-session filter is its own tiny Notifier (manual NotifierProvider).
class TaskFilterNotifier extends Notifier<TaskFilter> {
@override
TaskFilter build() => TaskFilter.all;
void set(TaskFilter f) => state = f; // watched by build() above; re-projects the stream
}
final taskFilterProvider =
NotifierProvider<TaskFilterNotifier, TaskFilter>(TaskFilterNotifier.new);
Riverpod:Provider 即 DI(会抛出异常的接缝)
ViewModel 从 Provider 读取协作者;组合根只负责一次性接入实际实现。占位接缝会抛出异常,直到被覆盖,因此未接线的依赖会在首次读取时明确报错,而不会在测试中悄悄构造真实服务。
// A seam: throws until a flavor main() overrides it. One live impl per flavor.
final databaseProvider = Provider<AppDatabase>(
(ref) => throw UnimplementedError('override databaseProvider in main()'),
);
final taskRepositoryProvider = Provider<TaskRepository>(
(ref) => TaskRepository(ref.watch(databaseProvider)), // inject, never `new` a live DB here
);
// main.dart — the ONLY place live collaborators are constructed.
void main() {
final db = AppDatabase.open();
runApp(ProviderScope(
overrides: [databaseProvider.overrideWithValue(db)],
child: const App(),
));
}
添加 Provider 之前,先问问是否可以使用构造函数参数。Provider 数量增加是一种异味,而非进展——只有普通 Provider 确实无法表达需求时,才使用 family/作用域/代码生成。
Riverpod:读取方式——watch / read / listen
完整说明见 references/reads-and-side-effects.md。
ref.watch(p)/ref.watch(p.select((s) => s.field))——在build()中用于显示。.select将重建范围缩小到单个字段。绝不要在大型 Widget 顶层watch整个控制器。ref.read(p.notifier)——在回调中使用(onTap、onPressed)。在build()中对值使用read会冻结在过期数据上;在回调中使用watch则会导致意外重建。ref.listen(p, ...)——在build()中监听变化并执行副作用(导航、显示 Snackbar)。绝不要直接从build()触发副作用。
class TaskCounter extends ConsumerWidget {
const TaskCounter({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final n = ref.watch(taskListNotifierProvider.select((s) => s.value?.openCount ?? 0));
return Text('$n');
}
}
**过期闭包陷阱:**绝不要将 ref.watch 的值捕获到 onTap 闭包中——传入稳定的键,并在点击时通过 ref.read 解析。快速再次点击后,被捕获的值会悄悄对之前的实体执行操作,而且没有任何 lint 能捕获这一问题。
// WRONG — acts on a stale item after a re-render.
onTap: () => ref.read(p.notifier).complete(task),
// RIGHT — id is stable; resolve now.
onTap: () => ref.read(p.notifier).complete(task.id),
操作路径方法返回 void。 箭头闭包 onTap: () => notifier.doThing() “返回”一个 Future,既不满足 discarded_futures,也不满足 unawaited_futures(目标类型是 VoidCallback),因此 Future 及其错误都会被丢弃。使用返回 void 的意图方法即可避免这一隐患;在方法内部通过 unawaited(_run().catchError(_report)) 启动异步操作。参见 async-safety。
Riverpod:生命周期与释放
- 实体级/会话级状态使用
.family,并以稳定且可比较的值(ID 或@freezed参数记录)作为键,绝不使用可变对象;同时使用.autoDispose,确保卸载时释放占用大量资源的会话状态。 - 应用级单例(数据库、引擎、服务)使用普通
Provider——绝不使用autoDispose。 await后写入前,使用ref.mounted检查(相当于 Riverpod 版的use_build_context_synchronously)。- 在
ref.onDispose中释放所拥有的资源(流、控制器、原生句柄)。Riverpod 会管理StreamProvider的订阅,因此不要手动管理。
Riverpod:无障碍状态不应通过 provider 传递
在 widget 的构建期间读取 MediaQuery.boldTextOf(context) / .textScalerOf(context) / .highContrastOf(context),绝不要通过 provider 读取。MediaQuery 已经是一个按设计可正确失效的 InheritedWidget;通过 provider 传递它,要么意味着在 provider 主体中使用 BuildContext,要么意味着手动同步会滞后一帧。应用/领域状态通过 Riverpod 管理;平台/无障碍状态通过 BuildContext 管理。参见 accessibility-as-code。
反模式
- 旧版 provider —
StateProvider/StateNotifierProvider/ChangeNotifierProvider(已移至flutter_riverpod/legacy.dart),或在 Riverpod 之外添加get_it/package:provider/ Bloc。两种 DI/状态机制严格来说不如一种。 - 在 provider 主体中编写业务逻辑。 provider 是一条细线;逻辑应放在 ViewModel、引擎和仓库中。
- 原地修改状态值并重新赋回同一个实例——基于值相等性判断的监听器不会发现变化。
- 在大型 widget 顶部使用
ref.watch,导致任何字段变化都会重建整个子树。请使用.select或叶节点ConsumerWidget。 - 在
setState()中管理应用/领域状态。setState/本地State仅用于短暂的 UI 状态(例如切换开关、动画进行中标记)。 - 在提交前乐观地重新发布状态——若在重新发布与提交之间发生崩溃,就会显示磁盘上从未存在过的事实。
- ViewModel 自行
new协作者、持有BuildContext、执行导航或显示 SnackBar。 应通过注入协作者来提供依赖并发布状态;由视图(或路由器重定向)做出响应。 - 按可变对象设置
family键,或对应用级单例使用autoDispose,或用keepAlive()撤销原本就不该添加的autoDispose。
完成标准
- 功能状态由一个基于不可变值的
Notifier/AsyncNotifier/StreamNotifier管理;widget 只持有短暂的 UI 状态。 - 状态为私有;每次变更都通过意图方法赋予新值;不进行原地修改。
- 派生值使用 getter/流投影,而不是存储字段。
- 异步状态使用一个
AsyncValue/状态并完整渲染;不使用零散的加载/错误布尔值。 - 每次持久化变更都通过仓库方法完成,并在返回前提交;不在提交前乐观地重新发布状态。
- 读取方式:显示内容使用
ref.watch/.select,回调中使用ref.read(p.notifier),副作用使用ref.listen;不进行宽泛的顶层 watch;不使用闭包捕获值。 - 通过 provider 注入协作者;替身接口在被覆盖前会抛出异常;在
main中为每种 flavor 接线一次实际实现;ViewModel 中不使用BuildContext/DateTime.now()。 - 每实体 provider 以稳定值作为
family键,并启用autoDispose;应用级单例使用普通 provider;在ref.onDispose中释放资源。 - 不导入
legacy.dart、get_it、package:provider或 Bloc(scripts/ban-legacy-providers.sh检查通过)。
相关技能
flutter-architecture— ViewModel、存储库和写入路径在按功能优先的 DAG 中的位置。error-handling-typed-results— 存储库方法返回的 Result/Failure 主干,用于替代抛出异常。async-safety— Future 丢弃漏洞、mounted防护,以及订阅/计时器的释放。persistence-drift—StreamProvider投影的 Drift 流,以及每次变更对应一个事务的写入路径。service-boundary-and-native— 用于每种副作用和原生通道的抛出边界 Provider 模式;它负责提供这些 ViewModel 读取的clockProvider(package:clock)边界,以替代DateTime.now()。widget-composition— 读取此状态的简洁ConsumerWidget视图。accessibility-as-code— 本技能共同负责的划分:通过 Riverpod 管理应用/领域状态,通过MediaQuery/BuildContext读取平台/辅助功能状态,绝不使用 Provider。value-objects-money-and-units— 接受Clock参数以实现确定性时间的值类型。app-startup-and-bootstrap— 配置ProviderScope覆盖项的组合根。
参考资料
Provider / ChangeNotifier 附录
对于官方 Flutter 指南所采用的技术栈(package:provider + ChangeNotifier),同样适用这十条规则;只有实现机制不同。
- ViewModel =
ChangeNotifier;新状态 = 所有字段设置完成后调用notifyListeners()(每次逻辑变更恰好调用一次,绝不在更新过程中或循环中调用)。 - 私有状态,不可变地公开。 字段使用
_private;公开只读 getter;返回List.unmodifiable(...)— 绝不暴露可变集合。 - 依赖注入 = 通过构造函数注入抽象,并在屏幕上方使用
Provider/ProxyProvider接入(页面弹出时自动释放)。不要使用get_it作为容器。 - 读取 = 显示内容使用
context.select/Consumer/Selector,回调中使用context.read。在didChangeDependencies或回调中读取,绝不在initState中读取。 - 防护 = 在
await之后调用notifyListeners()前检查_disposed标记;在dispose()中取消订阅并释放自行持有的控制器。
enum ViewStatus { idle, loading, ready, error }
class TaskListViewModel extends ChangeNotifier {
TaskListViewModel(this._repo); // injected abstraction
final TaskRepository _repo;
ViewStatus _status = ViewStatus.idle;
ViewStatus get status => _status;
List<Task> _tasks = const [];
List<Task> get tasks => List.unmodifiable(_tasks); // immutable view
int get openCount => _tasks.where((t) => !t.done).length; // derive, don't store
Future<void> load() async {
_status = ViewStatus.loading; notifyListeners();
try {
_tasks = await _repo.load();
_status = ViewStatus.ready;
} catch (_) {
_status = ViewStatus.error;
}
notifyListeners(); // exactly one, after all fields set
}
Future<void> complete(TaskId id) async {
await _repo.markComplete(id); // single write path
_tasks = [for (final t in _tasks) t.id == id ? t.copyWith(done: true) : t];
notifyListeners();
}
}