Flutter 基础体系 · 第 56/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。

Flutter Dio 网络层:拦截器、取消、重试、上传和错误模型

Dio 是 Dart 生态中常用的 HTTP 客户端。它在 dart:io 和 Web 环境中通过不同适配器发送请求,并在请求前后提供拦截器、超时、取消、进度回调和异常对象。

一个可维护的网络层不能只封装:

final response = await dio.get('/users');

还必须明确以下问题:

  • 哪些逻辑属于所有请求,例如日志、公共请求头;
  • 哪些错误可以重试,哪些错误重试只会造成副作用;
  • 页面销毁时如何取消仍在进行的请求;
  • 文件上传的请求体如何构造,如何报告进度;
  • Dio 的异常如何转换为业务层稳定、可测试的错误模型;
  • Android、iOS、桌面和 Web 在底层网络能力上有哪些差异。

下面以 Dio 5.x 的 API 为基础,示例适用于 Dart 3 语法。具体 Dio 小版本可能增加新的适配器或参数,但核心机制保持一致。


1. Dio 请求的基本结构

一次 Dio 请求可以抽象为:

业务调用拦截器链请求转换平台适配器服务器\text{业务调用} \rightarrow \text{拦截器链} \rightarrow \text{请求转换} \rightarrow \text{平台适配器} \rightarrow \text{服务器}

响应则沿相反方向返回:

服务器响应平台适配器响应拦截器业务调用\text{服务器响应} \rightarrow \text{平台适配器} \rightarrow \text{响应拦截器} \rightarrow \text{业务调用}

Dio 的核心对象是 Dio,请求配置通常通过 BaseOptions 设置:

import 'package:dio/dio.dart';

final dio = Dio(
  BaseOptions(
    baseUrl: 'https://api.example.com',
    connectTimeout: const Duration(seconds: 10),
    sendTimeout: const Duration(seconds: 30),
    receiveTimeout: const Duration(seconds: 30),
    headers: <String, dynamic>{
      'Accept': 'application/json',
    },
  ),
);

三个超时分别对应不同阶段:

  • connectTimeout:建立连接的等待时间;
  • sendTimeout:发送请求数据的等待时间,上传大文件时尤其相关;
  • receiveTimeout:等待接收响应数据的时间。

它们不是“整个请求最多允许多少秒”。例如连接耗时 5 秒、发送耗时 20 秒、接收耗时 20 秒,即使每项都没有超过单独配置的上限,总耗时仍可能超过 30 秒。

业务代码可以直接发起请求:

final response = await dio.get<Map<String, dynamic>>(
  '/users/42',
  queryParameters: <String, dynamic>{
    'include': 'profile',
  },
);

final data = response.data;

这里:

  • queryParameters 会被编码到 URL 查询字符串;
  • response.data 的类型取决于响应内容和 ResponseType
  • get<Map<String, dynamic>> 是 Dart 类型提示,不会自动保证服务端返回的数据真的符合该结构。

如果需要显式指定响应类型:

final response = await dio.get<String>(
  '/health',
  options: Options(responseType: ResponseType.plain),
);

2. 拦截器:请求和响应的可组合处理链

2.1 拦截器是什么

拦截器是在请求进入适配器之前,或响应、错误返回业务代码之前执行的处理器。Dio 提供三个主要阶段:

class LoggingInterceptor extends Interceptor {
  @override
  void onRequest(
    RequestOptions options,
    RequestInterceptorHandler handler,
  ) {
    print('→ ${options.method} ${options.uri}');
    handler.next(options);
  }

  @override
  void onResponse(
    Response<dynamic> response,
    ResponseInterceptorHandler handler,
  ) {
    print('← ${response.statusCode} ${response.requestOptions.uri}');
    handler.next(response);
  }

  @override
  void onError(
    DioException err,
    ErrorInterceptorHandler handler,
  ) {
    print('× ${err.requestOptions.method} '
        '${err.requestOptions.uri}: ${err.type}');
    handler.next(err);
  }
}

注册方式:

dio.interceptors.add(LoggingInterceptor());

handler.next(...) 表示继续处理链:

  • onRequest 中继续发送请求;
  • onResponse 中继续把响应交给下一个拦截器或业务代码;
  • onError 中继续传播错误。

如果拦截器已经得出最终结果,也可以改变流程:

