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:先快后准)

相关推荐
消失的旧时光-19432 小时前
Repository 层如何无缝接入本地缓存 / 数据库
数据库·flutter·缓存
消失的旧时光-19432 小时前
用 Drift 实现 Repository 无缝接入本地缓存/数据库(SWR:先快后准)
数据库·flutter·缓存
消失的旧时光-19432 小时前
Android(Kotlin) ↔ Flutter(Dart) 的“1:1 对应表”:架构分层来对照(MVVM/MVI 都适用)
android·flutter·kotlin
子春一212 小时前
Flutter 2025 性能工程体系:从启动优化到帧率稳定,打造丝滑、省电、低内存的极致用户体验
flutter·ux
子春一212 小时前
Flutter 2025 性能工程体系:从启动优化到帧率稳定,打造丝滑如原生的用户体验
flutter·ux
子春一213 小时前
Flutter 2025 架构演进工程体系:从单体到模块化,构建可扩展、可协作、可持续的大型应用
flutter·架构
renke336414 小时前
Flutter 2025 国际化与本地化工程体系:打造真正全球化的应用体验
flutter
子春一214 小时前
Flutter 2025 可访问性(Accessibility)工程体系:从合规达标到包容设计,打造人人可用的数字产品
前端·javascript·flutter
renke336414 小时前
Flutter 2025 状态管理工程体系:从 setState 到响应式架构,构建可维护、高性能的状态流
flutter