异步状态管理:加载、空态、错误态统一处理
系列:状态管理与架构篇 · 第 4 篇
列表页、详情页、表单提交,接口一多,页面里很容易出现同一种写法:isLoading、isEmpty、errorMessage 各管一摊,有的地方还要加个 hasLoaded 区分「真没数据」和「还没请求」。 Riverpod 里如果继续沿用这套,不是不能跑,而是状态组合一膨胀,UI 分支和单测都很难收尾。
这篇只聊一件事:异步结果在 Riverpod 里怎么收敛成一种形状,让加载、空列表、业务错误、网络异常在同一套逻辑里过完,而不是每个页面重新发明轮子。
1. 问题背景
典型场景:首屏进页面要拉列表,下拉刷新、上拉分页、某个 Tab 切换再请求一次。 产品还会要求:首次加载要有占位,刷新失败要在当前列表上提示,空列表要给运营位或引导文案。
工程里常见现象:
FutureProvider或手写的StateNotifier里,state是普通 model,成功时塞数据,失败时要么debugPrint要么弹 Toast,页面上还是那个旧数据,状态里看不出这次请求是失败还是陈旧。- 空态和错误态混用:接口返回
[]和业务错误都走「列表为空」,用户没法区分是「真的没有」还是「挂了」。 - 多个请求并行:
refresh和loadMore同时改同一份 state,最后一笔写入赢了,早返回的请求把新数据覆盖掉。
这些问题的根源不是 Riverpod 难用,而是没有把「异步的一次尝试」建模清楚。
2. 原因分析
Flutter 官方在 AsyncValue 里已经把异步结果的形态说死了:loading / data / error,再配上 AsyncValue.guard 把 try/catch 收成统一类型。 很多项目没用起来,通常有两类原因。
一是历史包袱:StateNotifier<HomeState> 这类 state 一开始就按「成功后的界面」去设计,HomeState 里没有地方放「这次请求是否进行中」,于是一切补丁都往 Widget 或局部变量里塞。
二是把「列表数据」和「分页游标」绑死在同一个可变对象上(例如在 model 上直接 addAll、改 pageNum)。 可变结构 + 多入口更新 时,很难证明任意时刻 UI 和内存是一致的;测试也只能依赖跑真接口或整段 mock。
所以从原理上讲,要统一三态,关键是:
- 页面上读的应该是 「当前这一轮异步结果的投影」,而不是「永远在变的业务对象草稿」。
- 加载中是否要保留上一版成功数据(skeleton / stale-while-revalidate)要明确策略,不能每个页面即兴发挥。
3. 解决方案
Riverpod 2 里用 AsyncNotifier(或代码生成的 @riverpod class X extends _$X)管「单一数据源」比较合适:build() 负责冷启动加载,refresh 用 AsyncValue.guard 包一层,内部照旧调 repository。
3.1 列表:AsyncValue + 保留旧数据
分页列表常见需求:刷新转圈时别把老列表闪没。 做法可以是 state 里同时持有 AsyncValue<List<Item>> 和「当前用于展示的列表」:loading 时用上一份 data,只有 error 时决定是清空还是保留(按产品来,但要写进 Notifier 注释里,避免半年后有人当 bug 改回去)。
更省事的一种是:用 AsyncValue 只承载最近一次完整请求的结果,分页增量放在不可变结构里,例如每次 loadMore 成功就 state = state.copyWith(items: [...old, ...new], page: nextPage),避免对同一个 List 实例原地 addAll,这样快照清晰,也方便对比测试。
3.2 空态 vs 错误态
AsyncData且data.isEmpty:当作业务空态,展示空页面组件。AsyncError:展示错误占位 + 重试。 不要让错误走 Toast 就结束,至少让ref.watch的订阅方还能画出一块错误 UI;Toast 可以额外加,不能替代 state。
若接口把「空」和「错」都落成 HTTP 200 + 空 body,只能在 repository 层做一次区分(例如业务 code 非 0 转成 throw),别在 Widget 里猜。
3.3 并发:取消或忽略陈旧结果
refresh 连点或 Tab 快速切换时,只保留最后一次请求的结果。 可以用 CancelToken、也可以 int 自增 generation,resolve 回来时比对,不匹配就丢弃。 这一点和用不用 Riverpod 无关,但 Notifer 是唯一合适做这件事的地方,写在页面里一定会漏。
4. 关键代码
下面片段是示意:用生成式 Provider(riverpod_generator)时,类名、文件名按你们规范来即可,逻辑不变。
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'room_feed_notifier.g.dart';
@riverpod
class RoomFeed extends _$RoomFeed {
@override
Future<List<RoomSummary>> build() async {
return _fetchPage(1);
}
Future<void> refresh() async {
state = const AsyncLoading();
state = await AsyncValue.guard(() => _fetchPage(1));
}
Future<void> loadMore() async {
final previous = state.valueOrNull ?? const <RoomSummary>[];
final nextPage = (previous.length ~/ pageSize) + 1;
state = await AsyncValue.guard(() async {
final more = await _fetchPage(nextPage);
if (more.isEmpty) return previous;
return [...previous, ...more];
});
}
Future<List<RoomSummary>> _fetchPage(int page) async {
final repo = ref.read(roomRepositoryProvider);
return repo.list(page: page, size: pageSize);
}
}
页面侧尽量只保留一种写法:
final asyncRooms = ref.watch(roomFeedProvider);
return asyncRooms.when(
loading: () => const RoomListSkeleton(),
error: (e, st) => RoomErrorView(
message: e.toString(),
onRetry: () => ref.read(roomFeedProvider.notifier).refresh(),
),
data: (list) => list.isEmpty
? const RoomEmptyView()
: RoomListView(items: list),
);
如果希望和「上一版数据」共存,可以把 when 换成 whenOrNull 或自定义 extension,在 loading 分支里读 asyncRooms.valueOrPrevious,这属于小优化,先在团队里统一一种别混用即可。
5. 效果验证
操作上可以直接看两件事:一是进列表页、断网、恢复网络点重试,同一套 when 分支是否都能走到且不需要手动 setState;二是在测试里对 Notifier 打桩 repository,refresh 抛异常时 state 是否为 AsyncError,Empty 时是否为 AsyncData([])。 能稳定断言这两类,后面加 Tab、加筛选不容易把边界改丢。
6. 可复用结论
- 异步列表优先用
AsyncValue表达一次请求的语义,空列表和异常不要混在同一个「长度为零」里糊弄过去。 - 分页尽量不可变拼接,少在共用的 model 上原地改
pageNum、原地addAll,并发场景会轻松很多。 - 并发只认最后一次结果:generation 或取消写在 Notifier 里,别散落在 UI 事件里。
- UI 只订阅一种 state 形状,
loading / error / data(empty)分支齐全,比一堆 bool 更经得起迭代。