资讯动态

Flutter Dio Token拦截器实战:自动刷新与并发排队

发布时间:2026/10/15 20:38:03 来源:尧图企业网站定制
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.0Token 的本地存储我一般用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 逻辑。这是符合预期的。但还有一种情况多个请求同时 401QueuedInterceptor会串行处理。第一个请求进入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? _cachedTokensaveToken时同时更新内存和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 的登录态管理建议先把拦截器的并发逻辑理清楚再动手写业务代码能省掉后面很多返工。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