引言
在移动应用开发中,认证状态管理是一项基础且关键的任务。一个典型的场景是:当访问令牌过期,API 返回 401 Unauthorized 错误时,应用需要能够自动刷新令牌,并且在此过程中保证用户体验不受影响。
然而,当应用在短时间内并发发起多个网络请求时,问题会变得复杂。若此时令牌恰好过期,所有请求都可能因认证失败而触发刷新逻辑,从而引发竞态条件,导致重复刷新、资源浪费甚至逻辑错误。
本文将详细介绍如何在 Flutter 应用中,利用 Dio 拦截器 与 Dart 的 Completer 类,构建一个优雅的锁定机制,从而高效、可靠地解决多请求场景下的令牌自动刷新问题。
问题:多重刷新引发的混乱
试想一下,如果不加控制地处理并发请求下的令牌过期,会发生什么?
假设应用启动时,需要同时调用多个 API 来获取用户资料、通知、动态等信息。如果此时访问令牌已过期,可能会发生如下情况:
- 请求 A 失败(401)-> 触发令牌刷新流程。
- 请求 B 失败(401)-> 再次触发令牌刷新流程。
- 请求 C 失败(401)-> 又一次触发令牌刷新流程。
这会导致服务器收到多个重复的刷新请求,浪费资源,并可能引发错误(例如,第一个请求刷新出的新令牌被后续的刷新请求立即置为失效)。

从网络日志中可以看到,多个请求(红色标记)因使用无效令牌访问而连续失败,状态码均为 401。
解决方案:使用 Completer 进行请求排队
解决这一问题的核心是引入一个锁定机制,其逻辑非常清晰:
- 发起者(Initiator): 当某个请求检测到令牌即将或已经过期时,它将发起令牌刷新流程。
- 队列(Queue): 在刷新进行期间,所有后续到达的请求都必须进入等待状态。
- 释放(Release): 令牌刷新完成后,通知所有等待的请求,它们将使用新获取的令牌继续执行。
在 Dart 语言中,Completer 是实现这种“等待-通知”模式的理想工具,它允许我们创建一个 Future 并在将来某个时刻手动完成它。
逻辑流程
本文实现的拦截器选择在 onRequest 阶段执行令牌检查逻辑。这种做法的优势在于,它主动预防因过期令牌导致的网络请求失败,而不是在收到 401 错误后再进行响应,从而使整个请求流程更加顺畅。
令牌拦截器的核心时序逻辑如下图所示:

