☰
Flutter Dio Token拦截器:并发刷新与重放实战
2026/10/15 22:10:54 网站建设 项目流程

1. 为什么 Token 拦截器值得单独拎出来聊

移动端项目只要涉及登录态,就绕不开 Token 管理这件事。我做过好几个 Flutter 项目,早期图省事,在每个接口调用处手动往 header 里塞 Token,结果就是:登录过期了要改十几个文件,Token 刷新逻辑散落在各个业务页面里,测试提的 bug 有一半是“某个页面没带 Token 导致 401”。后来统一收敛到 Dio 的拦截器里,才算把这块彻底理顺。

这篇内容聊的就是Flutter 中基于 Dio 的 Token 拦截器:它是什么、能解决什么问题、怎么落地。核心价值在于把“取 Token、塞 Token、Token 过期后刷新、刷新失败后登出”这一整套流程,从业务代码里剥离出来,做成一个对上层透明的中间层。适合已经用过 Dio、但对拦截器机制还停留在“知道有这东西”阶段的 Flutter 开发者,也适合正在重构网络层、想把鉴权逻辑统一收口的同学。

需要先明确一点:拦截器不是银弹,它解决的是“横切关注点”的复用问题。Token 的存储、刷新接口的设计、并发请求的处理,这些才是真正容易踩坑的地方。下面我会按“设计思路 → 核心机制 → 完整实现 → 问题排查”的顺序,把这块讲透。

2. 拦截器的整体设计与选型考量

2.1 为什么是拦截器,而不是封装一个请求方法

很多人第一反应是封装一个request()方法,在里面统一加 header。这个方案能用,但有几个硬伤。第一,Dio 的请求方式有 get、post、put、delete 等多种,封装一层意味着你要么写多个方法,要么用动态参数,调用处的类型提示会变差。第二,文件上传、下载这类特殊请求,走封装方法会很别扭。第三,也是最关键的:拦截器能拿到请求发出前和响应返回后的完整上下文,包括重试、错误转换,这些是普通封装方法做不到的。

Dio 的拦截器分两类:InterceptorsWrapper和QueuedInterceptor。前者是并发的,多个请求同时触发拦截逻辑时互不干扰;后者是队列化的,同一时刻只有一个请求能进入拦截逻辑,处理完才轮到下一个。这个区别在 Token 刷新场景下非常关键,后面会详细说。

2.2 Token 拦截器要解决的四个核心问题

我把 Token 拦截器的职责拆成四块,这样设计时不会漏:

  • 注入:请求发出前,把当前有效的 Token 塞进 header。
  • 识别:响应返回后,判断是否属于 Token 失效(通常是 401 或业务码约定)。
  • 刷新:Token 失效时,调用刷新接口换取新 Token。
  • 重放:刷新成功后,用新 Token 重新发起刚才失败的请求。

这四步里,刷新和重放是最容易出问题的。比如同时有 5 个请求都返回 401,如果每个都去刷新,就会发出 5 次刷新请求,后 4 次大概率因为旧 Token 已失效而失败,甚至把新 Token 也搞失效。这就是为什么刷新逻辑必须做“单飞”(single flight)控制。

2.3 存储选型:Token 放哪

Token 的持久化存储,常见选择是shared_preferences和flutter_secure_storage。前者简单、跨平台、读写快,但明文存储;后者基于各平台的安全存储机制(iOS 的 Keychain、Android 的 Keystore),安全性更高,但读写稍慢,且在某些定制系统上偶发兼容问题。

我的建议是:Access Token 和 Refresh Token 都放 secure storage。虽然多花几毫秒,但这是登录凭证,值得。如果项目对性能极度敏感,可以做一个内存缓存层,启动时从 secure storage 读一次到内存,后续读写走内存,退出时清理。这样既安全又快。

注意:不要把 Token 直接写进全局变量然后到处引用。全局变量在热重载、多 isolate 场景下行为不可控,而且测试时很难 mock。用依赖注入或者单例服务来管理。

3. 核心机制拆解与关键细节

3.1 Dio 拦截器的执行顺序

