401 刷新 Token 的队列版(请求挂起排队 + 刷新后统一重放/统一失败)

------更工程化的"中间件"语义:挂起、统一重放、统一失败、统一登出

共享 Future 方案已经能解决"并发只刷新一次";
队列版适用于:

  • 想在 refresh 期间挂起请求,不立刻把 401 抛给业务层

  • 想 refresh 失败时一锅端所有等待请求(并且只登出一次)

  • 想控制重放节奏:顺序 / 限流 / 去重 / 白名单(中大型项目常见需求)

1)为什么要搞队列版?

当首页并发几十个请求时,accessToken 过期会出现很多 401。

共享 Future 版是:每个 401 各自 await 刷新结果,然后各自 retry。

队列版是:401 先入队挂起,刷新完成后统一重放/统一失败。

队列版的核心价值:

  • 刷新期间请求不"立即失败",体验更平滑(尤其下拉刷新、弱网场景)

  • 刷新失败时统一失败 + 统一登出,UI 不会多次弹提示/多次跳登录

  • 更容易扩展:重放顺序、限流、去重、接口白名单

2)队列版核心思想(中间件语义)

当请求 401:

  1. 不立刻 handler.next(err)

  2. 把这个请求的 (RequestOptions + handler) 存入队列(挂起)

  3. 触发一次 refresh(并发锁保证只刷新一次)

  4. refresh 成功:统一重放 队列里所有请求(逐个 resolve 回原 caller)

  5. refresh 失败:统一 reject 所有等待请求,并触发 全局 onAuthExpired(只触发一次)

关键点:队列版必须有"全局登出/跳登录"的收敛点,否则并发失败会导致 UI 混乱。

3)代码实现(更新版:带全局 onAuthExpired)

3.1 TokenStore(示例)

Dart 复制代码
class TokenPair {
  final String accessToken;
  final String refreshToken;
  TokenPair(this.accessToken, this.refreshToken);
}

class TokenStore {
  static TokenPair? _pair;

  static TokenPair? get() => _pair;

  static Future<void> set(TokenPair pair) async {
    _pair = pair;
    // TODO: persist to secure storage
  }

  static Future<void> clear() async {
    _pair = null;
  }
}

3.2 RefreshManager(共享 Future:保证 refresh 只执行一次)

队列版仍然需要"只刷新一次"的并发锁(不然还是刷新风暴)。

Dart 复制代码
class RefreshManager {
  Future<String?>? _refreshing;

  Future<String?> getOrCreate(Future<String?> Function() job) {
    _refreshing ??= job().whenComplete(() {
      _refreshing = null;
    });
    return _refreshing!;
  }
}

3.3 AuthInterceptor(请求注入 accessToken)

Dart 复制代码
import 'package:dio/dio.dart';

class AuthInterceptor extends Interceptor {
  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
    final token = TokenStore.get()?.accessToken;
    if (token != null && token.isNotEmpty) {
      options.headers["Authorization"] = "Bearer $token";
    }
    handler.next(options);
  }
}

3.4 队列元素:保存"挂起请求"

Dart 复制代码
import 'package:dio/dio.dart';

class _QueuedReq {
  final RequestOptions options;
  final ErrorInterceptorHandler handler;
  _QueuedReq(this.options, this.handler);
}

3.5 QueueRefreshInterceptor(更新版:用 onAuthExpired 回调解耦 UI)

更新点:

  • 拦截器不再直接依赖 EventBus/UI

  • refresh 失败时只调用 onAuthExpired()(只触发一次)

  • App 层统一登出/跳登录(最干净)

Dart 复制代码
import 'package:dio/dio.dart';

class QueueRefreshInterceptor extends Interceptor {
  final Dio dio;        // 主 Dio:用于重放
  final Dio cleanDio;   // 干净 Dio:用于 refresh(避免递归)
  final RefreshManager mgr;

  final void Function() onAuthExpired;
  bool _expiredFired = false;

  final List<_QueuedReq> _queue = [];

  QueueRefreshInterceptor({
    required this.dio,
    required this.cleanDio,
    required this.mgr,
    required this.onAuthExpired,
  });

  @override
  void onError(DioException err, ErrorInterceptorHandler handler) async {
    if (err.response?.statusCode != 401) {
      return handler.next(err);
    }

    final req = err.requestOptions;

    // 防死循环:同一请求最多重放一次
    if (req.extra["retried"] == true) {
      return handler.next(err);
    }

    final pair = TokenStore.get();
    if (pair == null || pair.refreshToken.isEmpty) {
      _fireExpiredOnce();
      return handler.next(err);
    }

    // ① 入队 + 挂起(此刻不 next,不 resolve)
    _queue.add(_QueuedReq(req, handler));

    // ② 触发"只一次"的刷新:成功返回 newAccessToken,失败返回 null
    final newAccessToken = await mgr.getOrCreate(() async {
      try {
        // TODO: 替换为你们真实 refresh 接口
        final res = await cleanDio.post(
          "/auth/refresh",
          data: {"refreshToken": pair.refreshToken},
        );

        final data = res.data as Map<String, dynamic>;
        final access = data["accessToken"] as String;
        final refresh = data["refreshToken"] as String;

        await TokenStore.set(TokenPair(access, refresh));
        return access;
      } catch (_) {
        await TokenStore.clear();
        return null;
      }
    });

    // ③ 多个 onError 会到这里:避免重复 drain
    if (_queue.isEmpty) return;
    final pending = List<_QueuedReq>.from(_queue);
    _queue.clear();

    // ④ 刷新失败:统一失败 + 全局登出(只触发一次)
    if (newAccessToken == null) {
      _fireExpiredOnce();
      for (final q in pending) {
        q.handler.next(err);
      }
      return;
    }

    // ⑤ 刷新成功:统一重放队列(顺序重放最稳)
    for (final q in pending) {
      try {
        final resp = await _replay(q.options, newAccessToken);
        q.handler.resolve(resp);
      } catch (e) {
        q.handler.next(e is DioException ? e : err);
      }
    }
  }

