状态管理与架构篇-异步状态管理:加载、空态、错误态统一处理

4 阅读4分钟

异步状态管理:加载、空态、错误态统一处理

系列:状态管理与架构篇 · 第 4 篇

列表页、详情页、表单提交,接口一多,页面里很容易出现同一种写法:isLoadingisEmptyerrorMessage 各管一摊,有的地方还要加个 hasLoaded 区分「真没数据」和「还没请求」。 Riverpod 里如果继续沿用这套,不是不能跑,而是状态组合一膨胀,UI 分支和单测都很难收尾

这篇只聊一件事:异步结果在 Riverpod 里怎么收敛成一种形状,让加载、空列表、业务错误、网络异常在同一套逻辑里过完,而不是每个页面重新发明轮子。


1. 问题背景

典型场景:首屏进页面要拉列表,下拉刷新、上拉分页、某个 Tab 切换再请求一次。 产品还会要求:首次加载要有占位,刷新失败要在当前列表上提示,空列表要给运营位或引导文案。

工程里常见现象:

  • FutureProvider 或手写的 StateNotifier 里,state 是普通 model,成功时塞数据,失败时要么 debugPrint 要么弹 Toast,页面上还是那个旧数据,状态里看不出这次请求是失败还是陈旧
  • 空态和错误态混用:接口返回 [] 和业务错误都走「列表为空」,用户没法区分是「真的没有」还是「挂了」。
  • 多个请求并行:refreshloadMore 同时改同一份 state,最后一笔写入赢了,早返回的请求把新数据覆盖掉

这些问题的根源不是 Riverpod 难用,而是没有把「异步的一次尝试」建模清楚


2. 原因分析

Flutter 官方在 AsyncValue 里已经把异步结果的形态说死了:loading / data / error,再配上 AsyncValue.guardtry/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() 负责冷启动加载,refreshAsyncValue.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 错误态

  • AsyncDatadata.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 更经得起迭代。