mcpField
S
state-management-riverpod

state-management-riverpod

github.com/zakariaf开发63177 次安装5 周前更新
代码仓库

关于 state-management-riverpod

面向 Flutter 开发的 Riverpod 状态管理技能,提供状态建模、依赖注入和生命周期处理规范。强调不可变状态、单一仓库写入路径及 Riverpod 3.x 用法,并附有检查脚本。

主要功能

  1. 01以不可变状态组织功能
  2. 02通过仓库统一持久化写入
  3. 03使用 Provider 注入依赖
  4. 04规范读取、副作用与生命周期
  5. 05提供 Riverpod 3.x 测试参考
  6. 06脚本检查旧式 Provider 用法

适用场景

  1. 01为 Flutter 功能设计状态与 ViewModel
  2. 02让持久化变更在提交后更新界面
  3. 03为实体或会话管理自动释放状态
  4. 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.dart
task_list_feature.dart
references/
ownership-and-lifecycle.md
reads-and-side-effects.md
riverpod3-api-and-testing.md
scripts/
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。

不可妥协的规则

无论使用哪种状态库,以下规则都适用。

  1. 每个功能使用一个 ViewModel 管理一个不可变状态值。 状态必须是具有值相等性的值类型(freezed/sealed + copyWith),绝不能是可变字段集合。跨功能/共享状态应放在仓库中,而不是 ViewModel 里——两个 ViewModel 管理同一事实,就会产生两个彼此不一致的事实。
  2. 状态保持私有;只能通过意图方法修改。 Widget 不得直接访问状态并改动字段。视图调用 notifier.rename(id, name),绝不直接设置状态。这是状态变更的唯一入口,因此每次变更都能在一个地方进行审查。
  3. 每次状态转换都必须赋予一个新值。 基于值相等性的监听器会按值进行比较——原地修改实例后又将同一引用重新赋值,会导致变更被忽略,UI 也会悄然过期。始终使用 copyWith 创建新实例。
  4. 推导,不存储。 可从现有状态计算得出的值(总数、筛选后的数量、连续记录天数)应使用 getter 或流投影,绝不能作为第二个字段存储——存储推导值会产生第二个事实来源,并逐渐与实际状态不一致。
  5. 唯一写入路径。 每次持久化变更都必须通过仓库的一个方法完成;该方法应以事务方式持久化,并且仅在提交完成后返回。UI 更新应由流重新发出数据触发,而不是通过乐观地重新发布数据触发。先持久化再发布,可从结构上保障崩溃安全,而非依赖纪律约束。
  6. 单向数据流。 意图 → ViewModel → 仓库/服务 → 新状态 → 视图。视图绝不修改读取到的数据,也绝不绕过链路向上短路。数据向下流动;事件向上流动。
  7. 依赖注入的抽象,绝不自行构造。 ViewModel 接收其仓库/服务/时钟;绝不通过 new 创建实际协作者,也绝不持有 BuildContext。这样才能使用伪对象进行测试,并按不同风味替换实现。
  8. 将异步状态建模为显式状态,而不是零散标志。 使用一个 AsyncValue(或状态枚举 + 数据 + 错误)并穷尽所有情况进行渲染——绝不散布 isLoading/hasError 布尔值,以免编码出只设置了一部分的无效状态。
  9. 删除时级联清理引用。 删除实体时必须清除所有对它的引用(分配、外键、选择状态)——仍指向已删除父项的项目会埋下崩溃隐患。绝不静默丢弃孤儿;应明确地重新归属或取消分配。
  10. 状态逻辑中不得调用 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();
  }
}

评论

支持 Markdown