Files
conti-docs/flutter-app/05-networking.md
T

22 KiB
Raw Blame History

05. 网络层设计

决策

使用 dio^5.11.02026-08 快照)作为唯一 HTTP client,统一封装在 core_network 包里,通过拦截器链处理鉴权、日志、响应解包、异常归一化,不允许各 feature_* 自建 Dio 实例。

feature_* 的 repository 不直接依赖 Dio,而是依赖 core_network 暴露的 ApiClient——原因见下文「为什么要在 Dio 外面再包一层 ApiClient」。

依赖

dependencies:
  dio: ^5.11.0
  uuid: ^4.5.1        # 生成客户端 traceId

使用规则

  • core_network 暴露一个单例 Dio 实例和基于它的 ApiClient(通过 Riverpod provider 注入,见 03-state-management.md),所有 feature_* 的 repository 只能通过依赖注入拿这个实例,不允许 Dio() 直接 new。
  • 拦截器按固定顺序注册:LogInterceptor(仅 dev/staging 环境开启)→ AuthInterceptor(附加 token,401 时串行刷新)→ ApiResultInterceptor(解开后端统一响应包装)→ ErrorMappingInterceptor(把 DioException 统一转成项目自定义的 AppException 体系)。
  • 业务代码只捕获 AppException 及其子类(如 NetworkExceptionUnauthorizedExceptionBusinessException),不直接处理 DioException——异常归一化只在 core_network 内部发生一次。
  • 每个 feature 的 repository 只声明自己需要的接口方法,不直接拼接完整 URL 字符串到处写,baseUrl 和超时时间统一在 core_network 里按环境配置(见 08-build-flavors.md)。
  • 需要取消请求的场景(比如页面销毁时中断未完成的请求)统一使用 CancelToken,在对应 provider 的 ref.onDispose 里调用 cancel()

后端契约:统一响应包装

后端所有接口返回 ApiResult<T> { code, message, data, traceId }(见 backend/06-api-design.md)。解包只在 ApiResultInterceptor 里做一次repository 拿到的 response.data 已经是里层的 data

// packages/core_network/lib/src/api_result_interceptor.dart
class ApiResultInterceptor extends Interceptor {
  ApiResultInterceptor(this._logger);
  final AppLogger _logger;

  @override
  void onResponse(Response response, ResponseInterceptorHandler handler) {
    final body = response.data;
    // 非 JSON 对象响应(如文件下载)不走解包
    if (body is! Map<String, dynamic> || !body.containsKey('code')) {
      return handler.next(response);
    }

    // 用 num 而不是 int:万一后端某个字段序列化成了 JSON 浮点数,as int 会直接抛
    final code = (body['code'] as num?)?.toInt();
    final traceId = body['traceId'] as String?;
    _logger.d('[api] ${response.requestOptions.uri} code=$code traceId=$traceId');

    if (code == 0) {
      // 把外层包装剥掉,repository 的 fromJson 只需要认识 data 的结构
      response.data = body['data'];
      return handler.next(response);
    }

    // code != 0 一律转成业务异常,不让它以"成功响应"的形态流到业务层
    handler.reject(
      DioException(
        requestOptions: response.requestOptions,
        response: response,
        error: BusinessException(
          code: code ?? -1,
          message: (body['message'] as String?) ?? '请求失败',
          traceId: traceId,
        ),
      ),
      true, // callFollowingErrorInterceptor
    );
  }
}

traceId 必须留存backend 06/08 明确指望"用户报一个 traceId,后端就能在日志里定位这次请求"。所以

  • 每条 API 日志都带 traceId(成功失败都带)。
  • 错误提示 UI 上要能看到 traceId(不用显眼,可以放在"详情"里或长按复制),具体展示形式见 12-error-and-api-contract.md
  • 崩溃/错误上报时把 traceId 作为 tag 带上(见 13-observability-analytics.md)。

统一请求头

