Files
conti-docs/05-networking.md
T

6.5 KiB
Raw Blame History

05. 网络层设计

决策

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

依赖

dependencies:
  dio: ^5.11.0

使用规则

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

参考链接

附录: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)三个时机,多个拦截器按注册顺序像洋葱一样依次包裹。
  3. DioException:dio 所有的错误(超时、连接失败、非 2xx 状态码等)都会包装成这个类型,可以在拦截器里统一识别、转换。
  4. CancelToken:用来主动取消一个还没完成的请求,常见场景是页面销毁或用户切走时中断请求,避免拿到结果时 widget 已经不存在了。

使用示例(token 自动附加 + 401 自动刷新排队 + 异常归一化)

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

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

  return dio;
});
// packages/core_network/lib/src/auth_interceptor.dart
class AuthInterceptor extends Interceptor {
  final Ref _ref;
  bool _isRefreshing = false;
  final _pendingRequests = <Completer<void>>[];

  AuthInterceptor(this._ref);

  @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);

    if (_isRefreshing) {
      // 已经有一个刷新请求在跑,排队等它完成,避免并发刷新
      final completer = Completer<void>();
      _pendingRequests.add(completer);
      await completer.future;
      return handler.resolve(await _retry(err.requestOptions));
    }

    _isRefreshing = true;
    try {
      await _ref.read(authRepositoryProvider).refreshToken();
      for (final c in _pendingRequests) {
        c.complete();
      }
      _pendingRequests.clear();
      handler.resolve(await _retry(err.requestOptions));
    } catch (_) {
      _ref.read(authStateProvider.notifier).logout();
      handler.next(err);
    } finally {
      _isRefreshing = false;
    }
  }

  Future<Response> _retry(RequestOptions options) {
    final dio = _ref.read(dioProvider);
    return dio.fetch(options);
  }
}
// packages/core_network/lib/src/error_mapping_interceptor.dart
class ErrorMappingInterceptor extends Interceptor {
  @override
  void onError(DioException err, ErrorInterceptorHandler handler) {
    final mapped = switch (err.type) {
      DioExceptionType.connectionTimeout ||
      DioExceptionType.receiveTimeout => NetworkException('网络超时'),
      DioExceptionType.badResponse when err.response?.statusCode == 401 =>
        UnauthorizedException(),
      DioExceptionType.badResponse => BusinessException(
          err.response?.data['message'] ?? '请求失败',
          code: err.response?.statusCode,
        ),
      _ => NetworkException('网络异常,请稍后重试'),
    };
    handler.reject(DioException(requestOptions: err.requestOptions, error: mapped));
  }
}

业务层代码只需要这样写,不用关心 dio 内部细节:

try {
  final stores = await storeRepository.fetchNearbyStores(lat, lng);
} on UnauthorizedException {
  // 跳登录页
} on AppException catch (e) {
  // 统一展示 e.message
}