class LocalCacheInterceptor extends Interceptor {
  @override
  void onRequest(
    RequestOptions options,
    RequestInterceptorHandler handler,
  ) {
    if (options.path == '/feature-flags') {
      handler.resolve(
        Response<Map<String, dynamic>>(
          requestOptions: options,
          statusCode: 200,
          data: <String, dynamic>{
            'darkMode': true,
          },
        ),
      );
      return;
    }

    handler.next(options);
  }
}

handler.resolve 会把一个构造出来的响应当成成功结果;handler.reject 则可以直接终止请求并返回错误。拦截器必须选择一种结果路径,不能先 handler.nexthandler.resolve

2.2 拦截器的顺序和责任边界

多个拦截器按注册顺序处理请求。响应和错误通常沿拦截器链返回,因此每个拦截器都应该只承担一种明确职责,例如:

  1. 注入公共请求头;
  2. 记录请求日志;
  3. 处理认证失败;
  4. 转换或记录错误;
  5. 执行有限重试。

不要在一个拦截器中同时完成 token 刷新、缓存、重试、UI 提示和路由跳转。拦截器位于基础设施层,不应直接依赖页面生命周期。

公共请求头示例:

class HeaderInterceptor extends Interceptor {
  HeaderInterceptor(this.readAccessToken);

  final String? Function() readAccessToken;

  @override
  void onRequest(
    RequestOptions options,
    RequestInterceptorHandler handler,
  ) {
    final token = readAccessToken();

    if (token != null && token.isNotEmpty) {
      options.headers['Authorization'] = 'Bearer $token';
    }

    options.headers['X-Client-Version'] = '1.0.0';
    handler.next(options);
  }
}

这里使用 options.headers 而不是重新创建整个 Options,因为当前请求的 URL、超时、取消令牌、额外配置等都已经保存在 RequestOptions 中。

2.3 异步拦截器和并发

异步拦截器需要使用 async,并在 await 之后调用处理器:

class AsyncHeaderInterceptor extends Interceptor {
  AsyncHeaderInterceptor(this.loadToken);

  final Future<String?> Function() loadToken;

  @override
  Future<void> onRequest(
    RequestOptions options,
    RequestInterceptorHandler handler,
  ) async {
    try {
      final token = await loadToken();

      if (token != null) {
        options.headers['Authorization'] = 'Bearer $token';
      }

      handler.next(options);
    } catch (error, stackTrace) {
      handler.reject(
        DioException(
          requestOptions: options,
          error: error,
          stackTrace: stackTrace,
          type: DioExceptionType.unknown,
        ),
      );
    }
  }
}

如果 token 读取很快,普通 Interceptor 通常足够。但 token 刷新涉及并发时,必须考虑多个请求同时收到 401 的情况:

请求 A ── 401 ── 刷新 token ── 重放 A
请求 B ── 401 ── 刷新 token ── 重放 B
请求 C ── 401 ── 刷新 token ── 重放 C

这样可能导致多个刷新请求相互覆盖,甚至形成刷新风暴。Dio 提供 QueuedInterceptorsWrapper,使拦截器中的请求按队列顺序进入,适合需要串行处理的认证逻辑:

dio.interceptors.add(
  QueuedInterceptorsWrapper(
    onRequest: (options, handler) async {
      final token = await tokenStore.readAccessToken();

      if (token != null) {
        options.headers['Authorization'] = 'Bearer $token';
      }

      handler.next(options);
    },
    onError: (error, handler) async {
      if (error.response?.statusCode != 401) {
        handler.next(error);
        return;
      }

      try {
        final newToken = await tokenStore.refreshAccessToken();
        error.requestOptions.headers['Authorization'] =
            'Bearer $newToken';

        // 这里使用 fetch 重放当前 RequestOptions。
        final response = await dio.fetch<dynamic>(error.requestOptions);
        handler.resolve(response);
      } catch (_) {
        handler.next(error);
      }
    },
  ),
);

上例表达了“串行进入”的意图,但生产代码还必须防止无限刷新:

final alreadyRetried =
    error.requestOptions.extra['authRetried'] == true;

if (alreadyRetried) {
  handler.next(error);
  return;
}

error.requestOptions.extra['authRetried'] = true;

认证刷新还需要区分:

  • 原请求因为 access token 过期而失败;
  • 刷新 token 的请求本身失败;
  • 原请求已经重放过一次,仍返回 401
  • 取消或退出登录时不应继续刷新。

不要让刷新 token 的请求也经过同一套“401 刷新”逻辑,否则会递归。


3. 取消请求:取消的是一次请求尝试

3.1 CancelToken 的生命周期

Dio 使用 CancelToken 取消请求:

final cancelToken = CancelToken();

