Flutter Android 刷新竞态修复:取消旧请求与页面状态对齐

列表页最容易被低估的问题,不是接口慢,而是刷新竞态。用户下拉一次、切一次 Tab、返回再进入页面,或者弱网下连点两次重试,旧请求和新请求就可能同时回写状态:加载圈提前消失、旧数据覆盖新结果、分页游标错乱,最典型时甚至会在页面已经退出后再触发一次 setState()。这类问题在线上不一定每次都复现,所以很多团队先补 loading 标记,最后却把状态机越补越乱。

这篇文章只处理一个常青问题:Flutter Android 项目里,怎样把“刷新中的旧请求回写新页面”收口成一套可验证的工程方案。重点不是换状态管理库,而是把请求生命周期、页面生命周期和日志验证对齐,让控制器永远只认“最新一次有效刷新”。如果你的项目还停留在页面里直接 await api.fetch() 的阶段,这篇文章正好可以作为从可用到稳定的下一步。

适用场景、环境与边界

适用场景很明确:你已经有 Flutter 列表页、详情页返回刷新、下拉刷新、分页加载或搜索建议接口,而且线上偶发出现旧结果覆盖新结果、重复 toast、滚动位置跳回、页面销毁后仍尝试更新 UI。本文按 2026 年 8 月查阅的官方资料整理,示例以 Flutter 官方文档当前对应的 3.44.7 文档集、Dart 3.13 文档、Android 14 模拟器和常见 ChangeNotifier + Repository 分层为背景;如果你使用 Riverpod、Bloc 或 Cubit,核心收口思路不变。

边界也要先说清。第一,取消 Future 句柄不等于服务端请求已经停止,真正的网络取消还要配合 dioCancelToken、可关闭的 http.Client,或你自己的仓库层中断逻辑。第二,如果问题来自 MethodChannel 双向回调、图片解码卡顿或内存泄漏,应该分别处理;内存持续增长时,先看站内文章Flutter Android 内存泄漏排查:用 DevTools diff snapshots 对比控制器与订阅回收。第三,本文不讨论离线同步冲突,那是另一套数据一致性问题。

先把故障归类成刷新竞态,而不是网络慢

刷新竞态最怕误判成“弱网导致体验差”。你可以先看三类信号。第一类是顺序问题:日志里先打印 req=18 done,后打印 req=17 done,但页面最后显示的是第 17 次结果。第二类是生命周期问题:用户返回上一页以后,控制台偶发 setState() called after dispose(),或者 Snackbar 在已经切走的页面上弹出。第三类是状态问题:刷新与分页共用一个 loading,导致刷新完成时把分页的菊花一起关掉,下一页游标却还停在旧值。

建议先把日志字段固定下来,再动代码。至少要在每次刷新打印 requestIdpagephaseroutedropped=true/false。例如:

feed_refresh start requestId=18 page=1 phase=refresh route=/feed
feed_refresh done requestId=17 page=1 dropped=true route=/feed
feed_refresh done requestId=18 page=1 dropped=false route=/feed

有了这组日志,你就能分清到底是接口超时、用户重复触发,还是旧请求在新页面里“死而复生”。没有日志先改状态管理,最后很容易只修掉表象。

步骤一:在控制器里只认最新一次请求

第一步不要先在 Widget 里堆 if (!mounted),而是先把请求入口收成一个“只认最新请求”的控制器。核心有两件事:旧请求要被取消或标记作废,新请求完成前不能让旧结果回写状态。

import 'package:async/async.dart';
import 'package:flutter/foundation.dart';

class FeedController extends ChangeNotifier {
  FeedController(this._repository);

  final FeedRepository _repository;
  CancelableOperation<List<Post>>? _activeRefresh;
  int _requestId = 0;

  List<Post> items = const [];
  FeedPhase phase = FeedPhase.idle;
  String? errorMessage;

  Future<void> refresh() async {
    final requestId = ++_requestId;
    await _activeRefresh?.cancel();

    phase = FeedPhase.refreshing;
    errorMessage = null;
    notifyListeners();
    debugPrint('feed_refresh start requestId=$requestId phase=$phase');

    _activeRefresh = CancelableOperation.fromFuture(
      _repository.fetchFirstPage(),
      onCancel: () => debugPrint('feed_refresh cancel requestId=$requestId'),
    );

    try {
      final result = await _activeRefresh!.value;
      if (requestId != _requestId) {
        debugPrint('feed_refresh drop requestId=$requestId');
        return;
      }
      items = result;
      phase = FeedPhase.idle;
    } catch (error, stackTrace) {
      if (requestId != _requestId) return;
      phase = FeedPhase.failure;
      errorMessage = '$error';
      FlutterError.reportError(
        FlutterErrorDetails(exception: error, stack: stackTrace),
      );
    } finally {
      if (requestId == _requestId) {
        notifyListeners();
      }
    }
  }

  @override
  void dispose() {
    _activeRefresh?.cancel();
    super.dispose();
  }
}

enum FeedPhase { idle, refreshing, appending, failure }

这段代码解决的是“旧请求先返回怎么办”,而不是“所有异步都自动安全”。如果仓库层本身还能分页、搜索或详情回填,就继续沿用同一个 requestId 规则:只有当前代次能提交结果。这样做的收益是,状态一致性不再依赖用户是否刚好停留在页面上。

步骤二:在 Widget 层处理异步间隙和订阅回收

