1. 从一次接口集体报错说起:为什么Token拦截器值得单独拎出来讲
前阵子帮一个朋友排查他们App的登录态问题,现象很典型:用户登录成功后,首页数据能正常拉取,但只要切到"我的订单"页面,接口就返回401,退到登录页重新登录,又能撑一会儿,过几分钟再次失效。他们一开始怀疑是后端Token过期时间设太短,后端查了日志说Token有效期是7天,根本没到期。两边扯皮了两天,最后定位到问题出在客户端——他们的Dio请求里,Token是手动在每个接口调用处拼进去的,有的地方拼了,有的地方忘了拼,有的地方拼的是旧Token,还有的地方拼了但没处理刷新逻辑。
这个案例其实特别有代表性。在Flutter项目里,只要涉及网络请求,Token拦截器几乎是绕不开的一环。它看起来只是"给请求头加个字段"这么简单的事,但真正落地的时候,你会遇到一连串问题:Token存哪里、什么时候刷新、刷新的时候并发请求怎么办、刷新失败怎么踢用户下线、哪些接口不需要带Token、拦截器里能不能拿到BuildContext……这些问题不解决,代码写出来就是一堆散落的补丁,越维护越乱。
这篇内容就是围绕"在Dio里使用Token拦截器"这件事,把我自己踩过的坑、试过的方案、以及最终稳定跑在生产环境里的做法完整梳理一遍。适合已经用过Dio、但对拦截器机制还停留在"抄一段代码"阶段的Flutter开发者,也适合正在做登录态体系设计、想搞清楚刷新逻辑该怎么写的同学。我会从拦截器的执行顺序讲起,一路讲到并发刷新、白名单、失败重试这些实战细节,尽量把每个"为什么这么写"都讲透。
需要提前说明的是,下面涉及的代码都是基于Dio 5.x版本的API,Dio 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上挂一个GlobalKey<NavigatorState>,拦截器里通过这个key去拿导航能力;二是用事件总线或者全局状态管理(比如Riverpod、Bloc)发一个"登出事件",由UI层监听后自己处理跳转。我个人更推荐第二种,拦截器只负责"通知",不负责"操作UI",职责更清晰,测试也好写。
3. Token的存储与读取:别小看这一步,坑最多
3.1 存储方案选型:SharedPreferences还是安全存储
Token存哪里,这个问题看似简单,但选错了后面全是麻烦。常见的几种方案:
SharedPreferences:最常用,键值对存储,同步读取快,但明文存储,安全性一般;flutter_secure_storage:基于Keychain(iOS)和Keystore(Android),加密存储,安全性高,但读取是异步的,速度略慢;- 内存变量:最快,但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 Future<void> init() async { const storage = FlutterSecureStorage(); _accessToken = await storage.read(key: 'access_token'); _refreshToken = await storage.read(key: 'refresh_token'); } static Future<void> 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 Future<void> 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); } Future<String?> _refreshToken() async { // 调用刷新接口,返回新Token或null // ... } }这段代码能跑通单请求场景,但有个致命问题:如果同时有5个请求都返回401,会触发5次刷新。刷新接口通常是一次性的(旧refreshToken用完就失效),5次并发刷新会导致4次失败,甚至把用户踢下线。
4.2 并发刷新:用Completer做请求合并
解决并发刷新的经典方案是"请求合并":第一个401触发刷新,后续的401都等待同一个刷新结果。用Completer实现:
class TokenInterceptor extends Interceptor { final Dio dio; Completer<String?>? _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); } } Future<String?> _getNewToken() { if (_refreshCompleter != null) { // 已有刷新在进行,直接等结果 return _refreshCompleter!.future; } _refreshCompleter = Completer<String?>(); _doRefresh().then((token) { _refreshCompleter!.complete(token); _refreshCompleter = null; }).catchError((e) { _refreshCompleter!.complete(null); _refreshCompleter = null; }); return _refreshCompleter!.future; } Future<String?> _doRefresh() async { // 真正调用刷新接口 // 注意:这里要用一个独立的Dio实例,避免走拦截器造成死循环 } }这里有个极其关键的细节:刷新Token的请求本身,必须用一个独立的Dio实例,不能复用带拦截器的那个。否则刷新请求如果也返回401,就会再次触发刷新,无限递归下去。我见过有人在这里栽跟头,App直接卡死。
4.3 刷新失败后的登出处理
刷新失败(比如refreshToken也过期了),说明用户必须重新登录。这时候要做几件事:
- 清空本地Token(
TokenStore.clear()); - 通知UI层跳转到登录页;
- 把当前所有等待中的请求都reject掉,避免它们一直挂着。
第3点容易被忽略。如果刷新失败时,还有几个请求在等_refreshCompleter的结果,它们拿到null之后会走handler.next(err),这个是对的。但如果你在刷新失败时直接抛异常,那些等待的请求可能就悬在那里了。所以刷新失败一定要complete(null),而不是completeError,让等待方拿到明确的null结果。
通知UI层的方式,我推荐用全局的navigatorKey或者事件流。用navigatorKey的话:
final navigatorKey = GlobalKey<NavigatorState>(); // 在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里要单独处理:
Future<String?> _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的生命周期画成一张状态图:未登录、已登录、刷新中、已登出,四个状态之间的转换条件写清楚,拦截器的代码自然就清晰了。这个图不用多复杂,白纸上画几笔就行,但能帮你省下大量调试时间。