final future = dio.get<String>(
  '/large-resource',
  cancelToken: cancelToken,
);

cancelToken.cancel('页面已离开');

try {
  await future;
} on DioException catch (error) {
  if (CancelToken.isCancel(error)) {
    print('请求被取消:${error.message}');
  }
}

CancelToken 应与一次请求或一个明确的请求组绑定。它不是全局开关。

Flutter 页面中常见的生命周期是:

class DetailRepository {
  DetailRepository(this.dio);

  final Dio dio;

  Future<String> loadDetail({
    required String id,
    required CancelToken cancelToken,
  }) async {
    final response = await dio.get<String>(
      '/details/$id',
      cancelToken: cancelToken,
    );

    return response.data ?? '';
  }
}

状态管理层或页面持有 token:

final cancelToken = CancelToken();

@override
void dispose() {
  cancelToken.cancel('页面销毁');
  super.dispose();
}

取消动作必须发生在 dispose 之前或其中,避免异步回调在页面已经失效后仍尝试更新状态。

3.2 取消的状态变化

一次请求可以粗略表示为:

创建
  │
  ├── 发送中 ── 收到响应 ── 成功
  │
  ├── 发送中 ── 网络/HTTP错误 ── 失败
  │
  └── 发送中 ── cancelToken.cancel()
                         │
                       取消

取消后,等待中的 Future 通常以 DioException 结束,类型可以通过:

if (error.type == DioExceptionType.cancel) {
  // 取消是预期控制流,不应显示成“网络异常”
}

或者:

if (CancelToken.isCancel(error)) {
  // 兼容 Dio 对取消异常的判断方式
}

取消不是保证服务器已经停止执行。客户端停止等待和服务器停止处理是两个不同事件:

客户端取消⇏服务器事务回滚\text{客户端取消} \not\Rightarrow \text{服务器事务回滚}

例如上传请求已经到达服务器,客户端随后断开连接,服务器可能已经保存了部分文件,具体结果取决于服务器、代理和存储实现。因此取消上传后仍应由服务端提供幂等键、分片清理或未完成任务回收机制。

3.3 取消与重试的关系

取消错误不能重试。否则会出现:

用户取消请求
    ↓
重试拦截器捕获取消错误
    ↓
又发起一次请求

重试条件必须显式排除:

bool isRetryableError(DioException error) {
  if (CancelToken.isCancel(error)) {
    return false;
  }

  if (error.type == DioExceptionType.connectionTimeout ||
      error.type == DioExceptionType.sendTimeout ||
      error.type == DioExceptionType.receiveTimeout ||
      error.type == DioExceptionType.connectionError) {
    return true;
  }

  final statusCode = error.response?.statusCode;
  return statusCode == 408 ||
      statusCode == 425 ||
      statusCode == 429 ||
      (statusCode != null && statusCode >= 500);
}

这只是网络层的候选条件,不能据此断言业务操作一定安全。是否允许重试还取决于请求方法和接口幂等性。


4. 重试:重新发送不是简单重复 await

4.1 可重试条件

“请求失败”不是“可以重试”的同义词。

适合考虑重试的情况通常包括:

  • 临时连接失败;
  • DNS、连接建立或读取超时;
  • 服务端明确返回 408429
  • 服务端返回部分 5xx 状态码;
  • 请求具有幂等语义,或者携带服务端支持的幂等键。

不应盲目重试:

  • 400401403404 等确定性客户端错误;
  • 请求参数校验失败;
  • 用户主动取消;
  • 文件或请求体不可重复读取;
  • 非幂等写操作没有幂等保证。

幂等性表示同一个请求执行一次或多次,对资源最终状态的影响相同。GET 通常被设计为幂等,但“通常”不等于所有服务端实现都无副作用。POST 默认不应假设幂等,除非服务端通过幂等键等机制提供保证。

4.2 重试次数和退避

设第 nn 次重试前等待时间为:

dn=min(dmax,d0×2n)+Jd_n = \min(d_{\max}, d_0 \times 2^n) + J

其中:

  • d0d_0 是初始等待时间;
  • dmaxd_{\max} 是最大等待时间;
  • JJ 是随机抖动;
  • n=0n=0 表示第一次失败后的等待。

指数退避降低了大量客户端同时恢复时的请求洪峰。随机抖动用于避免客户端在相同时间点再次发送请求。

import 'dart:math';

Duration retryDelay(int retryIndex) {
  final exponentialMs = 500 * pow(2, retryIndex).toInt();
  final cappedMs = min(exponentialMs, 8_000);
  final jitterMs = Random().nextInt(250);

  return Duration(milliseconds: cappedMs + jitterMs);
}

