☰
Flutter Dio Token拦截器实战:自动刷新与并发排队
2026/10/15 20:37:55 网站建设 项目流程

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

移动端项目只要涉及到登录态,就绕不开 Token 管理这件事。我做过好几个 Flutter 项目,早期图省事,在每个接口调用前手动拼 Header,结果就是登录过期处理得乱七八糟,有的页面弹了登录页,有的页面直接白屏,还有的页面在 Token 刷新后拿着旧 Token 又发了一次请求。后来统一收敛到 Dio 的拦截器里,才算把这块逻辑理清楚。

这篇内容聊的就是Flutter 中基于 Dio 的 Token 拦截器,核心解决三件事:请求自动携带 Token、Token 过期自动刷新、刷新期间并发请求的排队与重放。适合已经用过 Dio 但还没系统整理过拦截器逻辑的开发者,也适合刚接触 Flutter 网络层、想一次性把登录态方案做扎实的新手。读完你至少能拿到一套可以直接抄进项目的拦截器骨架,以及几个我踩过的坑。

先说清楚一个前提:Token 拦截器不是"加个 Header"这么简单。它本质上是一个请求生命周期管理问题,涉及请求发出前、响应返回后、错误抛出时三个时机,还要处理并发、重试、循环刷新这些边界情况。下面我按设计思路、核心实现、实操细节、问题排查四个层面拆开讲。

2. 拦截器的整体设计与方案选型

2.1 为什么选 Dio 的 Interceptor 而不是自己包一层

Flutter 里做网络请求,常见的有http、dio、chopper几种。http包太底层,拦截逻辑得自己写;chopper偏代码生成,灵活度一般。Dio 的Interceptor提供了onRequest、onResponse、onError三个钩子,正好对应请求发出前、响应成功、响应失败三个时机,天然适合做 Token 注入和刷新。

我选 Dio 的核心理由是它的拦截器链式调用设计。你可以注册多个拦截器,按顺序执行,比如日志拦截器、Token 拦截器、错误处理拦截器各管一摊,职责清晰。而且 Dio 支持QueuedInterceptor,这个类内部自带请求队列,处理并发刷新 Token 时不用自己写锁,省了很多事。

提示:如果你的项目还在用http包,建议先评估迁移成本。Dio 的拦截器生态和文档成熟度明显更高,长期维护更省心。

2.2 Token 拦截器的三种典型方案对比

在动手写之前,先明确 Token 刷新的策略。我见过三种做法,各有适用场景:

方案核心思路优点缺点适用场景
被动刷新收到 401 才去刷新 Token实现简单,请求量小首次 401 请求会失败,需重试中小型项目
主动刷新请求前检查 Token 是否快过期,提前刷新用户无感知需要本地存过期时间,逻辑稍复杂对体验要求高的项目
双 Token 机制Access Token + Refresh Token 分离安全性高,Access Token 可短有效期后端需配合,前端逻辑最复杂金融、企业级应用

我大多数项目用的是被动刷新 + 并发排队的组合。原因很直接:后端只给了一个 Token,没有 Refresh Token,主动刷新需要额外接口支持。被动刷新虽然首次 401 会失败,但配合重试机制,用户基本无感知。

2.3 并发场景是设计的核心难点

假设页面同时发了 5 个请求,此时 Token 过期,5 个请求全部返回 401。如果每个 401 都去触发一次刷新,就会刷新 5 次,甚至可能因为刷新接口本身也带旧 Token 而陷入死循环。

正确的做法是:第一个 401 触发刷新,其余请求挂起等待,刷新完成后统一重放。这就是所谓的"请求排队"。Dio 的QueuedInterceptor内部用队列串行化处理,天然支持这个模式。如果你用普通Interceptor,就得自己维护一个Future和等待列表,代码量翻倍还容易出 bug。

3. 核心实现:从零搭一个可用的 Token 拦截器

3.1 基础骨架与依赖准备

先在pubspec.yaml里加上依赖:

dependencies: dio: ^5.4.0 shared_preferences: ^2.2.0

Token 的本地存储我一般用shared_preferences,简单够用。如果涉及敏感信息,可以换成flutter_secure_storage,但要注意它在部分平台上的兼容性问题。

基础骨架大概长这样:

class TokenInterceptor extends QueuedInterceptor { final Dio dio; final TokenStorage storage; TokenInterceptor({required this.dio, required this.storage}); @override void onRequest(RequestOptions options, RequestInterceptorHandler handler) async { final token = await storage.getToken(); if (token != null && token.isNotEmpty) { options.headers['Authorization'] = 'Bearer $token'; } handler.next(options); } }

这里继承的是QueuedInterceptor而不是Interceptor,这是关键。QueuedInterceptor会保证同一时刻只有一个请求进入onRequest,避免并发读写 Token 时的竞态问题。

3.2 onError 里处理 401 与刷新逻辑

真正复杂的是onError。收到 401 后,不能直接抛错,要先判断是不是已经在刷新中:

