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

所有评论(0)