Dio 的拦截器是链式的,请求拦截器按添加顺序执行,响应拦截器按相反顺序执行。这个顺序很重要。假设你添加了两个拦截器:日志拦截器和 Token 拦截器。如果日志在前,Token 在后,那么请求发出时先打日志(此时还没加 Token),再加 Token;响应返回时先经过 Token 拦截器,再经过日志拦截器。日志里看到的请求 header 可能不含 Token,容易误导排查。

我的习惯是:Token 拦截器放在最前面添加,日志拦截器放后面。这样日志能看到最终发出的完整请求,包括 Token。当然,日志里要记得脱敏,别把 Token 明文打出来。

3.2 401 的判定不能只看状态码

很多教程直接判断response.statusCode == 401,这在纯 RESTful 接口下没问题,但国内不少后端会把错误包在 200 响应里,用业务码区分,比如{ "code": 401, "msg": "登录已过期" }。所以判定逻辑要兼容两种情况:

bool _isTokenExpired(Response response) { if (response.statusCode == 401) return true; final data = response.data; if (data is Map && data['code'] == 401) return true; return false; }

这里有个坑:刷新接口本身返回 401 时,不能再触发刷新,否则会无限递归。所以拦截器里要有一个标志位,标记当前请求是不是刷新请求,或者用一个独立的 Dio 实例专门发刷新请求,不挂 Token 拦截器。我倾向后者,干净利落。

3.3 并发请求下的刷新控制

这是整个拦截器最核心的部分。假设页面初始化时同时发了 3 个请求,Token 恰好过期,3 个请求都返回 401。如果每个都触发刷新,就是 3 次刷新请求。正确做法是:第一个请求触发刷新,其余请求挂起等待,刷新完成后统一用新 Token 重放。

实现上有两种思路。第一种用QueuedInterceptor,它天然串行化,但要注意它会让所有请求排队,包括不需要 Token 的请求,可能影响性能。第二种用Completer手动控制:维护一个Future表示“刷新中”,其他请求 await 这个 Future,刷新完成后一起继续。

我实测下来,第二种更灵活。核心结构是这样:

Future<String?>? _refreshFuture; Future<String?> _getNewToken() { if (_refreshFuture != null) { return _refreshFuture!; } _refreshFuture = _doRefresh().whenComplete(() { _refreshFuture = null; }); return _refreshFuture!; }

whenComplete里把_refreshFuture置空,保证下次过期时能重新触发刷新。这个细节很容易漏,漏了的话第一次刷新后,后续所有过期都不会再刷新了。

3.4 重放请求时要注意的细节

重放不是简单地把原请求再发一次。有几个点要处理:

  • 更新 header:用新 Token 替换旧的 Authorization。
  • 避免死循环:重放请求如果又返回 401,不能再触发刷新,直接抛错让上层处理。
  • 保留原请求参数:Dio 的RequestOptions里包含了 method、path、data、queryParameters 等,重放时要用这些原始信息重建请求。

Dio 提供了dio.fetch(requestOptions)方法,可以直接用原始 options 重新发起请求,非常方便。但记得在 fetch 之前修改requestOptions.headers['Authorization']。

4. 完整实现与实操过程

4.1 依赖与基础结构

先看依赖,pubspec.yaml里需要:

dependencies: dio: ^5.4.0 flutter_secure_storage: ^9.0.0

然后定义一个 Token 存储服务,负责读写和清理:

class TokenStorage { static const _accessKey = 'access_token'; static const _refreshKey = 'refresh_token'; final _storage = const FlutterSecureStorage(); Future<String?> get accessToken => _storage.read(key: _accessKey); Future<String?> get refreshToken => _storage.read(key: _refreshKey); Future<void> save({required String access, required String refresh}) async { await _storage.write(key: _accessKey, value: access); await _storage.write(key: _refreshKey, value: refresh); } Future<void> clear() async { await _storage.delete(key: _accessKey); await _storage.delete(key: _refreshKey); } }

这个服务是拦截器和业务层之间的桥梁。业务层登录成功后调用save,登出时调用clear,拦截器只负责读。

4.2 刷新接口的独立 Dio 实例

前面提到,刷新请求不能走带 Token 拦截器的 Dio,否则会递归。所以单独建一个:

final _refreshDio = Dio(BaseOptions( baseUrl: 'https://api.example.com', connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 10), ));

