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 细节

八、总结

这套网络层的设计遵循了"关注点分离"的原则:

  1. JwtUtils 负责 Token 内容解析(不验证签名)
  2. AuthInterceptor 负责 Token 的自动注入、过期刷新、401 重试——对业务层完全透明
  3. 业务 API 类 只关心请求参数和响应数据的映射
  4. UI 层 只关心 Result 的成功/失败状态

通过这样的分层设计,我们在保证代码可维护性的同时,实现了完整的 Token 生命周期管理和无感的请求重试机制。这套方案对于大多数需要 JWT 认证的 Flutter 应用都具有参考价值。

更多推荐