2026/10/15 5:06:08

Flutter Dio Token拦截器实战:并发刷新与登录态设计

Flutter Dio Token拦截器实战:并发刷新与登录态设计 1. 从一次接口集体报错说起为什么Token拦截器值得单独拎出来讲前阵子帮一个朋友排查他们App的登录态问题现象很典型用户登录成功后首页数据能正常拉取但只要切到我的订单页面接口就返回401退到登录页重新登录又能撑一会儿过几分钟再次失效。他们一开始怀疑是后端Token过期时间设太短后端查了日志说Token有效期是7天根本没到期。两边扯皮了两天最后定位到问题出在客户端——他们的Dio请求里Token是手动在每个接口调用处拼进去的有的地方拼了有的地方忘了拼有的地方拼的是旧Token还有的地方拼了但没处理刷新逻辑。这个案例其实特别有代表性。在Flutter项目里只要涉及网络请求Token拦截器几乎是绕不开的一环。它看起来只是给请求头加个字段这么简单的事但真正落地的时候你会遇到一连串问题Token存哪里、什么时候刷新、刷新的时候并发请求怎么办、刷新失败怎么踢用户下线、哪些接口不需要带Token、拦截器里能不能拿到BuildContext……这些问题不解决代码写出来就是一堆散落的补丁越维护越乱。这篇内容就是围绕在Dio里使用Token拦截器这件事把我自己踩过的坑、试过的方案、以及最终稳定跑在生产环境里的做法完整梳理一遍。适合已经用过Dio、但对拦截器机制还停留在抄一段代码阶段的Flutter开发者也适合正在做登录态体系设计、想搞清楚刷新逻辑该怎么写的同学。我会从拦截器的执行顺序讲起一路讲到并发刷新、白名单、失败重试这些实战细节尽量把每个为什么这么写都讲透。需要提前说明的是下面涉及的代码都是基于Dio 5.x版本的APIDio 4.x和5.x在拦截器、错误处理上有一些差异如果你还在用4.x部分写法需要对照官方迁移文档调整。另外文中所有示例都是通用场景不涉及任何具体业务系统。2. Dio拦截器的执行链路搞不清顺序刷新逻辑必崩2.1 四种拦截器的分工与触发时机很多人写Token拦截器出问题根源在于没搞清楚Dio的拦截器到底是怎么串起来的。Dio的拦截器分四类按执行顺序排列是这样的拦截器类型触发时机典型用途onRequest请求发出前加Token、加公共参数、加签名onResponse收到响应后、业务处理前统一解包、日志、数据预处理onError请求失败或响应异常时Token失效处理、错误上报、重试onRequest队列中后续的前一个拦截器放行后多拦截器串联关键点在于同一个Dio实例上可以挂多个拦截器它们按添加顺序依次执行。onRequest是正序执行onResponse和onError是逆序执行。这个正序进、逆序出的模型跟栈的结构是一样的。我见过有人把Token刷新逻辑写在onRequest里结果每次请求都去检查一遍Token是否过期导致每个接口都要先发一个刷新请求性能直接崩掉。正确的做法是onRequest只负责把当前Token塞进请求头刷新逻辑放在onError里等真的收到401了再去刷新。这就是所谓的懒刷新策略只有用到的时候才刷避免无谓的请求。2.2 为什么刷新逻辑必须放在onError而不是onRequest这里展开说一下懒刷新的必要性。假设你把刷新判断放在onRequest每次发请求前先本地判断Token的过期时间戳如果快过期了就先去刷新。听起来合理但问题在于本地时间不可靠用户改系统时间、时区切换都会导致判断失误多个请求并发时每个请求都判断一次可能触发多次刷新服务端可能因为其他原因比如主动踢下线让Token提前失效本地时间戳根本判断不出来。而放在onError里逻辑就变成请求发出去了服务端明确告诉我401了这时候我才去刷新。这是服务端的权威判断比本地猜测靠谱得多。代价是第一次失效的请求会失败一次但通过重试机制可以补回来用户体验上几乎无感。2.3 拦截器里拿不到BuildContext这件事还有一个新手常踩的坑想在拦截器里弹个Toast提示登录已过期结果发现拿不到BuildContext。原因是拦截器是纯Dart逻辑层的东西它不属于任何Widget树自然没有上下文。解决办法有两个一是用全局的navigatorKey在MaterialApp上挂一个GlobalKeyNavigatorState拦截器里通过这个key去拿导航能力二是用事件总线或者全局状态管理比如Riverpod、Bloc发一个登出事件由UI层监听后自己处理跳转。我个人更推荐第二种拦截器只负责通知不负责操作UI职责更清晰测试也好写。3. Token的存储与读取别小看这一步坑最多3.1 存储方案选型SharedPreferences还是安全存储Token存哪里这个问题看似简单但选错了后面全是麻烦。常见的几种方案SharedPreferences最常用键值对存储同步读取快但明文存储安全性一般flutter_secure_storage基于KeychainiOS和KeystoreAndroid加密存储安全性高但读取是异步的速度略慢内存变量最快但App一重启就没了只能作为缓存层。我的建议是分层存储内存里放一份当前Token的缓存用于拦截器同步读取持久层用flutter_secure_storage存一份App启动时读出来填充到内存。这样既保证了拦截器读取的速度不用每次await又保证了持久化和安全性。如果你对安全性要求没那么高SharedPreferences也能用但要注意不要把Token直接拼在URL里也不要在日志里打印完整Token这两个是常见的信息泄露点。3.2 异步读取带来的时序问题flutter_secure_storage的读取是异步的这就带来一个时序问题拦截器的onRequest是同步回调你在里面await读取Token虽然语法上可以但会让每个请求都多一次异步等待。更好的做法是在App启动阶段就把Token读出来放到内存单例里拦截器直接从内存拿。代码大概长这样class TokenStore { static String? _accessToken; static String? _refreshToken; static String? get accessToken _accessToken; static Futurevoid init() async { const storage FlutterSecureStorage(); _accessToken await storage.read(key: access_token); _refreshToken await storage.read(key: refresh_token); } static Futurevoid save(String access, String refresh) async { _accessToken access; _refreshToken refresh; const storage FlutterSecureStorage(); await storage.write(key: access_token, value: access); await storage.write(key: refresh_token, value: refresh); } static Futurevoid clear() async { _accessToken null; _refreshToken null; const storage FlutterSecureStorage(); await storage.delete(key: access_token); await storage.delete(key: refresh_token); } }在main()里await TokenStore.init()之后再runApp这样拦截器里就能同步拿到Token了。这个初始化会稍微拖慢启动速度但通常也就几十毫秒可以接受。3.3 Token过期时间要不要存有人喜欢把Token的过期时间也存下来用于本地预判。我的经验是可以存但只作为辅助不作为刷新依据。比如你可以在Token快过期时比如还剩5分钟提前静默刷新一次减少用户遇到401的概率。但真正的刷新触发还是要靠onError里的401判断双保险。4. 手写一个能扛住并发的Token拦截器4.1 基础版本先跑通单请求刷新先看一个最基础的版本理解核心逻辑class TokenInterceptor extends Interceptor { final Dio dio; TokenInterceptor(this.dio); override void onRequest(RequestOptions options, RequestInterceptorHandler handler) { final token TokenStore.accessToken; if (token ! null !options.path.contains(/login)) { options.headers[Authorization] Bearer $token; } handler.next(options); } override void onError(DioException err, ErrorInterceptorHandler handler) async { if (err.response?.statusCode 401) { // 尝试刷新Token final newToken await _refreshToken(); if (newToken ! null) { // 用新Token重试原请求 final options err.requestOptions; options.headers[Authorization] Bearer $newToken; try { final response await dio.fetch(options); return handler.resolve(response); } catch (e) { return handler.reject(err); } } } handler.next(err); } FutureString? _refreshToken() async { // 调用刷新接口返回新Token或null // ... } }这段代码能跑通单请求场景但有个致命问题如果同时有5个请求都返回401会触发5次刷新。刷新接口通常是一次性的旧refreshToken用完就失效5次并发刷新会导致4次失败甚至把用户踢下线。4.2 并发刷新用Completer做请求合并解决并发刷新的经典方案是请求合并第一个401触发刷新后续的401都等待同一个刷新结果。用Completer实现class TokenInterceptor extends Interceptor { final Dio dio; CompleterString?? _refreshCompleter; TokenInterceptor(this.dio); override void onError(DioException err, ErrorInterceptorHandler handler) async { if (err.response?.statusCode ! 401) { return handler.next(err); } final newToken await _getNewToken(); if (newToken null) { // 刷新失败通知登出 _onLogout(); return handler.next(err); } final options err.requestOptions; options.headers[Authorization] Bearer $newToken; try { final response await dio.fetch(options); handler.resolve(response); } catch (e) { handler.next(err); } } FutureString? _getNewToken() { if (_refreshCompleter ! null) { // 已有刷新在进行直接等结果 return _refreshCompleter!.future; } _refreshCompleter CompleterString?(); _doRefresh().then((token) { _refreshCompleter!.complete(token); _refreshCompleter null; }).catchError((e) { _refreshCompleter!.complete(null); _refreshCompleter null; }); return _refreshCompleter!.future; } FutureString? _doRefresh() async { // 真正调用刷新接口 // 注意这里要用一个独立的Dio实例避免走拦截器造成死循环 } }这里有个极其关键的细节刷新Token的请求本身必须用一个独立的Dio实例不能复用带拦截器的那个。否则刷新请求如果也返回401就会再次触发刷新无限递归下去。我见过有人在这里栽跟头App直接卡死。4.3 刷新失败后的登出处理刷新失败比如refreshToken也过期了说明用户必须重新登录。这时候要做几件事清空本地TokenTokenStore.clear()通知UI层跳转到登录页把当前所有等待中的请求都reject掉避免它们一直挂着。第3点容易被忽略。如果刷新失败时还有几个请求在等_refreshCompleter的结果它们拿到null之后会走handler.next(err)这个是对的。但如果你在刷新失败时直接抛异常那些等待的请求可能就悬在那里了。所以刷新失败一定要complete(null)而不是completeError让等待方拿到明确的null结果。通知UI层的方式我推荐用全局的navigatorKey或者事件流。用navigatorKey的话final navigatorKey GlobalKeyNavigatorState(); // 在MaterialApp里 MaterialApp(navigatorKey: navigatorKey, ...) // 拦截器里 navigatorKey.currentState?.pushNamedAndRemoveUntil(/login, (route) false);注意pushNamedAndRemoveUntil会清空整个路由栈确保用户不能通过返回键回到之前的页面。5. 白名单、重试与那些容易忽略的边界情况5.1 哪些接口不该带Token不是所有接口都需要Token。登录、注册、发送验证码、刷新Token本身这些接口如果带了Token反而可能出问题比如后端校验逻辑冲突。所以拦截器里要维护一个白名单const _whiteList [ /auth/login, /auth/register, /auth/sendCode, /auth/refresh, ]; bool _isWhiteList(String path) { return _whiteList.any((item) path.contains(item)); }在onRequest里判断白名单接口直接放行不加Authorization头。这里用contains而不是是因为实际路径可能带查询参数或者前缀。5.2 重试次数要设上限刷新Token后重试原请求这个重试只能做一次。如果重试还失败就不能再触发刷新了否则又是死循环。所以要在RequestOptions里打个标记if (options.extra[retried] true) { return handler.next(err); } options.extra[retried] true;extra是Dio提供的自定义字段专门用来在请求生命周期里传递额外信息非常适合做这种标记。5.3 刷新接口本身返回401怎么办这是个特殊情况刷新Token的请求返回401说明refreshToken也失效了。这时候不能再走刷新-重试的逻辑必须直接登出。所以在_doRefresh里要单独处理FutureString? _doRefresh() async { try { final response await _refreshDio.post(/auth/refresh, data: { refresh_token: TokenStore.refreshToken, }); final newToken response.data[access_token]; final newRefresh response.data[refresh_token]; await TokenStore.save(newToken, newRefresh); return newToken; } on DioException catch (e) { if (e.response?.statusCode 401) { // refreshToken失效直接登出 await TokenStore.clear(); _onLogout(); } return null; } }5.4 网络异常和401要区分开onError里判断401一定要用err.response?.statusCode而不是err.type。因为网络超时、连接失败这些情况response是null的如果你直接err.response!.statusCode会抛空指针。我见过有人这么写结果用户断网的时候App直接崩溃。正确的判断顺序是先看response是否为null再看statusCode。非401的错误直接handler.next(err)放行交给上层处理。6. 把拦截器接进项目注册顺序与调试技巧6.1 拦截器的注册顺序有讲究一个项目里通常不止一个拦截器日志拦截器、Token拦截器、错误处理拦截器、加密拦截器……它们的注册顺序直接影响执行结果。我的建议顺序是日志拦截器放最前面这样能看到最原始的请求和最终的响应Token拦截器放中间负责加Token和刷新业务错误拦截器放最后负责把HTTP错误转成业务错误码。因为onRequest是正序执行onError是逆序执行所以Token拦截器在onError里会先于日志拦截器执行刷新逻辑能第一时间介入日志拦截器则记录到刷新后的最终结果。这个顺序是我试过比较顺的你可以根据项目情况调整。6.2 用日志拦截器排查Token问题调试Token问题时日志拦截器是神器。Dio自带的LogInterceptor就够用dio.interceptors.add(LogInterceptor( request: true, requestHeader: true, responseHeader: false, responseBody: true, error: true, ));但要注意生产环境一定要关掉requestHeader和responseBody的日志否则Token和用户数据会明文打印到控制台这是严重的安全隐患。可以用kDebugMode做条件判断if (kDebugMode) { dio.interceptors.add(LogInterceptor(requestHeader: true, responseBody: true)); }6.3 一个排查刷新死循环的实用技巧如果你怀疑刷新逻辑陷入了死循环可以在_doRefresh里加一个计数器打印每次刷新的时间戳和调用栈。正常情况下一次401只会触发一次刷新如果看到短时间内大量刷新记录基本可以确定是死循环回头检查刷新用的Dio实例是不是复用了带拦截器的那个。另外可以在onError入口处打印err.requestOptions.path和err.requestOptions.extra看看重试标记有没有正确设置。这两个信息能帮你快速定位问题出在哪个环节。7. 几个我踩过的坑和对应的处理方式7.1 Token里带了Bearer前缀又加了一次后端返回的Token有时候已经带了Bearer前缀你在拦截器里又拼一次变成Bearer Bearer xxx服务端直接拒绝。这个坑很隐蔽因为日志里看请求头是有的但就是401。处理办法是加Token前先判断final token TokenStore.accessToken; if (token ! null) { options.headers[Authorization] token.startsWith(Bearer ) ? token : Bearer $token; }7.2 多端登录导致Token互相踢掉如果后端支持多端登录同一账号在A设备登录后B设备的Token会失效。这时候B设备的刷新请求也会失败触发登出。这是正常逻辑但用户体验上会突然被踢。如果产品要求允许多端在线那后端就不该踢旧Token如果要求单端在线那客户端就要做好被踢后的友好提示而不是直接闪退到登录页。7.3 拦截器里做耗时操作阻塞了请求有人在onRequest里做加密、签名计算这些操作如果耗时较长比如RSA签名会阻塞请求发出。解决办法是把这些操作放到compute里用isolate执行或者提前算好缓存起来。Token拦截器本身不应该做重活它只负责取Token、塞Header这两件事。7.4 热重载后Token丢失开发阶段用热重载hot reload时内存里的Token单例会重置但持久层还在。如果你只在main()里初始化一次TokenStore热重载后内存就是空的所有请求都不带Token。解决办法是在拦截器里做兜底内存没有时同步从持久层读一次虽然异步但可以接受一次await。或者干脆在开发时每次热重载都重新登录反正测试账号登录很快。8. 写在最后关于拦截器设计的一点个人体会Token拦截器这个东西代码量不大但它是整个App网络层的咽喉。写得好用户全程无感写得糙用户动不动就被踢下线还找不到原因。我自己总结下来核心就三条懒刷新、请求合并、失败即登出。懒刷新保证不浪费请求请求合并保证并发安全失败即登出保证状态干净。把这三条落实了剩下的都是细节打磨。另外提醒一句拦截器的逻辑一定要写单元测试。尤其是刷新失败、并发401、白名单这些分支手动测很难覆盖全。用mockito或者http_mock_adapter模拟各种响应把边界情况都跑一遍上线才踏实。我吃过没写测试的亏一个并发刷新的bug在测试环境死活复现不出来上线后用户量一上来就暴露了回滚都来不及。如果你正在设计登录态体系建议把Token的生命周期画成一张状态图未登录、已登录、刷新中、已登出四个状态之间的转换条件写清楚拦截器的代码自然就清晰了。这个图不用多复杂白纸上画几笔就行但能帮你省下大量调试时间。