// packages/core_network/lib/src/header_interceptor.dart
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
  final env = _ref.read(appEnvProvider);
  options.headers.addAll({
    'X-Trace-Id': const Uuid().v4(),            // 客户端生成,便于端到端串联
    'X-App-Version': env.appVersion,             // 如 1.4.0+142
    'X-Platform': Platform.isIOS ? 'ios' : 'android',
    'X-Device-Id': _ref.read(deviceIdProvider),  // 安装级匿名 ID,不是 IMEI/IDFA
  });
  // 当前门店上下文;未登录/未选门店时不带
  final storeId = _ref.read(currentStoreIdProvider.select((s) => s));
  if (storeId != null) options.headers['X-Store-Id'] = '$storeId';
  handler.next(options);
}
  • X-Store-Id冗余信息access token 的 claims 里已经有 storeIdbackend 04),后端以 token 为准。带这个头只是为了日志排查时能一眼看出客户端当时认为自己在哪个门店——如果两者不一致,说明切换门店后 token 没换,是个 bug 信号。
  • X-Trace-Id 需要和后端对齐一次backend 06 说 traceId 由后端入口 filter 生成。约定是后端优先复用请求头里的 X-Trace-Id,没有才自己生成,否则客户端日志和服务端日志会各用一套 ID 对不上。这条挂在待确认项里。
  • 不采集 IMEI/IDFA/MAC 等设备唯一标识,deviceId 用首次安装时生成并存本地的随机 UUID,避免踩合规红线(见 07-native-integration.md 的隐私清单部分)。

Token 刷新:必须串行,失败即登出

这一段是整个网络层最容易写错、错了后果最严重的地方,因为它和后端的 refresh token 轮换策略强耦合。

backend/04-security-auth.md

  • refresh token 是一次性的,每次换 access token 都会签发新的、旧的立刻 revokedAt
  • 旧 token 再被用一次 = 判定为泄漏重放,该用户名下所有 refresh token 全部撤销

由此推出三条客户端硬性约束:

  1. 绝对不能并发刷新。 两个请求同时 401、同时拿同一个旧 refresh token 去换,第二个必然被判为重放 → 用户被全设备强制登出。这就是刷新队列存在的真正原因,不是为了"省一次请求"。
  2. 刷新失败不能重试。 失败意味着 refresh token 已过期/已撤销/已被重放,再试一次结果一样。直接登出跳登录页。
  3. 刷新请求本身不能走带 AuthInterceptor 的那个 Dio,否则刷新接口返回 401 时会再次触发刷新,无限递归。core_auth 内部自建一个Dio(不装任何拦截器)专门发刷新请求——这也是 01-project-structure.md 里 "core_auth 不依赖 core_network" 这条规则的由来。
// packages/core_network/lib/src/auth_interceptor.dart
class AuthInterceptor extends Interceptor {
  AuthInterceptor(this._ref);
  final Ref _ref;

  /// 同一时刻最多一个刷新在跑;其他 401 请求 await 同一个 Future
  Future<void>? _refreshing;

  static const _retriedKey = 'x-retried';

  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
    final token = _ref.read(authStateProvider).accessToken;
    if (token != null) options.headers['Authorization'] = 'Bearer $token';
    handler.next(options);
  }

  @override
  void onError(DioException err, ErrorInterceptorHandler handler) async {
    if (err.response?.statusCode != 401) return handler.next(err);

    // 一次性重试标记:带着新 token 重放后又 401,说明不是 token 的问题,别再刷了
    if (err.requestOptions.extra[_retriedKey] == true) {
      _ref.read(authStateProvider.notifier).logout();
      return handler.next(err);
    }

    try {
      // 用一个共享的 Future 天然实现串行:先到的发起刷新,后到的复用同一个 Future
      _refreshing ??= _ref.read(authRepositoryProvider).refreshToken();
      await _refreshing;
    } catch (e) {
      // 刷新失败 = refresh token 已失效,不重试,直接登出
      _ref.read(authStateProvider.notifier).logout();
      return handler.next(err);
    } finally {
      _refreshing = null;
    }

    // 刷新成功,用新 token 重放原请求
    try {
      final options = err.requestOptions
        ..extra[_retriedKey] = true
        ..headers['Authorization'] =
            'Bearer ${_ref.read(authStateProvider).accessToken}';
      handler.resolve(await _ref.read(dioProvider).fetch(options));
    } on DioException catch (e) {
      handler.next(e);
    }
  }
}

对比:常见的"bool _isRefreshing + List<Completer> 队列"写法有个致命缺陷——catch 分支里如果忘了对队列里的 CompletercompleteError 并清空,所有排队的请求会永久挂起await completer.future 永不返回),表现是 UI 一直转圈、用户只能杀进程。用共享 Future 的写法从结构上就不存在这个问题:刷新失败时 await _refreshing 对每个等待者都会抛异常,各自走各自的 catch,没有需要手动清理的队列。