控制器只认最新请求之后,Widget 层还要处理“页面已经不在了,但回调还在跑”的问题。Flutter 官方对 setState 的说明很明确:比起事后检查 mounted,更好的方式是尽早取消会触发 setState 的工作。但只要出现异步间隙,页面回调前仍要补一次 mounted 检查,尤其是刷新完成后要弹 Snackbar、跳转或滚动到顶部的场景。

class FeedPageState extends State<FeedPage> {
  late final ScrollController _scrollController;
  StreamSubscription<ConnectivityResult>? _networkSubscription;

  @override
  void initState() {
    super.initState();
    _scrollController = ScrollController();
    _networkSubscription = connectivity.onConnectivityChanged.listen((_) {
      context.read<FeedController>().refresh();
    });
  }

  Future<void> _handleRefresh() async {
    await context.read<FeedController>().refresh();
    if (!context.mounted) return;
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(content: Text('列表已更新')),
    );
  }

  @override
  void dispose() {
    _networkSubscription?.cancel();
    _scrollController.dispose();
    super.dispose();
  }
}

这里真正要收口的不只是 StreamSubscriptionTimerAnimationControllerTextEditingController、原生监听器和 WebSocket 回调,都要在 dispose() 里对称回收。否则即使你在刷新函数里加了 mounted,旧订阅仍可能从别的入口把状态再推回来。

步骤三:把刷新、分页和重试拆成可验证状态机

很多线上 bug 不是“没取消请求”,而是刷新、分页、重试共用了一套布尔值。更稳的做法,是至少拆出 idlerefreshingappendingfailure 四种阶段,并规定每个阶段允许什么动作。比如 refreshing 时允许再次触发刷新,但必须先废弃旧请求;appending 时不允许并发拉第二页;failure 只保留重试,不顺手清空旧列表。

如果你用 Repository 分层,建议把“请求是否应该落库”也纳入状态机。例如:搜索关键字已经从 flutter 变成 dart 时,旧关键字返回的数据即使成功,也只能记日志,不能覆盖当前列表。日志层最好固定两项指标:stale_request_droppedduplicate_item_count。前者验证你的取消策略是否真的生效,后者验证分页结果有没有被旧响应重新拼接一遍。

目录结构建议:把取消责任放到固定位置

很多团队第一次修好列表页,第二个页面还是会复制一套临时判断,根因通常不是代码不会写,而是取消责任没有固定归属。更稳的目录做法,是让页面只发动作,控制器只维护状态机,Repository 负责配置与命令级网络调用,数据源负责把取消令牌真正传给客户端。一个足够清晰的目录可以像这样:

lib/
  features/feed/presentation/feed_page.dart
  features/feed/presentation/feed_controller.dart
  features/feed/data/feed_repository.dart
  features/feed/data/feed_remote_data_source.dart
  shared/logging/app_logger.dart

这样排查时你能很快回答三个问题:哪一层生成 requestId,哪一层取消旧请求,哪一层决定旧结果只记日志不落状态。只要这三个职责还混在 Widget 里,后面继续接入搜索、筛选和分页时,刷新竞态大概率还会再次出现。

验证方式:测试、日志和指标三层过关

只靠手点很难证明竞态真的修完。建议至少做三层验证。第一层是静态与单元测试,确保控制器在连续两次 refresh() 时只保留最新结果。第二层是集成测试,模拟用户在弱网下快速下拉、切页、返回再进入。第三层是日志和指标验收,确认线上不会再出现旧请求回写。

flutter analyze
flutter test test/feed/feed_controller_test.dart
flutter test integration_test/feed_refresh_race_test.dart -d emulator-5554
adb logcat | findstr feed_refresh

测试里重点断言三件事:最后一次请求返回后列表内容正确、旧请求完成时不会再次 notifyListeners()、页面销毁后不会再弹成功提示。上线后再看两组指标:stale_request_dropped 是否在快速切换时正常增加,以及 duplicate_item_count 是否保持为 0。若你还没有系统化测试基线,可以把这篇文章和站内文章Flutter 测试配置怎么收口:单元测试、Widget 测试与 Android 集成测试基线一起落地,避免修完这次竞态、下次又在别的列表页复发。

失败边界、避坑点与复盘清单

失败边界要提前说清。第一,取消本地等待不等于服务端事务回滚,所以写操作接口仍要做幂等;例如点赞、下单、上传这类动作,前端只能防重复回写,不能假设后端没有收到旧请求。第二,若卡顿来自大 JSON 解析,请优先把解析挪到 Isolate,而不是把所有责任都推给刷新状态机。第三,如果列表依赖本地缓存和远端合并,就要额外验证缓存版本号,否则你只是把“网络旧数据覆盖新数据”换成了“缓存旧数据覆盖新数据”。

最后给一个复盘清单,方便你在真实项目里逐项确认:

  1. 入口是否只保留一个当前有效 requestId
  2. dispose() 中是否对称回收了订阅、控制器和定时器。
  3. 刷新、分页、重试是否使用了可区分的阶段,而不是一个 loading
  4. 测试里是否覆盖“旧请求后返回”“页面先销毁再回调”“快速重复下拉”三种场景。
  5. 日志里是否能直接看出哪次响应被丢弃,以及丢弃是否符合预期。

把这五项都落地以后,刷新竞态就不再是只能靠线上运气复现的问题,而会变成一个可测试、可监控、可持续复用的 Flutter 工程能力。