@override void onError(DioException err, ErrorInterceptorHandler handler) async { if (err.response?.statusCode != 401) { return handler.next(err); } final refreshDio = Dio(BaseOptions(baseUrl: err.requestOptions.baseUrl)); try { final newToken = await _refreshToken(refreshDio); await storage.saveToken(newToken); final options = err.requestOptions; options.headers['Authorization'] = 'Bearer $newToken'; final response = await dio.fetch(options); return handler.resolve(response); } catch (e) { await storage.clearToken(); return handler.next(err); } }

这段代码有几个细节值得说。第一,刷新 Token 用的是新的 Dio 实例,不是原来的dio。为什么?因为原来的dio上挂着这个拦截器,如果用它去刷新,刷新请求本身也会走拦截器,可能再次触发 401,形成递归。第二,刷新成功后用dio.fetch(options)重放原请求,注意这里用的是原dio,因为此时 Token 已经更新,拦截器会正确注入新 Token。第三,刷新失败要清空本地 Token 并抛出原错误,让上层跳转登录页。

3.3 刷新接口本身的防重入

上面代码有个隐患:如果刷新接口返回的也是 401(比如 Refresh Token 也过期了),会怎样?因为刷新用的是独立 Dio,不会走拦截器,所以不会递归,但会抛异常,被 catch 捕获,走清空 Token 逻辑。这是符合预期的。

但还有一种情况:多个请求同时 401,QueuedInterceptor会串行处理。第一个请求进入onError开始刷新,此时第二个请求的onError会被排队,等第一个处理完才执行。等第二个执行时,Token 已经刷新过了,它拿到的err.requestOptions里的旧 Token 需要被替换。上面的代码里options.headers['Authorization']被重新赋值,所以没问题。

注意:如果你用的是普通Interceptor,这里必须自己加一个bool _isRefreshing标志位和等待队列,否则会刷新多次。QueuedInterceptor帮你省了这部分。

3.4 把拦截器注册到 Dio 实例

拦截器写好了,注册方式也有讲究:

final dio = Dio(BaseOptions(baseUrl: 'https://api.example.com')); final storage = TokenStorage(); dio.interceptors.add(TokenInterceptor(dio: dio, storage: storage)); dio.interceptors.add(LogInterceptor(responseBody: true));

顺序很重要。Token 拦截器要放在日志拦截器前面,这样日志里能看到带 Token 的请求头。如果项目还有统一的错误处理拦截器,一般放在最后,让它处理 Token 拦截器没消化的错误。

4. 实操过程中的关键细节与避坑

4.1 Token 存储的读写时机

Token 的读写看似简单,但有几个坑。第一,shared_preferences的读写是异步的,onRequest里await storage.getToken()会阻塞请求,如果每个请求都读一次磁盘,性能会有影响。我的做法是在内存里缓存一份 Token,启动时从磁盘加载一次,之后读写都走内存,异步落盘。

第二,Token 刷新后要同步更新内存和磁盘,否则下次请求读到的还是旧 Token。我一般把TokenStorage封装成一个单例,内部维护String? _cachedToken,saveToken时同时更新内存和SharedPreferences。

第三,退出登录时要清空 Token,并且要取消所有正在进行的请求。Dio 提供了dio.close()方法,但关闭后实例不能复用,需要重新创建。更优雅的做法是用CancelToken,在退出登录时调用cancelToken.cancel()。

4.2 刷新接口的路径要排除拦截

刷新 Token 的接口本身不能带旧 Token,也不能走 Token 拦截器。除了用独立 Dio 实例,还有一种做法是在拦截器里判断请求路径:

@override void onRequest(RequestOptions options, RequestInterceptorHandler handler) async { if (options.path.contains('/auth/refresh')) { return handler.next(options); } // ... 注入 Token }

两种方式都行,我倾向于独立 Dio 实例,因为职责更清晰,刷新逻辑和业务请求完全隔离。但独立实例要注意配置好baseUrl、超时时间这些基础参数,别漏了。

4.3 重放请求时的参数一致性

重放请求时,err.requestOptions里保存的是原始请求的所有信息,包括 method、path、query、data、headers。直接用dio.fetch(options)就能完整重放。但有一个细节:如果原始请求的data是一个Stream或者已经被消费过的FormData,重放可能会失败。这种情况在文件上传接口里比较常见。

解决办法是在onRequest阶段就把需要重放的数据缓存下来,或者对文件上传这类请求单独处理,不走自动重放。我一般会在options.extra里加一个标记,比如options.extra['retryable'] = true,只有标记过的请求才重放。

4.4 超时与重试的边界

Token 刷新本身也可能超时。如果刷新接口 5 秒没响应,是继续等还是直接失败?我的做法是给刷新 Dio 单独设置较短的超时时间,比如 3 秒,超时后直接走登录失效逻辑。因为刷新接口通常很快,超过 3 秒基本说明网络或服务端有问题,继续等只会让用户干等。

另外,重放请求也要设置重试次数上限。我见过有人写了个无限重试的逻辑,结果 Token 一直刷新失败,请求一直重放,直接把 App 卡死。正确做法是给options.extra加一个retryCount,超过 1 次就不再重放,直接抛错。

5. 常见问题排查与速查表

5.1 典型问题与排查思路

实际开发中遇到的问题五花八门,我整理了一张速查表,覆盖大部分场景:

问题现象可能原因排查方向解决方案
请求头没有 Token拦截器未注册或顺序不对检查dio.interceptors列表确保 Token 拦截器已添加且在日志拦截器之前
401 后没有刷新onError未触发或状态码判断错误打印err.response?.statusCode确认后端返回的是 401 而非 403
刷新后仍 401新 Token 未正确注入检查options.headers是否被覆盖重放前重新赋值 Authorization
并发请求刷新多次用了普通 Interceptor查看拦截器基类改用QueuedInterceptor
刷新接口递归调用刷新用了同一个 Dio 实例检查刷新 Dio 是否挂了拦截器用独立 Dio 实例
重放请求数据丢失FormData 已被消费检查请求类型对上传类请求禁用自动重放
退出登录后仍发请求未取消进行中的请求检查 CancelToken退出时 cancel 所有请求

5.2 一个容易被忽略的坑:Token 格式

后端要求的 Token 格式不一定是Bearer xxx。有的后端直接要token,有的要Authorization: xxx不带前缀。这个一定要和后端确认清楚,我见过因为多写了个Bearer导致 401 的情况,排查了半天。

另外,Token 里如果有特殊字符,比如+、/、=,在某些 Header 解析实现里可能出问题。虽然标准允许,但保险起见可以让后端用 URL-safe 的 Base64 编码。

5.3 调试技巧:打印完整请求链路

调试 Token 问题时,光看日志不够,我一般会临时加一段代码,把请求的完整信息打出来:

@override void onRequest(RequestOptions options, RequestInterceptorHandler handler) async { final token = await storage.getToken(); print('[TokenInterceptor] path=${options.path}, token=${token?.substring(0, 10)}...'); // ... }

注意只打印 Token 前 10 位,别把完整 Token 打到日志里,尤其是生产环境。我见过有人把完整 Token 打进日志,结果日志被上传到第三方平台,造成安全隐患。

5.4 测试环境的特殊处理

测试环境经常需要切换账号,Token 变化频繁。我一般会在测试包里加一个"强制刷新 Token"的入口,方便调试。另外,测试环境的 Token 有效期通常很短,正好可以用来验证刷新逻辑是否正常工作。

还有一个细节:如果测试环境用的是自签名证书,Dio 默认会拒绝请求。需要在BaseOptions里配置badCertificateCallback,但生产环境千万别开这个,否则有中间人攻击风险。

6. 进阶优化与个人经验

6.1 把 Token 逻辑抽成独立模块

拦截器写完后,我建议把 Token 相关的逻辑抽成一个独立的AuthManager或TokenManager,拦截器只负责调用它。这样做的好处是,登录、登出、刷新、Token 读取这些操作都集中在一处,方便维护和测试。拦截器本身保持轻薄,只做请求层面的编排。

TokenManager可以暴露这些方法:getToken()、saveToken()、clearToken()、refreshToken()、isTokenExpired()。其中refreshToken()内部要加锁,防止并发调用。Dart 里可以用Completer实现一个简单的互斥锁。

6.2 用 extra 传递业务标记

Dio 的RequestOptions.extra是个很有用的字段,可以传递任意业务标记。我常用它来标记"是否需要 Token"、"是否可重试"、"是否显示 Loading"。比如登录接口不需要 Token,就可以在请求时设置extra: {'noAuth': true},拦截器里判断这个标记决定是否注入 Token。

这种方式比在拦截器里硬编码路径判断要灵活得多,新增免登录接口时不用改拦截器代码。

6.3 关于 Token 过期时间的处理

如果后端返回的 Token 里带了过期时间(比如 JWT 的exp字段),可以在本地解析出来,提前判断是否快过期。这样可以在请求前主动刷新,减少 401 的发生。解析 JWT 可以用jwt_decoder包,几行代码就能拿到过期时间。

不过要注意,JWT 的exp是服务端时间,客户端时间可能不准。如果客户端时间比服务端慢,可能提前刷新;如果快,可能过期了还没刷新。所以主动刷新只能作为优化,不能完全替代 401 的被动处理。

6.4 我踩过的最大的一个坑

最后分享一个我踩过的坑。早期项目里,我把 Token 拦截器写成了普通Interceptor,没加任何并发控制。测试时单请求没问题,上线后用户反馈"偶尔要登录两次"。排查了很久才发现,是首页同时发了 3 个请求,Token 过期时 3 个 401 同时触发刷新,刷新了 3 次,其中两次用的是旧 Token,导致刷新失败,用户被踢到登录页。

改成QueuedInterceptor后问题消失。这个坑让我明白,网络层的并发问题在单请求测试时根本暴露不出来,必须用并发场景去验证。后来我养成了一个习惯,写完拦截器后,专门写一个测试用例,同时发 5 个请求,看 Token 刷新了几次。

这套方案我在几个项目里都用过,稳定性没问题。如果你正在做 Flutter 的登录态管理,建议先把拦截器的并发逻辑理清楚,再动手写业务代码,能省掉后面很多返工。

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

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

立即咨询