  Future<Response<dynamic>> _replay(RequestOptions req, String token) {
    return dio.request(
      req.path,
      data: req.data,
      queryParameters: req.queryParameters,
      options: Options(
        method: req.method,
        headers: Map<String, dynamic>.from(req.headers)
          ..["Authorization"] = "Bearer $token",
        extra: Map<String, dynamic>.from(req.extra)..["retried"] = true,
      ),
      cancelToken: req.cancelToken,
      onSendProgress: req.onSendProgress,
      onReceiveProgress: req.onReceiveProgress,
    );
  }

  void _fireExpiredOnce() {
    if (_expiredFired) return;
    _expiredFired = true;
    onAuthExpired();
  }
}

4)接入 DioClient(更新版:注入 onAuthExpired)

Dart 复制代码
import 'package:dio/dio.dart';

class DioClient {
  DioClient._();
  static final DioClient instance = DioClient._();

  late final Dio dio;
  late final Dio cleanDio;
  final RefreshManager mgr = RefreshManager();

  void init({required void Function() onAuthExpired}) {
    dio = Dio(BaseOptions(baseUrl: "https://api.example.com"));
    cleanDio = Dio(BaseOptions(baseUrl: "https://api.example.com"));

    dio.interceptors.addAll([
      AuthInterceptor(),
      QueueRefreshInterceptor(
        dio: dio,
        cleanDio: cleanDio,
        mgr: mgr,
        onAuthExpired: onAuthExpired,
      ),
    ]);
  }
}

5)全局 onAuthExpired:统一登出 / 跳登录(App 层只做一次)

队列版最怕:refresh 失败时释放大量等待请求 → UI 多处重复登出。

所以 App 层要"只做一次"。

final navigatorKey = GlobalKey<NavigatorState>();

MaterialApp:

MaterialApp(

navigatorKey: navigatorKey,

// routes...

);

5.2 Gate:防重复跳转(推荐)

Dart 复制代码
class AuthExpiredGate {
  bool _fired = false;

  void fireOnce(void Function() action) {
    if (_fired) return;
    _fired = true;
    action();
  }

  void reset() => _fired = false; // 登录成功后 reset
}

final authGate = AuthExpiredGate();

5.3 注入 init(闭环)

Dart 复制代码
DioClient.instance.init(onAuthExpired: () {
  authGate.fireOnce(() async {
    await TokenStore.clear();
    // TODO: 清理用户态 userStore.logout()

    navigatorKey.currentState
        ?.pushNamedAndRemoveUntil('/login', (route) => false);

    // TODO: toast("登录过期,请重新登录");
  });
});

6)共享 Future 版 vs 队列版怎么选?

  • 小中型项目:共享 Future 版足够(实现简单,稳定好维护)

  • 中大型项目:队列版更工程化(挂起、统一重放、统一失败、统一登出、便于扩展限流/去重/白名单)

结语:队列版的本质不是 Dio,而是"异步并发控制 + 中间件语义"

  • 共享 Future:异步锁(临界区只执行一次)

  • 队列:挂起/重放/统一失败(中间件语义更强)

  • onAuthExpired:全局状态一致(只登出一次,UI 不乱)

下一篇:

用 Drift 实现 Repository 无缝接入本地缓存/数据库(SWR:先快后准)

相关推荐
ujainu几秒前
护眼又美观:Flutter + OpenHarmony 鸿蒙记事本一键切换夜间模式(四)
android·flutter·harmonyos
ujainu2 分钟前
让笔记触手可及:为 Flutter + OpenHarmony 鸿蒙记事本添加实时搜索(二)
笔记·flutter·openharmony
一只大侠的侠2 分钟前
Flutter开源鸿蒙跨平台训练营 Day 13从零开发注册页面
flutter·华为·harmonyos
一只大侠的侠15 分钟前
Flutter开源鸿蒙跨平台训练营 Day19自定义 useFormik 实现高性能表单处理
flutter·开源·harmonyos
恋猫de小郭1 小时前
Flutter Zero 是什么?它的出现有什么意义?为什么你需要了解下?
android·前端·flutter
一只大侠的侠6 小时前
Flutter开源鸿蒙跨平台训练营 Day 10特惠推荐数据的获取与渲染
flutter·开源·harmonyos
renke33649 小时前
Flutter for OpenHarmony:色彩捕手——基于HSL色轮与感知色差的交互式色觉训练系统
flutter
子春一11 小时前
Flutter for OpenHarmony:构建一个 Flutter 四色猜谜游戏,深入解析密码逻辑、反馈算法与经典益智游戏重构
算法·flutter·游戏
铅笔侠_小龙虾12 小时前
Flutter 实战: 计算器
开发语言·javascript·flutter
微祎_13 小时前
Flutter for OpenHarmony:构建一个 Flutter 重力弹球游戏,2D 物理引擎、手势交互与关卡设计的工程实现
flutter·游戏·交互