Flutter 应用 HTTP 网络层封装:从 Token 管理到请求重试的完整实践
Flutter 应用 HTTP 网络层封装从 Token 管理到请求重试的完整实践一、整体架构概览在本应用开发中网络层是连接客户端与服务端的桥梁。一个好的网络层封装应当具备以下能力统一的请求/响应处理集中管理 baseUrl、超时、响应格式等自动携带认证信息Token 自动注入请求头Token 无感刷新过期前主动刷新过期后自动重试错误处理与降级网络异常时给出友好的用户提示本项目的网络层由以下几部分组成分层组件职责业务调用层UI / Controller发起网络请求、处理返回结果、驱动界面更新业务接口层UserAPI / BillAPI封装具体 API 调用统一返回ResultT提取错误信息网络引擎层HttpUtils (Dio)管理 Dio 单例配置 baseUrl、超时策略注册拦截器拦截器层AuthInterceptorToken 自动注入请求头、主动提前刷新、401 拦截与请求重试队列工具层JwtUtils / StoreUtilsJWT 解析exp、sub、Token 安全本地存储下面我们自底向上逐层分析各模块的设计思路。二、基础工具层2.1 JWT 解析工具JwtUtilsJWT Token 由三部分组成中间部分Payload经 Base64 编码存储了 Token 的元信息如过期时间exp、用户标识sub等。在不验证签名的前提下我们可以直接从客户端解析这些信息。classJwtUtils{/// 从 JWT token 中解析过期时间戳秒staticint?getExpTimestamp(Stringtoken){finalpayload_decodePayload(token);if(payloadnull)returnnull;returnpayload[exp]asint?;}/// 判断 token 是否已过期staticboolisExpired(Stringtoken,{int leadSeconds0}){finalexpgetExpTimestamp(token);if(expnull)returntrue;finalnowDateTime.now().millisecondsSinceEpoch~/1000;returnnowexp-leadSeconds;// 提前 leadSeconds 秒判为过期}}设计要点leadSeconds参数允许我们设置一个提前量在 Token 真正过期前就主动刷新避免恰好过期时发出的请求返回 401所有解析操作都包裹在try-catch中任何异常都返回null保证了稳定性2.2 本地存储StoreUtilsStoreUtils基于flutter_secure_storage实现用于安全地保存 Token 等敏感信息。此处不展开代码只需知道它提供了secure.read(key)和secure.write(key, value)两个核心接口。三、Dio 引擎配置HttpUtils本项目选用 Dio 作为 HTTP 库HttpUtils采用单例模式确保全局共享同一个 Dio 实例及其拦截器链。classHttpUtils{staticHttpUtils?_instance;lateDio_dio;HttpUtils._internal(){BaseOptionsoptionsBaseOptions(responseType:ResponseType.json,);// Debug 模式不限超时方便断点调试if(kDebugMode){options.connectTimeoutnull;options.receiveTimeoutnull;}else{options.connectTimeoutconstDuration(seconds:5);options.receiveTimeoutconstDuration(seconds:3);}_dioDio(options)..interceptors.add(AuthInterceptor(_dio));}factoryHttpUtils(){_instance??HttpUtils._internal();return_instance!;}}设计要点环境感知根据kDebugMode动态调整超时策略开发时方便断点调试生产环境则严格控制超时拦截器注入在构造函数中将AuthInterceptor注入 Dio后续所有请求都会经过这个拦截器处理四、拦截器层Token 管理的核心AuthInterceptor是整个网络层最复杂的部分它承担了 Token 的生命周期管理。我们通过流程图来理解它的完整逻辑4.1 Token 自动注入与提前刷新步骤操作触发条件行为1附加 Token 和 UUID 到请求头每次请求从UserController获取当前 Token生成 UUID 标识注入到options.headers2判断 Token 是否即将过期Token 提前 60 秒即视为过期若过期 → 异步触发刷新不阻塞当前请求若未过期 → 直接放行请求每个请求发出前拦截器会执行两个固定动作第一从UserController中读取当前 Token 并附加到请求头同时为请求生成一个 UUID 作为唯一标识第二检查 Token 是否在 60 秒内即将过期。如果即将过期则异步触发 Token 刷新但不会阻塞当前请求的发送——这是一种乐观策略默认当前 Token 仍然有效如果尚未过期则直接放行请求。代码实现overridevoidonRequest(RequestOptionsoptions,RequestInterceptorHandlerhandler){finaluserControllerGet.findUserController();_attachToken(options);_attachUUID(options);// Token 即将过期时触发异步刷新if(userController.isLoggedIn.valueuserController.refreshToken.isNotEmptyJwtUtils.isExpired(userController.token.value,leadSeconds:60)){_doRefresh();// 异步刷新不阻塞当前请求returnhandler.next(options);}returnhandler.next(options);}设计要点刷新操作通过_doRefresh()异步执行不阻塞当前请求。这是一种乐观策略——假设当前 Token 仍然有效即使刷新失败也由后续的 401 错误处理来兜底使用leadSeconds: 60在过期前 60 秒就主动刷新给予足够的缓冲时间4.2 401 错误处理与请求重试队列步骤判断条件分支行为1是否为/user/refresh接口的 401是直接返回错误避免死循环否进入步骤 22——将当前请求加入_pendingRequests等待队列3是否有正在进行的刷新任务是等待当前刷新完成共享同一个 Completer否发起新的 Token 刷新请求4刷新结果成功遍历_pendingRequests更新 Token 后批量重试所有等待中的请求失败清空_pendingRequests所有等待请求返回原始 401 错误清除用户登录状态当拦截器捕获到 401 响应时首先检查该请求是否来自/user/refresh刷新接口本身——如果是直接返回错误以免形成死循环。如果不是则将当前失败请求放入等待队列_pendingRequests中。接着检查是否已有正在执行的刷新任务通过_refreshCompleter判断若有当前请求只需等待同一个 Completer 的结果若没有则发起新的刷新请求。刷新成功时为所有等待队列中的请求换上新的 Token 并批量重试刷新失败时清空队列并向所有等待请求返回错误最终清除用户登录状态。核心代码overrideFuturevoidonError(DioExceptionerr,ErrorInterceptorHandlerhandler)async{if(err.response?.statusCode!401||err.requestOptions.path.contains(/user/refresh)){returnhandler.next(err);}finalrequestUUID_extractUUID(err.requestOptions);// ... 省略边界检查 ...// 将请求加入等待队列_pendingRequests[requestUUID]_PendingRequest(requestOptions:err.requestOptions,handler:handler,err:err,hasTried:false,);// 等待刷新完成后重试bool resultawait_refreshCompleter?.future??await_doRefreshAndWait();if(result){// 刷新成功 → 重试所有请求await_retryAllPending();}else{// 刷新失败 → 返回错误并登出await_failAllPending();await_logout();}}4.3 并发刷新控制多请求同时触发 401 时我们不希望发起多次刷新请求。通过Completer实现合并刷新Futurebool_doRefresh()async{// 如果已有正在进行的刷新直接返回等待外部的 completerif(_refreshCompleter!null)return;_refreshCompleterCompleterbool();try{// 调用刷新接口...finalrespawaitUserAPI.refresh(refreshToken:refreshToken);_refreshCompleter!.complete(resp.isSuccess);}catch(_){_refreshCompleter!.complete(false);}finally{_refreshCompleternull;}}设计要点_refreshCompleter作为全局锁确保同一时间只有一个刷新请求在执行其他等待中的请求通过await _refreshCompleter?.future等待同一个 Completer 的结果刷新完成后立即释放锁允许后续的刷新请求正常执行4.4 请求唯一标识UUID为了在重试时准确定位原始请求我们为每个出站请求附加一个 UUIDvoid_attachUUID(RequestOptionsoptions){if(options.headers[x-dio-uuid]!null)return;options.headers[x-dio-uuid]constUuid().v4();}这个 UUID 作为请求的身份标识在整个生命周期中保持不变即使是重试请求也使用相同的 UUID。五、业务接口层业务接口层将基础的 HTTP 请求封装为语义化的方法统一返回ResultT类型classUserAPI{staticfinalDio_dioHttpUtils().dio;staticFutureResultLoginSuccesslogin({requiredStringemail,requiredStringpassword,})async{try{finalresponseawait_dio.post(/user/login,data:LoginRequest(email:email,password:password).toJson(),);returnResult.success(data:LoginSuccess.fromJson(response.data),message:登录成功,);}onDioExceptioncatch(e){returnResult.error(message:_extractErrorMessage(e));}}}设计要点统一返回类型ResultT封装了成功/失败状态上层调用无需关心 HTTP 状态码细节错误信息提取_extractErrorMessage从 DioException 中解析后端返回的detail字段提供友好的错误描述纯函数风格所有 API 方法都是静态的、无副作用除网络请求外便于测试和组合六、UI 层调用示例在登录页面中用户点击登录按钮后的完整调用链如下Futurevoid_handleLogin()async{setState(()_isLoadingtrue);// 1. 调用业务接口finalresultawaitUserAPI.login(email:_emailController.text.trim(),password:_passwordController.text,);setState(()_isLoadingfalse);if(result.isSuccessresult.data!null){// 2. 保存 Token 到本地存储 内存await_userController.setLogin(result.data!.token,result.data!.refreshToken,newUserId:result.data!.userId,);// 3. 跳转主页_goToHome();}else{// 4. 显示错误提示_showError(result.message??登录失败);}}完整数据流向图阶段发送方向组件操作1. 发起请求LoginPage → UserAPIUserAPI接收 email/password构造LoginRequest调用_dio.post(/user/login, ...)2. 请求拦截UserAPI → AuthInterceptorAuthInterceptor自动从UserController注入当前 Token 到请求头为请求附加 UUID 标识3. 发送请求AuthInterceptor → Dio → 后端Dio通过 HTTP 发送 POST 请求到后端服务4. 接收响应后端 → Dio → AuthInterceptorAuthInterceptor拦截 HTTP 响应若为 401 则触发 Token 刷新并重试否则直接放行5. 返回结果AuthInterceptor → UserAPIUserAPI将响应数据解析为ResultLoginSuccess成功或失败6. 更新 UIUserAPI → LoginPageLoginPage根据Result决定保存 Token 跳转主页还是显示错误提示整个登录流程从LoginPage收集用户输入的邮箱和密码开始传递给UserAPI.login()方法。UserAPI构建请求体后通过 Dio 发出 POST 请求。请求出发前AuthInterceptor拦截并自动注入当前 Token 和 UUID请求返回后拦截器再次检查响应状态码——若遇到 401 则触发 Token 刷新并重试。最终UserAPI将原始响应包装成统一的ResultLoginSuccess返回给LoginPage页面根据成功或失败分别执行跳转主页或展示错误提示的操作。七、关键设计决策总结设计决策实现方式优势提前刷新 TokenleadSeconds 60在过期前 60 秒异步刷新减少 401 错误提升用户体验请求重试队列401 时暂存请求到_pendingRequests刷新后批量重试避免请求丢失对用户透明防并发刷新Completer作为互斥锁避免短时间内发起多次刷新请求请求去重标识为每个请求附加 UUID在重试队列中准确定位原始请求环境感知超时kDebugMode控制超时策略开发体验与生产性能兼得统一结果封装ResultT泛型类调用方无需关心底层 HTTP 细节八、总结这套网络层的设计遵循了关注点分离的原则JwtUtils负责 Token 内容解析不验证签名AuthInterceptor负责 Token 的自动注入、过期刷新、401 重试——对业务层完全透明业务 API 类只关心请求参数和响应数据的映射UI 层只关心Result的成功/失败状态通过这样的分层设计我们在保证代码可维护性的同时实现了完整的 Token 生命周期管理和无感的请求重试机制。这套方案对于大多数需要 JWT 认证的 Flutter 应用都具有参考价值。