例如:

  • 第一次失败:约 500~749 ms;
  • 第二次失败:约 1000~1249 ms;
  • 第三次失败:约 2000~2249 ms;
  • 后续逐渐达到上限。

4.3 一个有限重试拦截器

class RetryInterceptor extends Interceptor {
  RetryInterceptor({
    required this.dio,
    this.maxRetries = 2,
  });

  final Dio dio;
  final int maxRetries;

  @override
  Future<void> onError(
    DioException err,
    ErrorInterceptorHandler handler,
  ) async {
    final options = err.requestOptions;
    final retryCount = (options.extra['retryCount'] as int?) ?? 0;

    if (retryCount >= maxRetries || !isRetryableError(err)) {
      handler.next(err);
      return;
    }

    final method = options.method.toUpperCase();
    final retryableMethod =
        method == 'GET' ||
        method == 'HEAD' ||
        method == 'OPTIONS';

    if (!retryableMethod &&
        options.extra['idempotencyKey'] == null) {
      handler.next(err);
      return;
    }

    options.extra['retryCount'] = retryCount + 1;

    try {
      await Future<void>.delayed(retryDelay(retryCount));

      // fetch 使用当前 RequestOptions 发起请求。
      // 重试次数保存在 extra 中,避免无限循环。
      final response = await dio.fetch<dynamic>(options);
      handler.resolve(response);
    } on DioException catch (retryError) {
      handler.next(retryError);
    } catch (error, stackTrace) {
      handler.next(
        DioException(
          requestOptions: options,
          error: error,
          stackTrace: stackTrace,
          type: DioExceptionType.unknown,
        ),
      );
    }
  }
}

注册:

dio.interceptors.add(
  RetryInterceptor(
    dio: dio,
    maxRetries: 2,
  ),
);

这个实现有几个重要边界:

  1. maxRetries = 2 表示初始请求之外最多再发送两次;
  2. 取消错误在 isRetryableError 中被排除;
  3. 默认只重试常见幂等方法;
  4. 非幂等方法只有在 extra['idempotencyKey'] 存在时才允许进入重试路径;
  5. retryCount 写入 RequestOptions.extra,防止错误处理链无限递归。

调用非幂等接口时:

final idempotencyKey = 'order-${DateTime.now().microsecondsSinceEpoch}';

await dio.post(
  '/orders',
  data: <String, dynamic>{
    'productId': 'p-1',
    'quantity': 1,
  },
  options: Options(
    extra: <String, dynamic>{
      'idempotencyKey': idempotencyKey,
    },
    headers: <String, dynamic>{
      'Idempotency-Key': idempotencyKey,
    },
  ),
);

服务端必须真正理解并持久化这个键。仅在客户端 extra 中记录,而不把幂等信息传给服务端,不能防止重复创建订单。

4.4 请求体可重复性

重试的关键不是“还能不能调用 Dio”,而是“请求体能否再次读取”。

以下请求体通常容易重试:

data: <String, dynamic>{
  'name': 'Alice',
}

但以下情况需要谨慎:

  • 文件流已经读到末尾;
  • 一次性 Stream<List<int>>
  • 生成器每次读取结果不同;
  • 上传过程中服务端已经接受了部分数据。

上传重试通常应重新构造 FormDataMultipartFile,而不是复用已消费的流。对于大文件,更适合使用服务端支持的分片上传和断点续传,而不是简单重发整个文件。


5. 文件上传:FormDataMultipartFile 和进度

5.1 multipart/form-data 的结构

文件上传常用 multipart/form-data。一个 multipart 请求由多个 part 组成:

Content-Disposition: form-data; name="description"

avatar image
----------------
Content-Disposition: form-data; name="file"; filename="avatar.jpg"
Content-Type: image/jpeg

二进制文件内容

Dio 中可以这样构造:

final formData = FormData.fromMap(
  <String, dynamic>{
    'description': 'avatar image',
    'file': await MultipartFile.fromFile(
      '/path/to/avatar.jpg',
      filename: 'avatar.jpg',
    ),
  },
);

final response = await dio.post<Map<String, dynamic>>(
  '/upload',
  data: formData,
  onSendProgress: (sent, total) {
    if (total > 0) {
      final progress = sent / total;
      print('上传进度:${(progress * 100).toStringAsFixed(1)}%');
    } else {
      print('已发送 $sent 字节,当前无法计算百分比');
    }
  },
);

