☰
Flutter Dio Token拦截器实战:并发刷新与登录态设计
2026/10/15 5:06:07 网站建设 项目流程

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也过期了),说明用户必须重新登录。这时候要做几件事:

  1. 清空本地Token(TokenStore.clear());
  2. 通知UI层跳转到登录页;
  3. 把当前所有等待中的请求都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拦截器、错误处理拦截器、加密拦截器……它们的注册顺序直接影响执行结果。

我的建议顺序是:

  1. 日志拦截器放最前面,这样能看到最原始的请求和最终的响应;
  2. Token拦截器放中间,负责加Token和刷新;
  3. 业务错误拦截器放最后,负责把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的生命周期画成一张状态图:未登录、已登录、刷新中、已登出,四个状态之间的转换条件写清楚,拦截器的代码自然就清晰了。这个图不用多复杂,白纸上画几笔就行,但能帮你省下大量调试时间。

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

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

立即咨询