core_auth 侧的刷新实现:

// packages/core_auth/lib/src/token_refresher.dart
class TokenRefresher {
  // 裸 Dio:不装任何拦截器,避免刷新请求自己再触发一轮刷新
  final _bare = Dio(BaseOptions(
    baseUrl: AppEnv.current.apiBaseUrl,
    connectTimeout: const Duration(seconds: 10),
  ));

  Future<TokenPair> refresh(String refreshToken) async {
    final res = await _bare.post('/api/v1/auth/refresh', data: {'refreshToken': refreshToken});
    final data = res.data['data'] as Map<String, dynamic>; // 裸 Dio 没有解包拦截器,手动取
    // 后端轮换:新的 refreshToken 必须立刻覆盖存储,旧的已经作废了
    return TokenPair(
      accessToken: data['accessToken'] as String,
      refreshToken: data['refreshToken'] as String,
    );
  }
}

新的 refresh token 一定要写回 secure storage(见 06-local-storage.md)。写回失败或写回前进程被杀,下次启动用旧 token 就会触发重放判定——所以写回要在"通知 authState 更新"之前完成。

异常归一化

// packages/core_network/lib/src/error_mapping_interceptor.dart
class ErrorMappingInterceptor extends Interceptor {
  @override
  void onError(DioException err, ErrorInterceptorHandler handler) {
    // 已经是 AppException 的(比如 ApiResultInterceptor 抛的 BusinessException)直接放行,
    // 不要二次包装成 NetworkException
    if (err.error is AppException) return handler.next(err);

    final mapped = switch (err.type) {
      DioExceptionType.connectionTimeout ||
      DioExceptionType.sendTimeout ||
      DioExceptionType.receiveTimeout => NetworkException('网络超时,请检查网络后重试'),
      DioExceptionType.cancel => RequestCancelledException(),
      DioExceptionType.badResponse when err.response?.statusCode == 401 =>
        UnauthorizedException(),
      DioExceptionType.badResponse => HttpException(
          statusCode: err.response?.statusCode ?? -1,
          message: '服务异常(${err.response?.statusCode}',
        ),
      _ => NetworkException('网络异常,请稍后重试'),
    };
    handler.next(DioException(
      requestOptions: err.requestOptions,
      response: err.response,
      error: mapped,
    ));
  }
}

为什么要在 Dio 外面再包一层 ApiClient

拦截器没有办法让 dio.get() 抛出 AppException。dio 的错误通道只认 DioExceptionhandler.reject(...) 传进去的必须是 DioException,我们的 AppException 只能挂在它的 error 字段上。也就是说,如果 repository 直接调 dio.get(),业务层写

try { ... } on UnauthorizedException { ... }   // ❌ 永远进不来

捕获不到的——实际抛出来的仍然是 DioException

解决办法是在 core_network 的出口把 DioException.error 拆出来重抛:

// packages/core_network/lib/src/api_client.dart
class ApiClient {
  ApiClient(this._dio);
  final Dio _dio;

  Future<T> get<T>(String path, {Map<String, dynamic>? query, CancelToken? cancelToken}) =>
      _run(() => _dio.get<T>(path, queryParameters: query, cancelToken: cancelToken));

  Future<T> post<T>(String path, {Object? data, CancelToken? cancelToken}) =>
      _run(() => _dio.post<T>(path, data: data, cancelToken: cancelToken));

  Future<T> _run<T>(Future<Response<T>> Function() send) async {
    try {
      final res = await send();
      return res.data as T;
    } on DioException catch (e, st) {
      final error = e.error;
      // 拦截器已经归一化过,这里只负责把它从 DioException 的壳里拿出来重抛
      if (error is AppException) Error.throwWithStackTrace(error, st);
      Error.throwWithStackTrace(NetworkException('网络异常,请稍后重试'), st);
    }
  }
}

规则:repository 一律注入 ApiClient,不注入 Dio 只有 core_network 内部和 core_auth 的裸 Dio 会直接碰 Dio 类型。这样上面那段 on UnauthorizedException 才真的成立。

Error.throwWithStackTrace 保留原始堆栈,否则上报到崩溃平台的堆栈会全部指向 _run 这一行,等于没有堆栈。

超时、重试与幂等