sent 是已经发送的字节数,total 是总字节数。total 可能为 -1 或不可用,因此不能无条件计算百分比。

不要手动写:

headers: {
  'Content-Type': 'multipart/form-data',
}

因为 multipart 的 Content-Type 通常还需要包含 boundary。Dio 在使用 FormData 时会构造正确的请求内容和相关头部。手动覆盖可能导致服务端无法解析表单。

5.2 移动端、桌面和 Web 的文件差异

MultipartFile.fromFile 依赖本地文件路径,适用于使用 dart:io 文件系统的 Android、iOS、Windows、macOS 和 Linux 场景,但不适用于浏览器 Web。

Web 中通常使用字节:

final bytes = await xFile.readAsBytes();

final formData = FormData.fromMap(
  <String, dynamic>{
    'file': MultipartFile.fromBytes(
      bytes,
      filename: xFile.name,
    ),
  },
);

其中 xFile 可以来自支持 Web 的文件选择或图片选择插件。Web 代码不能直接导入并使用 dart:ioFile;跨平台项目通常通过条件导入、插件抽象或分别实现文件读取层来隔离差异。

一个平台中立的上传接口可以接收字节和文件名:

Future<Response<Map<String, dynamic>>> uploadBytes({
  required Dio dio,
  required List<int> bytes,
  required String filename,
  required CancelToken cancelToken,
  void Function(int sent, int total)? onSendProgress,
}) {
  final formData = FormData.fromMap(
    <String, dynamic>{
      'file': MultipartFile.fromBytes(
        bytes,
        filename: filename,
      ),
    },
  );

  return dio.post<Map<String, dynamic>>(
    '/upload',
    data: formData,
    cancelToken: cancelToken,
    onSendProgress: onSendProgress,
  );
}

这种接口可以在移动端把文件读成字节后调用,也可以在 Web 中直接调用,但将整个文件读入内存会增加内存压力。大文件上传应结合平台能力和服务端协议决定是否采用流式或分片方案。

5.3 上传的取消和重试

上传取消:

final cancelToken = CancelToken();

try {
  await uploadBytes(
    dio: dio,
    bytes: fileBytes,
    filename: 'report.pdf',
    cancelToken: cancelToken,
    onSendProgress: (sent, total) {
      // 更新状态
    },
  );
} on DioException catch (error) {
  if (CancelToken.isCancel(error)) {
    // 不提示“上传失败”,而是处理用户主动取消
  } else {
    // 转换为上传错误
  }
}

// 用户点击取消时:
cancelToken.cancel('用户取消上传');

上传重试不能简单套用 GET 的策略。即使接口是 POST,也可能通过幂等键保证重复提交安全;但文件数据是否可以重建、服务端是否会产生孤儿文件,仍需单独确认。


6. Dio 的错误模型:传输错误、HTTP 错误和业务错误

6.1 DioException 与 HTTP 状态码不是同一层

Dio 抛出的 DioException 可能表示:

  • 请求没有得到 HTTP 响应,例如连接失败;
  • 得到了响应,但状态码被 validateStatus 判定为错误;
  • 请求被取消;
  • 请求、发送或接收超时;
  • 转换器或其他未知错误。

因此下面两类错误必须区分:

传输层错误:
客户端没有拿到可用的 HTTP 响应。

HTTP 层错误:
客户端拿到了 HTTP 响应,例如 401、404、500。

业务层错误:
HTTP 可能是 200,但 JSON 中 code 表示业务失败。

Dio 常见的 DioExceptionType 包括:

  • connectionTimeout
  • sendTimeout
  • receiveTimeout
  • badCertificate
  • badResponse
  • cancel
  • connectionError
  • unknown

badResponse 是否产生,受 validateStatus 影响。默认情况下,非成功状态通常会进入错误分支;如果改变了 validateStatus,某些状态可能作为普通 Response 返回。

6.2 稳定的业务错误模型

UI 和领域层不应依赖 DioExceptionType 的每个细节。可以定义自己的错误模型:

sealed class ApiFailure {
  const ApiFailure();
}

final class NetworkFailure extends ApiFailure {
  const NetworkFailure(this.message);

  final String message;
}

final class TimeoutFailure extends ApiFailure {
  const TimeoutFailure();
}

final class CancelledFailure extends ApiFailure {
  const CancelledFailure();
}

final class HttpFailure extends ApiFailure {
  const HttpFailure({
    required this.statusCode,
    this.message,
  });

  final int statusCode;
  final String? message;
}

final class ServerBusinessFailure extends ApiFailure {
  const ServerBusinessFailure({
    required this.code,
    required this.message,
  });

