228 lines
12 KiB
Markdown
228 lines
12 KiB
Markdown
# 03. 状态管理方案
|
||||
|
|
|
|||
|
|
## 决策
|
|||
|
|
|
|||
|
|
使用 **[Riverpod](https://riverpod.dev/)**(`flutter_riverpod` + `riverpod_generator` 代码生成),不使用 Bloc/Provider/GetX。
|
|||
|
|
|
|||
|
|
版本基线:`flutter_riverpod: ^3.4.2`(当前 stable,2026-08 快照,需在实际开工时用 `flutter pub outdated` 复核)。
|
|||
|
|
|
|||
|
|
## 依赖
|
|||
|
|
|
|||
|
|
```yaml
|
|||
|
|
dependencies:
|
|||
|
|
flutter_riverpod: ^3.4.2
|
|||
|
|
riverpod_annotation: ^3.4.2
|
|||
|
|
|
|||
|
|
dev_dependencies:
|
|||
|
|
riverpod_generator: ^3.4.2
|
|||
|
|
build_runner: ^2.15.2
|
|||
|
|
custom_lint: ^0.8.1
|
|||
|
|
riverpod_lint: ^3.1.8
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> `custom_lint` 的版本必须是 `^0.8.x`:`riverpod_lint 3.x` 依赖的是 `custom_lint 0.8.x`,写成 `^0.6.0` 会直接 `pub get` 解析失败。`custom_lint` 的版本约束比较严,每次升 `riverpod_lint` 都要顺带核一下它要求的 `custom_lint` 版本。
|
|||
|
|
|
|||
|
|
## 使用规则
|
|||
|
|
|
|||
|
|
- 所有跨 widget 共享的状态、依赖注入,统一通过 Riverpod provider 暴露,不额外引入 `get_it`/`provider` 等其他 DI 方案。
|
|||
|
|
- 优先使用 `riverpod_generator` 的注解写法(`@riverpod`),不手写裸 `Provider`/`StateNotifierProvider` 模板代码。
|
|||
|
|
- `Notifier`/`AsyncNotifier` 用于承载可变的 feature 状态;无状态的计算/依赖注入用普通 `Provider`。
|
|||
|
|
- `domain`/`data` 层的 repository 实现通过 provider 注入到 `presentation` 层,`presentation` 只依赖 provider 暴露的接口类型(见 [02-layering.md](./02-layering.md))。
|
|||
|
|
- 每个 `feature_*` 包各自维护自己的 provider,不跨包直接引用另一个 feature 的 provider(同 [01-project-structure.md](./01-project-structure.md) 的 feature 隔离规则);跨 feature 共享的 provider 定义在对应的 `core_*` 包里。
|
|||
|
|
|
|||
|
|
## Riverpod 3 的自动重试:全局关掉
|
|||
|
|
|
|||
|
|
Riverpod 3 起,**provider 抛异常后会自动重试**,默认策略是指数退避(200ms 起,翻倍到 6.4s 封顶)。这个默认行为在本项目里弊大于利,有三个具体问题:
|
|||
|
|
|
|||
|
|
1. **和 401 刷新打架**:access token 过期时,`core_network` 的 `AuthInterceptor` 已经在做刷新 + 重放(见 [05-networking.md](./05-networking.md))。provider 层再自动重试一轮,等于同一个失败被两套机制各重试一次,日志里会出现莫名其妙的重复请求。更糟的是后端 refresh token 是**一次性轮换**的(见 [backend/04-security-auth.md](../../conti-backend/docs/04-security-auth.md)),并发刷新会被判定为重放攻击,导致该用户所有 refresh token 被撤销、被强制登出。
|
|||
|
|
2. **错误提示会闪**:UI 拿到 `AsyncError` 弹了错误提示,200ms 后自动重试又切回 `AsyncLoading`,用户看到的是提示一闪而过。
|
|||
|
|
3. **测试 flaky**:单测里断言 `AsyncError` 时,后台还挂着一个待重试的定时器,测试跑完 container 被 dispose 会报 pending timer,或者断言时机不对直接读到 `AsyncLoading`。
|
|||
|
|
|
|||
|
|
**决策**:在 `ProviderScope` 上全局关闭 retry,需要重试的地方显式打开。
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// app/lib/main.dart
|
|||
|
|
void main() {
|
|||
|
|
runApp(
|
|||
|
|
ProviderScope(
|
|||
|
|
// 全局关掉自动重试:返回 null 表示"不重试"
|
|||
|
|
retry: (retryCount, error) => null,
|
|||
|
|
child: const ContiApp(),
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
单个 provider 确实需要重试时(比如首页 tile 这种失败了自己悄悄重试一次比弹错更好的场景),在该 provider 上单独开:
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
@Riverpod(retry: _homeTileRetry)
|
|||
|
|
Future<List<Tile>> homeTiles(Ref ref) async { /* ... */ }
|
|||
|
|
|
|||
|
|
// 只重试一次,且只对网络类错误重试;业务错误(BusinessException)重试没有意义
|
|||
|
|
Duration? _homeTileRetry(int retryCount, Object error) {
|
|||
|
|
if (retryCount >= 1) return null;
|
|||
|
|
if (error is! NetworkException) return null;
|
|||
|
|
return const Duration(milliseconds: 500);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
规则:**重试只对"重试一次可能就好了"的错误有意义**——超时、连接失败。业务错误码(后端返回 `code != 0`)、401、参数错误重试多少次都是同样的结果,只是在浪费用户的时间和流量。
|
|||
|
|
|
|||
|
|
## 缓存生命周期:默认 autoDispose,长驻要写理由
|
|||
|
|
|
|||
|
|
`@riverpod` 注解生成的 provider **默认是 autoDispose 的**(没有 listener 时自动销毁并释放状态)。这个默认值保持不变,原因是门店切换的场景下(见下一节)"用完就销毁"能省掉一大堆手动清理。
|
|||
|
|
|
|||
|
|
要改成长驻的写 `@Riverpod(keepAlive: true)`,并且**必须在注释里写清为什么**。目前认可的长驻场景只有三类:
|
|||
|
|
|
|||
|
|
- 全局单例依赖(`Dio` 实例、`Database` 实例、`SharedPreferences`)——本来就该活到进程结束。
|
|||
|
|
- 全局会话状态(登录态、当前门店上下文,见 [11-store-context-and-session.md](./11-store-context-and-session.md))。
|
|||
|
|
- 明确要跨页面保留的数据(比如工作台数据,用户从子页面返回时不希望再 loading 一次)。
|
|||
|
|
|
|||
|
|
除此之外一律 autoDispose。列表页数据尤其不要 keepAlive——门店切了、权限变了,长驻的旧数据会直接显示成错的。
|
|||
|
|
|
|||
|
|
需要"短时间内返回不重新加载、但也不永久长驻"的,用 `ref.keepAlive()` + 定时器的写法,别直接 `keepAlive: true`:
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
@riverpod
|
|||
|
|
Future<List<Store>> storeList(Ref ref) async {
|
|||
|
|
final link = ref.keepAlive();
|
|||
|
|
final timer = Timer(const Duration(minutes: 5), link.close); // 5 分钟后允许被回收
|
|||
|
|
ref.onDispose(timer.cancel);
|
|||
|
|
return ref.watch(storeRepositoryProvider).fetchStores();
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 门店切换 / 登出时的批量失效
|
|||
|
|
|
|||
|
|
PRD §11.4 要求切换门店后购物车、待办、预警、订单上下文全部跟着切。落到 Riverpod 上,**不能靠每个 feature 自己去监听门店变化**——总会漏掉一个,而漏掉的表现是"用户在 A 门店看到 B 门店的数据",属于严重问题。
|
|||
|
|
|
|||
|
|
统一做法:所有与门店相关的 provider 都 `ref.watch(currentStoreIdProvider)`,让 Riverpod 的依赖图自己完成级联失效。
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
@riverpod
|
|||
|
|
Future<List<PurchaseOrder>> purchaseOrders(Ref ref) async {
|
|||
|
|
// watch 而不是 read:门店一变,这个 provider 自动重建
|
|||
|
|
final storeId = ref.watch(currentStoreIdProvider);
|
|||
|
|
return ref.watch(purchaseRepositoryProvider).fetchOrders(storeId);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
这条规则要写进 code review checklist:**任何请求带 storeId 的 provider,storeId 必须来自 `ref.watch(currentStoreIdProvider)`,不允许从别处传参或 `ref.read`**。`ref.read` 拿到的是快照,门店变了不会触发重建,这正是最容易漏的地方。
|
|||
|
|
|
|||
|
|
依赖图管不到的部分(Drift 本地缓存、H5 会话、导航栈)需要显式清理,完整清单见 [11-store-context-and-session.md](./11-store-context-and-session.md)。
|
|||
|
|
|
|||
|
|
## 测试
|
|||
|
|
|
|||
|
|
- `Notifier`/`AsyncNotifier` 的单元测试用 **`ProviderContainer.test()`** 直接实例化,不依赖 widget tree——这是 Riverpod 3 新增的测试专用构造,自带 `addTearDown(container.dispose)`,不需要再手写。
|
|||
|
|
- Widget 测试中用 `ProviderScope(overrides: [...])` 注入 mock 依赖。
|
|||
|
|
- 测试里如果某个 provider 单独开了 retry,断言错误状态前记得覆盖掉,否则会遇到 pending timer(详见 [09-testing.md](./09-testing.md))。
|
|||
|
|
|
|||
|
|
## 附录:Riverpod 是什么,日常怎么用
|
|||
|
|
|
|||
|
|
给还没接触过 Riverpod 的同学看的入门说明。
|
|||
|
|
|
|||
|
|
### 要解决的问题
|
|||
|
|
|
|||
|
|
Flutter 官方最早推荐的状态管理方式是 [`InheritedWidget`](https://docs.flutter.dev/data-and-backend/state-mgmt/inherited-widget)——通过 widget 树往下传数据。写法繁琐,社区后来做了一层封装叫 [`Provider`](https://pub.dev/packages/provider),但 `Provider` 本质还是绑定在 widget 树上:拿依赖必须要有 `BuildContext`,写错了会在运行时才报错(比如 `ProviderNotFoundException`),而且没法很方便地在 widget 树之外(比如后台任务、单元测试)读取状态。
|
|||
|
|
|
|||
|
|
**Riverpod** 是 `Provider` 的原作者 Remi Rousselet 重新设计的下一代方案:把状态容器从 widget 树里剥离出来,变成一套独立的依赖图,`BuildContext` 不再是拿依赖的必要条件,错误也从运行时提前到**编译期**发现(比如 provider 类型不匹配会直接编译报错,而不是运行时崩溃)。
|
|||
|
|
|
|||
|
|
### 核心概念
|
|||
|
|
|
|||
|
|
1. **`Provider`**:声明一个"如何创建某个值"的配方,值可以是同步的、异步的(`Future`/`Stream`)、也可以是可变的状态。
|
|||
|
|
2. **`Notifier` / `AsyncNotifier`**:承载**可变**状态的载体,通过方法修改状态(类似过去 `StateNotifier` 的角色,3.x 里统一成 `Notifier`)。
|
|||
|
|
3. **`ref.watch(xxxProvider)`**:在 widget 或另一个 provider 里订阅某个 provider,值变化时自动触发重建/重新计算。
|
|||
|
|
4. **`ref.read(xxxProvider)`**:只读取一次当前值,不订阅变化(一般用在按钮点击等一次性事件回调里)。
|
|||
|
|
5. **`@riverpod` 注解 + 代码生成**:手写 `Provider`/`NotifierProvider` 样板代码容易出错(尤其是泛型),项目统一用 `riverpod_generator` 的注解写法,跑 `build_runner` 自动生成对应的 provider。
|
|||
|
|
|
|||
|
|
### 使用示例(`feature_store`:拉取附近门店列表)
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// presentation/store_list_notifier.dart
|
|||
|
|
part 'store_list_notifier.g.dart';
|
|||
|
|
|
|||
|
|
@riverpod
|
|||
|
|
class StoreListNotifier extends _$StoreListNotifier {
|
|||
|
|
@override
|
|||
|
|
Future<List<Store>> build() async {
|
|||
|
|
final repository = ref.watch(storeRepositoryProvider);
|
|||
|
|
final position = ref.watch(currentPositionProvider); // 定位也是一个 provider,不是 notifier 的字段
|
|||
|
|
return repository.fetchNearbyStores(position.lat, position.lng);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Future<void> refresh() async {
|
|||
|
|
// 让 Riverpod 重跑 build(),而不是自己去调 build()
|
|||
|
|
ref.invalidateSelf();
|
|||
|
|
await future; // 等这一轮重建完成,方便下拉刷新的 RefreshIndicator 收起动画
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> **不要写成 `state = await AsyncValue.guard(() => build())`。** `build()` 里有 `ref.watch`,只有 Riverpod 自己在重建流程中调用它才能正确重建订阅关系;手动调用会让旧的订阅残留、新的订阅重复注册。需要重跑 `build()` 就用 `ref.invalidateSelf()`。
|
|||
|
|
>
|
|||
|
|
> 只想改一部分状态、不想重跑整个 `build()` 时,才用 `AsyncValue.guard`,而且里面调的是 repository 而不是 `build()`:
|
|||
|
|
>
|
|||
|
|
> ```dart
|
|||
|
|
> Future<void> loadMore() async {
|
|||
|
|
> final current = state.valueOrNull ?? const [];
|
|||
|
|
> state = await AsyncValue.guard(() async {
|
|||
|
|
> final next = await ref.read(storeRepositoryProvider).fetchNearbyStores(/* ... */);
|
|||
|
|
> return [...current, ...next];
|
|||
|
|
> });
|
|||
|
|
> }
|
|||
|
|
> ```
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// presentation/store_list_page.dart
|
|||
|
|
class StoreListPage extends ConsumerWidget {
|
|||
|
|
const StoreListPage({super.key});
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|||
|
|
final storesAsync = ref.watch(storeListNotifierProvider);
|
|||
|
|
|
|||
|
|
return storesAsync.when(
|
|||
|
|
data: (stores) => ListView.builder(
|
|||
|
|
itemCount: stores.length,
|
|||
|
|
itemBuilder: (_, i) => ListTile(title: Text(stores[i].name)),
|
|||
|
|
),
|
|||
|
|
loading: () => const CircularProgressIndicator(),
|
|||
|
|
error: (err, _) => Text('加载失败: $err'),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
`storeRepositoryProvider` 定义在 `data` 层(见 [02-layering.md](./02-layering.md) 的跳过 domain 层示例),`StoreListNotifier` 通过 `ref.watch` 拿到接口类型,不关心具体实现——这就是 Riverpod 承担依赖注入职责的地方,不需要额外的 `get_it`。
|
|||
|
|
|
|||
|
|
### 测试示例
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
test('刷新后状态应更新为最新门店列表', () async {
|
|||
|
|
// ProviderContainer.test() 是 Riverpod 3 的测试专用构造,
|
|||
|
|
// 自动注册 tearDown 做 dispose,不用再写 addTearDown(container.dispose)
|
|||
|
|
final container = ProviderContainer.test(
|
|||
|
|
overrides: [
|
|||
|
|
storeRepositoryProvider.overrideWithValue(FakeStoreRepository()),
|
|||
|
|
],
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
final stores = await container.read(storeListNotifierProvider.future);
|
|||
|
|
expect(stores, isNotEmpty);
|
|||
|
|
});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
`ProviderContainer` 让整个依赖图脱离 widget 树单独运行,`overrides` 直接替换掉真实的 repository,这也是"编译期安全 + 好测试"这条评价的具体体现。
|
|||
|
|
|
|||
|
|
## 参考链接
|
|||
|
|
|
|||
|
|
- [Riverpod 官方文档](https://riverpod.dev/)
|
|||
|
|
- [Riverpod 3 迁移指南](https://riverpod.dev/docs/whats_new)
|
|||
|
|
- [Riverpod: Automatic retry](https://riverpod.dev/docs/whats_new#automatic-retry)
|
|||
|
|
- [riverpod_generator | Dart package](https://pub.dev/packages/riverpod_generator)
|
|||
|
|
- [flutter_riverpod | Dart package](https://pub.dev/packages/flutter_riverpod)
|
|||
|
|
- [riverpod_lint | Dart package](https://pub.dev/packages/riverpod_lint)
|
|||
|
|
- [InheritedWidget(Flutter 官方文档)](https://docs.flutter.dev/data-and-backend/state-mgmt/inherited-widget)
|
|||
|
|
- [provider | Dart package](https://pub.dev/packages/provider)
|