BaseOptions(
  connectTimeout: const Duration(seconds: 10),
  receiveTimeout: const Duration(seconds: 15),
  sendTimeout: const Duration(seconds: 30),   // 上传单独放宽,见下文
)

默认不做自动重试。 理由和 03-state-management.md 里全局关掉 Riverpod retry 是同一条:多层重试叠加会让一次用户操作变成难以预测的 N 次请求,日志也没法看。需要重试的地方显式写、并且必须满足:

  • 只重试 GET,或后端明确支持幂等键(Idempotency-Key 头)的 POST。
  • 只对超时/连接失败重试,业务错误码和 4xx 不重试。
  • 最多 1 次。

f6-integration 侧后端已经配了重试和熔断(backend/05-integration-layer.md),客户端再叠一层意义不大,反而会把后端的熔断窗口打满。

CancelToken 与 provider 生命周期

@riverpod
Future<List<PurchaseOrder>> purchaseOrders(Ref ref) async {
  final cancelToken = CancelToken();
  ref.onDispose(cancelToken.cancel);  // 页面销毁 / 门店切换导致 provider 重建时自动中断

  final storeId = ref.watch(currentStoreIdProvider);
  return ref.watch(purchaseRepositoryProvider).fetchOrders(storeId, cancelToken: cancelToken);
}

被取消的请求会抛 RequestCancelledExceptionUI 层必须把它当"什么都不做"处理,不能弹错误提示——用户主动离开页面时看到"请求失败"是很糟的体验。这条在 12-error-and-api-contract.md 的错误展示规则里统一约定。

文件与图片上传

PRD 第 7.3 节的 JSBridge 能力清单(H5 桥接的图片选择/上传)和施工照片场景都要用到。

// packages/core_network/lib/src/api_client.dart
Future<T> upload<T>(
  String path, {
  required List<File> files,
  Map<String, dynamic>? fields,
  void Function(int sent, int total)? onProgress,
  CancelToken? cancelToken,
}) async {
  final formData = FormData.fromMap({
    ...?fields,
    'files': [
      for (final f in files)
        await MultipartFile.fromFile(f.path, filename: p.basename(f.path)),
    ],
  });
  return _run(() => _dio.post<T>(
        path,
        data: formData,
        cancelToken: cancelToken,
        onSendProgress: onProgress,
        // 上传单独放宽超时,用全局的 30s 传几张原图会超
        options: Options(sendTimeout: const Duration(minutes: 3)),
      ));
}

约定:

  • 上传前必须压缩。门店员工用手机直接拍的照片通常 3–8 MB,原图上传在门店 WiFi 环境下大概率超时。统一压到长边 1600px、JPEG 质量 80,超过 2 MB 再降一档。
  • 进度必须可见:多图上传要有整体进度,否则用户会以为卡死反复点。
  • 失败要能单张重传,不能因为第 5 张失败就让前 4 张重来。所以 UI 上传状态按单张维护。
  • FormData 不可重用dio 的 FormData 是流,重试必须重新构造一个,直接复用会报 stream already listened。

传输安全

  • 全环境强制 HTTPS,包括 dev。Android 侧在 network_security_config.xml 里关掉明文流量(cleartextTrafficPermitted="false"),iOS 不放开 ATS 例外。这样"某个环境不小心配了 http 的 baseUrl"会在开发阶段就直接失败,而不是上线后才发现。
  • 证书 pinning:首版不做。 取舍如下——pinning 能防中间人抓包,但代价是证书轮换时必须发新版 App,否则全线不可用;而门店 App 走的是公司自有域名 + 标准 CA,主要威胁模型是"员工手机装了抓包工具看接口",这个用 pinning 挡的收益不高。如果后续有合规要求再加,届时用双证书 pin(当前 + 备用) 并且 pin 到中间 CA 而不是叶子证书,留出轮换空间。
  • 日志脱敏:LogInterceptor 只在 dev/staging 开启,且 Authorization 头、密码、手机号在打日志前替换成掩码。这条同样适用于上报到崩溃平台的面包屑(见 13-observability-analytics.md)。

待确认项

  • X-Trace-Id 由客户端生成、后端复用——需与后端确认入口 filter 的实现。
  • 分页参数字段名(backend 06 的「待补充」里也挂着这一项,见 02-layering.md)。
  • 错误码表(backend 06 待补充),拿到后补进 12-error-and-api-contract.md 的映射表。
  • 上传接口的大小上限、允许的文件类型、是否走对象存储直传。