  final String code;
  final String message;
}

final class DecodeFailure extends ApiFailure {
  const DecodeFailure(this.message);

  final String message;
}

final class UnknownFailure extends ApiFailure {
  const UnknownFailure(this.error);

  final Object error;
}

使用 Dart 3 的模式匹配,调用方可以明确处理每一种结果:

Future<void> loadUser() async {
  final result = await repository.loadUser('42');

  switch (result) {
    case UserLoaded(:final user):
      print(user.name);
    case UserLoadFailed(:final failure):
      switch (failure) {
        case CancelledFailure():
          break;
        case TimeoutFailure():
          showRetryButton();
        case HttpFailure(:final statusCode):
          if (statusCode == 401) {
            redirectToLogin();
          }
        case NetworkFailure():
          showOfflineMessage();
        case ServerBusinessFailure(:final message):
          showMessage(message);
        case DecodeFailure():
        case UnknownFailure():
          showGenericError();
      }
  }
}

相比直接把 DioException 传到 UI,这种模型的优势是:基础库可以替换,页面也不需要知道底层适配器的具体错误类型。

6.3 将 DioException 映射为 ApiFailure

ApiFailure mapDioException(DioException error) {
  if (CancelToken.isCancel(error) ||
      error.type == DioExceptionType.cancel) {
    return const CancelledFailure();
  }

  switch (error.type) {
    case DioExceptionType.connectionTimeout:
    case DioExceptionType.sendTimeout:
    case DioExceptionType.receiveTimeout:
      return const TimeoutFailure();

    case DioExceptionType.connectionError:
      return NetworkFailure(
        error.message ?? '无法连接到服务器',
      );

    case DioExceptionType.badResponse:
      final statusCode = error.response?.statusCode;

      if (statusCode == null) {
        return const UnknownFailure('响应缺少状态码');
      }

      final body = error.response?.data;

      if (body is Map<String, dynamic> &&
          body['code'] != null &&
          body['message'] is String) {
        return ServerBusinessFailure(
          code: body['code'].toString(),
          message: body['message'] as String,
        );
      }

      return HttpFailure(
        statusCode: statusCode,
        message: body is Map<String, dynamic>
            ? body['message']?.toString()
            : null,
      );

    case DioExceptionType.badCertificate:
      return const NetworkFailure('TLS 证书校验失败');

    case DioExceptionType.unknown:
      return UnknownFailure(error);
  }
}

这里不能只根据 HTTP 状态码判断全部业务结果。例如服务端可能返回:

{
  "code": "INSUFFICIENT_BALANCE",
  "message": "余额不足"
}

HTTP 状态可能是 200,但业务仍然失败。此时仓库层应在成功响应后检查业务协议:

class User {
  const User({
    required this.id,
    required this.name,
  });

  final String id;
  final String name;

  factory User.fromJson(Map<String, dynamic> json) {
    return User(
      id: json['id'] as String,
      name: json['name'] as String,
    );
  }
}

Future<User> fetchUser(Dio dio, String id) async {
  final response = await dio.get<Map<String, dynamic>>('/users/$id');
  final body = response.data;

  if (body == null) {
    throw const FormatException('响应体为空');
  }

  final code = body['code'];
  if (code != null && code != 'OK' && code != 0) {
    throw ServerBusinessFailure(
      code: code.toString(),
      message: body['message']?.toString() ?? '业务请求失败',
    );
  }

  final userJson = body['data'];
  if (userJson is! Map<String, dynamic>) {
    throw const FormatException('data 不是对象');
  }

  return User.fromJson(userJson);
}

FormatException 还应在仓库边界被转换为 DecodeFailure,否则 UI 层会同时处理 DioExceptionFormatException 和业务异常,错误类型会逐渐失控。


7. 一个端到端的网络层封装

下面组合公共配置、拦截器、请求取消和错误转换:

class ApiClient {
  ApiClient({
    required String baseUrl,
    required Future<String?> Function() readToken,
  }) {
    dio = Dio(
      BaseOptions(
        baseUrl: baseUrl,
        connectTimeout: const Duration(seconds: 10),
        sendTimeout: const Duration(seconds: 30),
        receiveTimeout: const Duration(seconds: 30),
        headers: <String, dynamic>{
          'Accept': 'application/json',
        },
      ),
    );

    dio.interceptors.add(
      HeaderInterceptor(readToken),
    );

    dio.interceptors.add(
      LogInterceptor(
        requestBody: false,
        responseBody: false,
      ),
    );

    dio.interceptors.add(
      RetryInterceptor(
        dio: dio,
        maxRetries: 2,
      ),
    );
  }

