Files
conti-retail-app/docs/03-state-management.md
T
2026-08-17 15:29:55 +08:00

228 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 的 providerstoreId 必须来自 `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)
- [InheritedWidgetFlutter 官方文档)](https://docs.flutter.dev/data-and-backend/state-mgmt/inherited-widget)
- [provider | Dart package](https://pub.dev/packages/provider)