参考链接

附录:dio 是什么,日常怎么用

给还没接触过这套网络层封装方式的同学看的入门说明。

要解决的问题

Dart 内置的 http 包只提供最基础的请求能力,实际项目里几乎总会遇到这些需求:

  1. 每个请求都要带 token,token 过期了要先刷新再重试,不能让每个业务代码自己判断"要不要刷新 token"。
  2. 统一的错误处理:网络超时、服务端返回的业务错误码、HTTP 状态码错误,最终都要转换成 UI 层能直接判断的统一异常类型,而不是每个 feature 各自写一遍 try/catch 判断状态码。
  3. 请求/响应日志:调试时想看到完整的请求参数和返回值,上线后又不希望日志暴露敏感数据。

http 包本身没有"拦截器"这个概念,要实现上面这些都得自己在每次调用前后手动包一层逻辑,容易漏、容易不一致。dio 内置了 Interceptor 机制,可以把这些横切逻辑集中写一次,所有请求自动生效。

核心概念

  1. Dio 实例:一个 client 对象,带 BaseOptionsbaseUrlconnectTimeout 等默认配置),项目里应该只有一个全局实例,而不是每个地方各建一个。
  2. Interceptor:可以拦截请求发出前(onRequest)、响应回来后(onResponse)、出错时(onError)三个时机。注意 dio 的执行顺序:三个时机都是按注册顺序正向执行的,不是"请求正向、响应反向"的洋葱模型——这一点和很多人的直觉不同,配置拦截器顺序时要留意。
  3. DioException:dio 所有的错误(超时、连接失败、非 2xx 状态码等)都会包装成这个类型,可以在拦截器里统一识别、转换。
  4. CancelToken:用来主动取消一个还没完成的请求,常见场景是页面销毁或用户切走时中断请求,避免拿到结果时 widget 已经不存在了。

拦截器链的组装

// packages/core_network/lib/src/dio_client.dart
final dioProvider = Provider<Dio>((ref) {
  final env = ref.watch(appEnvProvider);
  final dio = Dio(BaseOptions(
    baseUrl: env.apiBaseUrl,                       // 见 08-build-flavors.md
    connectTimeout: const Duration(seconds: 10),
    receiveTimeout: const Duration(seconds: 15),
    sendTimeout: const Duration(seconds: 30),
  ));

  dio.interceptors.addAll([
    HeaderInterceptor(ref),
    if (env.enableLog) LogInterceptor(responseBody: false),
    AuthInterceptor(ref),
    ApiResultInterceptor(ref.watch(loggerProvider)),
    ErrorMappingInterceptor(),
  ]);

  return dio;
});

final apiClientProvider = Provider<ApiClient>((ref) => ApiClient(ref.watch(dioProvider)));

顺序的理由:AuthInterceptor 必须排在 ErrorMappingInterceptor 前面,才能在 401 被归一化成 UnauthorizedException 之前先尝试刷新 tokenApiResultInterceptor 排在 ErrorMappingInterceptor 前面,是因为它抛出的 BusinessException 需要能被后者识别并放行(后者第一行就是判断 err.error is AppException)。

业务层看到的样子

// data/repository/purchase_repository_impl.dart
class PurchaseRepositoryImpl implements PurchaseRepository {
  PurchaseRepositoryImpl(this._api);
  final ApiClient _api;

  @override
  Future<List<PurchaseOrder>> fetchOrders(int storeId, {CancelToken? cancelToken}) async {
    // 返回的已经是 ApiResult 里的 data,外层包装由拦截器解开
    final list = await _api.get<List<dynamic>>(
      '/api/v1/purchase/orders',
      query: {'storeId': storeId},
      cancelToken: cancelToken,
    );
    return list.map((e) => PurchaseOrder.fromJson(e as Map<String, dynamic>)).toList();
  }
}
// presentation 层
try {
  final orders = await repository.fetchOrders(storeId);
} on UnauthorizedException {
  // 已经被 AuthInterceptor 处理过登出,这里一般只需要静默
} on BusinessException catch (e) {
  showToast('${e.message}${e.traceId}');
} on RequestCancelledException {
  // 用户主动离开,什么都不做
} on AppException catch (e) {
  showToast(e.message);
}