  late final Dio dio;

  Future<T> getJson<T>(
    String path, {
    CancelToken? cancelToken,
    T Function(dynamic data)? decode,
  }) async {
    try {
      final response = await dio.get<dynamic>(
        path,
        cancelToken: cancelToken,
      );

      return decode == null
          ? response.data as T
          : decode(response.data);
    } on DioException catch (error) {
      throw mapDioException(error);
    } on FormatException catch (error) {
      throw DecodeFailure(error.message);
    }
  }
}

调用:

final client = ApiClient(
  baseUrl: 'https://api.example.com',
  readToken: () async => tokenStore.readAccessToken(),
);

final cancelToken = CancelToken();

try {
  final user = await client.getJson<User>(
    '/users/42',
    cancelToken: cancelToken,
    decode: (data) {
      if (data is! Map<String, dynamic>) {
        throw const FormatException('用户响应不是 JSON 对象');
      }

      return User.fromJson(data);
    },
  );

  print(user.name);
} on ApiFailure catch (failure) {
  switch (failure) {
    case CancelledFailure():
      break;
    case TimeoutFailure():
      print('请求超时');
    case NetworkFailure(:final message):
      print(message);
    case HttpFailure(:final statusCode):
      print('HTTP 错误:$statusCode');
    case ServerBusinessFailure(:final message):
      print(message);
    case DecodeFailure(:final message):
      print('数据格式错误:$message');
    case UnknownFailure(:final error):
      print('未知错误:$error');
  }
}

这段结构中,各层职责是:

Dio:
  建立连接、发送请求、接收响应、产生底层异常

拦截器:
  公共头、日志、认证、有限重试

ApiClient:
  统一调用入口,将 DioException 转换为 ApiFailure

Repository:
  解析接口协议,构造领域对象

页面或状态管理:
  根据 ApiFailure 决定加载、重试、登录、提示或忽略取消

如果 decode 抛出 FormatException,它不会被 on DioException 捕获,而会被后面的 on FormatException 捕获。这是 Dart 异常类型匹配的结果,不能只写一个 catch (error) 后把所有问题都标成网络错误。


8. HTTP 错误、网络错误和服务器业务错误的诊断

遇到请求失败时,应按照“有没有响应”分层诊断。

8.1 没有 HTTP 响应

典型表现:

error.response == null

可能原因:

  • DNS 解析失败;
  • 没有网络连接;
  • TLS 握手失败;
  • 连接超时;
  • 代理或防火墙中断;
  • 用户取消;
  • Web 浏览器阻止了请求。

此时 statusCode 不存在,不能显示为“服务器返回 500”。

8.2 有 HTTP 响应但状态码异常

典型表现:

error.response != null
error.response?.statusCode == 401

这说明请求已经到达了某个 HTTP 服务,并获得了响应。诊断重点是:

  • 服务端返回了什么状态码;
  • response.data 的格式是什么;
  • 是否有请求 ID;
  • 是否是认证过期、权限不足、限流或服务端故障。

例如 429 通常意味着服务端限流。可以读取 Retry-After,但要尊重服务端给出的等待时间,而不是无条件使用客户端固定退避。

8.3 HTTP 成功但业务失败

这种情况需要解析业务协议。若所有接口都使用统一结构,可以定义:

class ApiEnvelope<T> {
  const ApiEnvelope({
    required this.code,
    required this.message,
    required this.data,
  });

  final String code;
  final String message;
  final T? data;
}

但不要假设所有接口都一定使用相同 JSON 外壳。文件下载、纯文本、空响应和第三方接口可能有完全不同的格式,应按接口契约解析。


9. 平台差异与网络层边界

Android、iOS 和桌面

这些平台通常通过 Dart IO 网络栈和 Dio 的 IO 适配器访问网络。需要额外处理:

  • Android 的网络权限和明文 HTTP 配置;
  • iOS 的 App Transport Security 对非 HTTPS 请求的限制;
  • 桌面系统的代理、证书存储和文件路径差异;
  • 本地文件上传权限和沙盒路径;
  • 证书校验失败不能通过无条件接受坏证书来“修复”。

生产环境不应使用接受所有 TLS 证书的配置。那会把中间人攻击伪装成网络可用。

Web