这个实例只用于发刷新请求,不挂任何 Token 相关拦截器。刷新接口通常长这样:传入 refresh_token,返回新的 access_token 和 refresh_token。注意,有些后端刷新后会把 refresh_token 也换掉,所以保存时要两个都更新,不能只更新 access。

4.3 Token 拦截器的完整代码

下面是核心拦截器,我加了详细注释:

class TokenInterceptor extends Interceptor { final TokenStorage _storage; final Dio _refreshDio; final Dio _mainDio; final VoidCallback onLogout; Future<String?>? _refreshFuture; TokenInterceptor({ required TokenStorage storage, required Dio refreshDio, required Dio mainDio, required this.onLogout, }) : _storage = storage, _refreshDio = refreshDio, _mainDio = mainDio; @override void onRequest( RequestOptions options, RequestInterceptorHandler handler, ) async { final token = await _storage.accessToken; if (token != null && token.isNotEmpty) { options.headers['Authorization'] = 'Bearer $token'; } handler.next(options); } @override void onResponse( Response response, ResponseInterceptorHandler handler, ) async { if (!_isTokenExpired(response)) { handler.next(response); return; } // Token 过期,尝试刷新并重放 final newToken = await _getNewToken(); if (newToken == null) { onLogout(); handler.reject( DioException( requestOptions: response.requestOptions, response: response, type: DioExceptionType.badResponse, ), ); return; } try { final opts = response.requestOptions; opts.headers['Authorization'] = 'Bearer $newToken'; final retried = await _mainDio.fetch(opts); handler.resolve(retried); } on DioException catch (e) { handler.reject(e); } } @override void onError(DioException err, ErrorInterceptorHandler handler) async { // 网络层直接返回 401 的情况 if (err.response?.statusCode == 401) { final newToken = await _getNewToken(); if (newToken == null) { onLogout(); handler.next(err); return; } try { final opts = err.requestOptions; opts.headers['Authorization'] = 'Bearer $newToken'; final retried = await _mainDio.fetch(opts); handler.resolve(retried); } on DioException catch (e) { handler.next(e); } return; } handler.next(err); } Future<String?> _getNewToken() { if (_refreshFuture != null) return _refreshFuture!; _refreshFuture = _doRefresh().whenComplete(() { _refreshFuture = null; }); return _refreshFuture!; } Future<String?> _doRefresh() async { final refresh = await _storage.refreshToken; if (refresh == null || refresh.isEmpty) return null; try { final resp = await _refreshDio.post('/auth/refresh', data: { 'refresh_token': refresh, }); final data = resp.data; final newAccess = data['access_token'] as String?; final newRefresh = data['refresh_token'] as String? ?? refresh; if (newAccess == null) return null; await _storage.save(access: newAccess, refresh: newRefresh); return newAccess; } catch (_) { return null; } } bool _isTokenExpired(Response response) { if (response.statusCode == 401) return true; final data = response.data; if (data is Map && data['code'] == 401) return true; return false; } }

这段代码有几个设计点值得说明。第一,onResponse和onError都处理了 401,因为 Dio 对非 2xx 状态码默认走onError,但业务码 401 走onResponse,两条路都要覆盖。第二,_getNewToken用_refreshFuture做单飞控制,并发请求只会触发一次刷新。第三,刷新失败时调用onLogout回调,让业务层决定是跳登录页还是弹提示,拦截器不直接操作 UI。

4.4 组装 Dio 实例

最后把所有东西拼起来:

final tokenStorage = TokenStorage(); final refreshDio = Dio(BaseOptions(baseUrl: 'https://api.example.com')); final mainDio = Dio(BaseOptions( baseUrl: 'https://api.example.com', connectTimeout: const Duration(seconds: 15), receiveTimeout: const Duration(seconds: 15), )); mainDio.interceptors.add(TokenInterceptor( storage: tokenStorage, refreshDio: refreshDio, mainDio: mainDio, onLogout: () { // 跳转登录页、清理本地用户信息等 }, )); // 日志拦截器放后面,能看到最终 header mainDio.interceptors.add(LogInterceptor( requestBody: true, responseBody: true, ));

注意mainDio传给拦截器是为了在重放时调用fetch。这里有个循环引用:拦截器持有mainDio,mainDio又持有拦截器。Dart 里对象引用是安全的,不会造成内存泄漏,因为拦截器生命周期和 Dio 一致。但如果你在拦截器里做 dispose 操作,要小心别把 Dio 也关了。

4.5 登录与登出的衔接

拦截器只负责读 Token,写入要靠业务层。登录成功后:

Future<void> login(String username, String password) async { final resp = await mainDio.post('/auth/login', data: { 'username': username, 'password': password, }); await tokenStorage.save( access: resp.data['access_token'], refresh: resp.data['refresh_token'], ); }

登出时清理:

Future<void> logout() async { await tokenStorage.clear(); // 跳转登录页 }

这里有个细节:登出时最好也调用后端的登出接口,让服务端把 refresh_token 加入黑名单。否则用户登出后,如果 refresh_token 泄露,仍然能换新 Token。当然,这取决于后端是否支持。

5. 常见问题与排查技巧实录

5.1 刷新后仍然 401 怎么办

这是最常见的问题。排查顺序如下:

现象可能原因排查方法
刷新成功但重放仍 401重放时 header 没更新打印重放请求的 header,确认 Authorization 是新 Token
刷新接口返回 401refresh_token 也过期了检查 refresh_token 有效期,确认是否需要重新登录
刷新成功但新 Token 无效后端返回字段名不对打印刷新响应体,确认字段名是 access_token 还是 token
偶发 401并发刷新导致旧 Token 覆盖新 Token检查是否有多个刷新请求同时发出

我踩过最坑的一次是:后端刷新接口返回的字段叫accessToken(驼峰),我按access_token(下划线)取的,结果一直是 null,刷新逻辑静默失败。所以刷新响应的字段名一定要和后端对齐,最好让后端提供接口文档。

5.2 并发请求只刷新一次,但重放时有的成功有的失败

这种情况通常是重放时机问题。如果刷新完成后,多个请求同时用新 Token 重放,理论上都应该成功。但如果后端有 Token 生效延迟(比如新 Token 写入缓存需要几百毫秒),就可能部分失败。解决办法是在刷新成功后加一个短暂的延迟,或者让后端保证 Token 即时生效。

另一个可能是:重放时用了_mainDio.fetch,但_mainDio的拦截器又走了一遍onRequest,把 header 里的新 Token 又覆盖成了旧的。这种情况要检查onRequest里是否无条件覆盖了 Authorization。正确做法是:如果 header 里已经有 Authorization 且是新的,就不要覆盖。或者更简单,重放时用一个不带 Token 拦截器的 Dio 实例。

5.3 Token 存储读取失败

flutter_secure_storage在某些 Android 定制系统上会抛异常,比如 Keystore 被清理、系统升级后密钥失效。表现是读取 Token 时抛PlatformException。我的处理方式是在 TokenStorage 里包一层 try-catch,读取失败时返回 null,并清理存储:

Future<String?> get accessToken async { try { return await _storage.read(key: _accessKey); } catch (_) { await clear(); return null; } }

这样即使存储损坏,用户最多重新登录一次,不会卡死在启动页。

5.4 拦截器里的异步操作导致请求顺序错乱

onRequest是异步的,如果里面 await 了存储读取,多个请求的 header 注入顺序可能和发出顺序不一致。一般情况下没问题,但如果后端对请求顺序有要求(比如先创建再查询),就可能出问题。解决办法是用QueuedInterceptor替代InterceptorsWrapper,强制串行。代价是性能略降,但 Token 注入这种轻量操作,影响可以忽略。

5.5 刷新请求本身超时

刷新接口超时后,_doRefresh返回 null,触发登出。但有时候只是网络抖动,用户并不想被登出。我的做法是给刷新加一次重试:

Future<String?> _doRefresh() async { for (var i = 0; i < 2; i++) { try { // ... 刷新逻辑 return newAccess; } on DioException catch (e) { if (i == 1 || e.type != DioExceptionType.connectionTimeout) { return null; } } } return null; }

只在超时的情况下重试一次,其他错误(如 401)直接返回 null。这样既提升了弱网下的体验,又不会在 refresh_token 真失效时反复请求。

5.6 调试时如何快速验证拦截器

我习惯在拦截器里加临时日志,但要注意脱敏:

debugPrint('[Token] inject: ${token?.substring(0, 8)}...'); debugPrint('[Token] expired, refreshing...'); debugPrint('[Token] refresh success, retry: ${opts.path}');

只打印 Token 前 8 位,既能确认注入成功,又不会泄露完整凭证。验证并发刷新时,可以在页面初始化时故意发 3 个请求,观察日志里“refreshing”只出现一次,就说明单飞控制生效了。

6. 几个容易被忽略的进阶细节

6.1 Token 快过期时的主动刷新

被动刷新(等 401 再刷)有个缺点:用户会感知到一次额外的请求延迟。更好的做法是解析 JWT 的 exp 字段,在 Token 快过期时主动刷新。JWT 的 payload 是 base64 编码的,解码后能拿到过期时间:

DateTime? _getExpiry(String token) { try { final parts = token.split('.'); if (parts.length != 3) return null; final payload = json.decode( utf8.decode(base64Url.decode(base64Url.normalize(parts[1]))), ); final exp = payload['exp'] as int?; return exp == null ? null : DateTime.fromMillisecondsSinceEpoch(exp * 1000); } catch (_) { return null; } }

然后在onRequest里判断:如果距离过期不到 5 分钟,就先刷新再发请求。这样用户几乎感知不到 Token 切换。当然,这要求后端用 JWT,且 Token 里带 exp。

6.2 多 Dio 实例的 Token 共享

有些项目会创建多个 Dio 实例,比如一个给普通接口,一个给文件上传。如果每个实例都挂 Token 拦截器,刷新时可能各自触发一次。解决办法是让所有实例共享同一个 TokenInterceptor 实例,或者共享同一个_refreshFuture。我倾向于前者,把拦截器做成单例,所有 Dio 实例 add 同一个拦截器对象。这样单飞控制是全局的,不会重复刷新。

6.3 拦截器与路由的联动

Token 刷新失败触发登出时,如果当前页面有未完成的请求,可能会在登出后回调到已销毁的页面,导致 setState 报错。解决办法是在登出时先取消所有进行中的请求。Dio 提供了CancelToken,可以在拦截器里维护一个全局 CancelToken,登出时 cancel 掉:

final _globalCancelToken = CancelToken(); // 登出时 _globalCancelToken.cancel('logout');

然后在创建请求时传入这个 CancelToken。这样登出瞬间,所有请求都会被取消,不会再有回调触发。

6.4 测试环境如何绕过 Token

开发阶段经常需要跳过登录直接调接口。可以在拦截器里加一个开关:

if (kDebugMode && _skipAuth) { options.headers['Authorization'] = 'Bearer dev-token'; handler.next(options); return; }

但要注意,这个开关不能带到生产环境。用kDebugMode包一层,release 构建时自动失效。

7. 我在实际项目中的几点体会

Token 拦截器这东西,写出来不难,难的是覆盖各种边界情况。我前后重构过三次,第一次只处理了 401 刷新,结果并发请求把 refresh_token 刷失效了;第二次加了单飞控制,但忘了刷新失败后的登出,用户卡在空白页;第三次才把取消请求、主动刷新、多实例共享这些补上。

最大的体会是:拦截器的复杂度应该和业务的安全要求匹配。如果只是内部工具,Token 过期直接登出就行,没必要做刷新。如果是面向用户的 App,刷新、重放、并发控制一个都不能少。另外,别在拦截器里写业务逻辑,它只该管 Token 的注入和刷新,登出后的跳转、提示,交给回调去处理,保持职责单一。

还有一个细节:刷新接口的路径、字段名、错误码,最好抽成配置项,别硬编码在拦截器里。不同环境(开发、测试、生产)的接口地址可能不同,硬编码会导致切换环境时到处改代码。我现在的做法是把这些放在一个AuthConfig类里,拦截器从配置读取,改起来只动一个地方。

最后分享一个小技巧:如果你不确定拦截器的执行顺序,可以在每个拦截器的onRequest里打印一行日志,发一个请求看输出顺序。Dio 的拦截器链是先进先出(请求)和后进先出(响应),实测一次就记住了,比看文档直观得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询