根据拦截器的设计,其工作流程可以总结为以下几步:
- 读取令牌过期时间:在转发请求前,拦截器首先检查存储的令牌过期时间戳。
- 检查锁定状态:通过判断
_completer 变量是否不为 null,来确认当前是否有令牌刷新流程正在进行。
- 如果已锁定:若有刷新正在进行,后续到达的请求会通过
await _completer.future 暂停执行,等待刷新完成。
- 如果未锁定:若没有进行中的刷新,拦截器将初始化
_completer = Completer() 来“上锁”,接着执行令牌刷新、更新本地存储,最后调用 _completer.complete() 来“解锁”并通知所有等待的请求。
通过在 onRequest 中集中处理令牌的检查与刷新,确保了所有发出的请求都尽可能携带有效的令牌,有效避免了因认证失败引发的网络错误,并将刷新逻辑集中化、同步化。
分步实现
接下来,我们将使用流行的 dio 网络库进行具体的代码实现。在 Flutter 项目中处理移动端的网络请求时,一个健壮的拦截器是提升稳定性的关键。
设置拦截器和“锁”
首先,定义拦截器类并声明 _completer 作为控制访问的“锁”。
class TokenInterceptor extends Interceptor {
final TokenRepository repository;
// 这个 completer 作为我们的锁定机制
Completer<void>? _completer;
TokenInterceptor({TokenRepository? repository})
: repository = repository ?? TokenRepository();
// ...
}
阻塞机制
在 onRequest 方法中,我们首先检查当前是否已存在一个进行中的刷新流程(即 _completer 是否已被创建)。
@override
Future<void> onRequest(
RequestOptions options, RequestInterceptorHandler handler) async {
// ...
// 条件:如果 completer 不为 null(表示刷新正在进行中)
// 并且当前请求路径不是令牌刷新端点本身(避免死锁)
if (_completer != null && options.path != endPointToken) {
// 在此暂停,直到 future 完成
await _completer?.future;
// 完成后,令牌已刷新。更新请求头。
final headers = options.headers;
headers[HttpHeaders.authorizationHeader] = repository.accessToken;
return handler.next(options);
}
// ...
}
这段代码确保了,如果“请求 A”正在刷新令牌(创建了 _completer),那么“请求 B”会在 await _completer?.future 处暂停,直到“请求 A”调用 _completer.complete() 后才继续执行。
预防性令牌刷新
我们选择一种更优的策略:在发送请求前主动检查令牌的剩余有效期,而不是被动等待服务器返回 401 错误。
在本实现中,当访问令牌的剩余有效时间小于或等于 10 秒时,就会触发刷新。这样可以确保发出的请求总是携带一个在短期内有效的令牌,从源头上预防了因令牌瞬间过期导致的网络错误,这对于优化HTTP请求成功率很有帮助。
/// ...
// 计算时间差
final differenceTime = expiredTime?.difference(currentTime);
// 如果令牌在 10 秒或更短时间内过期,则进行刷新
final isTokenExpired = (differenceTime?.inSeconds ?? 0) <= 10;
final shouldGetToken = repository.shouldGetToken ?? false;
/// ...
执行刷新
如果判断需要刷新令牌,我们将初始化 _completer(上锁),执行刷新请求,然后解锁。
/// ...
if (shouldGetToken || isTokenExpired) {
try {
// 1. 上锁:初始化 completer
_completer = Completer<void>();
// 2. 执行操作:从 repository 获取新令牌
final response = await repository.obtainToken();
if (response == null) {
throw ArgumentError('response is null');
}
// 3. 保存:持久化新令牌
await saveToken(response);
final accessToken = response.accessToken;
// 为当前这次发起的请求更新请求头
final headers = options.headers;
headers[HttpHeaders.authorizationHeader] = accessToken;
final newOption = options.copyWith(headers: headers);
handler.next(newOption); // 转发发起者请求
// 4. 解锁:通知所有等待的请求
if (handler.isCompleted) {
_completer?.complete();
_completer = null;
}
return;
} catch (e) {
// 即使发生错误,也要始终确保解锁
_completer?.complete();
_completer = null;
return handler.next(options);
}
}
/// ...
结果:无缝的用户体验
在实现了 TokenInterceptor 并集成到 Dio 实例后,让我们观察应用的实际表现。在下方的网络日志截图中可以看到,尽管同时发起了多个请求,但没有一个因为 401 错误而失败。拦截器无缝地处理了令牌过期问题。系统仅进行了一次令牌刷新(对应日志中耗时较长的那个请求),而所有其他请求都自动排队等待,并在刷新成功后全部成功完成,状态码为 200 OK。

结论
处理令牌过期不仅关乎错误处理,更是一种对网络请求流程的优化。通过结合 Dio Interceptor 与 Dart 的 Completer,我们成功地将可能引发混乱的竞态条件转化为一个有序、高效的请求队列。
关键要点总结:
- 逻辑集中化:使用拦截器将认证相关的逻辑与业务 UI 代码分离,使架构更清晰。
- 主动检查策略:在请求发出前进行令牌有效期检查(预防性检查),可以减少不必要的网络往返,降低延迟。
- 实施锁定机制:利用
Completer 防止并发场景下的重复刷新调用,确保令牌刷新操作的原子性和数据完整性。