Dio Web 请求受浏览器安全模型约束:

  • 跨域请求必须满足服务端 CORS 配置;
  • 浏览器会控制部分请求头,客户端不能像原生应用一样任意修改;
  • Cookie、凭证和跨域策略由浏览器与服务端共同决定;
  • 不能直接使用 dart:ioFile
  • 上传通常需要字节、浏览器文件对象或插件提供的抽象;
  • 取消请求不等同于保证服务器停止处理;
  • 底层连接、代理和证书细节由浏览器控制,诊断信息少于原生平台。

因此,跨平台网络层可以统一业务调用接口,但不应假设每个平台拥有完全相同的底层能力。


10. 常见错误用法及其失败原因

把所有错误都重试

try {
  return await dio.post('/orders', data: order);
} catch (_) {
  return await dio.post('/orders', data: order);
}

失败原因有两个:

  1. POST 可能已经在服务端成功,客户端只是没有收到响应;
  2. 第二次请求可能创建重复订单。

应使用有限次数、退避策略和服务端幂等保证。

在重试时复用不可重复的流

final stream = file.openRead();

await dio.post(
  '/upload',
  data: stream,
);

如果这个流已经读取过一部分,失败后直接复用不能保证从头发送。重试前应重新创建可读流,或使用分片上传协议。

把取消提示成网络错误

catch (error) {
  showError('网络请求失败');
}

用户返回页面、切换搜索关键词或主动点击取消时,取消是预期控制流,不应弹出错误提示。必须优先识别 DioExceptionType.cancel

在日志中打印敏感信息

LogInterceptor 可能打印请求头、请求体和响应体。生产日志应避免输出:

  • Authorization
  • Cookie;
  • 密码;
  • 身份证件和支付信息;
  • 大文件内容;
  • 个人隐私数据。

如果需要诊断,应记录 URL 模板、状态码、耗时、请求 ID 和脱敏后的错误信息。

手动设置 multipart Content-Type

手动设置 multipart/form-data 可能丢失 boundary,导致服务端报“表单格式错误”。让 Dio 根据 FormData 构造请求,除非明确理解底层编码并自行维护 boundary。

在拦截器中直接操作页面

拦截器中执行 Navigator 跳转或显示 SnackBar,会把网络基础设施与 UI 生命周期耦合。多个请求同时失败时还可能重复跳转。更稳定的做法是把 401 转换成认证状态事件,由认证状态管理层决定页面行为。


11. 测试网络层时应验证哪些故障路径

网络层的测试重点不是只验证 200,而是验证状态转换和错误边界:

  1. GET 成功并正确解析 JSON;
  2. 返回 401 后只刷新一次 token;
  3. 重试次数达到上限后不再发送;
  4. 用户取消后不进入重试;
  5. 500 可以按策略重试;
  6. 400 不重试;
  7. POST 没有幂等键时不自动重试;
  8. 上传进度中 total <= 0 时不会除零;
  9. 响应格式错误被映射为 DecodeFailure
  10. 页面销毁后,取消异常不会触发错误提示;
  11. 重试上传时请求体确实被重新构造;
  12. Web 不依赖 dart:io 文件 API。

测试时可以为 Dio 注入测试适配器,或在仓库边界使用可替换的 HTTP 客户端接口。关键是让“服务器响应”“网络连接失败”“取消”和“解码失败”能够独立制造,而不是依赖真实网络的不稳定行为。


12. 选择网络策略的判断顺序

一次请求进入网络层后,可以按下面顺序判断:

请求是否已取消?
  ├─ 是:直接结束,不重试
  └─ 否

是否收到 HTTP 响应?
  ├─ 否:根据网络错误和超时策略决定是否重试
  └─ 是

状态码是否可接受?
  ├─ 否:根据状态码、请求幂等性和次数决定是否重试
  └─ 是

业务 code 是否成功?
  ├─ 否:转换为业务错误,通常不由通用网络重试处理
  └─ 是

响应数据是否符合模型?
  ├─ 否:转换为解码错误
  └─ 是:返回领域对象

这个顺序体现了几个不能混淆的事实:

  • 取消是控制流,不是网络故障;
  • HTTP 错误和业务错误属于不同协议层;
  • 重试首先依赖错误的临时性,其次依赖操作的幂等性;
  • 上传的请求体可能不可重复;
  • 页面只应接收稳定的领域结果和错误模型,不应承担 Dio 细节。

Dio 适合承担 HTTP 传输、拦截器编排、取消、超时、上传和有限重试;认证状态、业务协议解析、领域错误和页面展示则应在更高层完成。这样网络层的每个故障路径都有明确归属,跨平台差异也能被限制在适配层内。


系列导航与关联阅读

官方资料

本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。