# 05. 网络层设计 ## 决策 使用 **[dio](https://pub.dev/packages/dio)**(`^5.11.0`,2026-08 快照)作为唯一 HTTP client,统一封装在 `core_network` 包里,通过拦截器链处理鉴权、日志、异常归一化,不允许各 `feature_*` 自建 `Dio` 实例。 ## 依赖 ```yaml dependencies: dio: ^5.11.0 ``` ## 使用规则 - `core_network` 暴露一个单例 `Dio` 实例(通过 Riverpod provider 注入,见 [03-state-management.md](./03-state-management.md)),所有 `feature_*` 的 repository 只能通过依赖注入拿这个实例,不允许 `Dio()` 直接 new。 - 拦截器按固定顺序注册:`LogInterceptor`(仅 dev/staging 环境开启)→ `AuthInterceptor`(附加 token,401 时排队刷新)→ `ErrorMappingInterceptor`(把 `DioException` 统一转成项目自定义的 `AppException` 体系)。 - 业务代码只捕获 `AppException` 及其子类(如 `NetworkException`、`UnauthorizedException`、`BusinessException`),不直接处理 `DioException`——异常归一化只在 `core_network` 内部发生一次。 - 每个 feature 的 repository 只声明自己需要的接口方法,不直接拼接完整 URL 字符串到处写,`baseUrl` 和超时时间统一在 `core_network` 里按环境配置(见 [08-build-flavors.md](./08-build-flavors.md),待写)。 - 需要取消请求的场景(比如页面销毁时中断未完成的请求)统一使用 `CancelToken`,在对应 `Notifier`/`State` 的 `dispose` 时调用 `cancel()`。 ## 参考链接 - [dio 官方文档](https://pub.dev/packages/dio) - [dio | Dart package](https://pub.dev/packages/dio) - [Dio Interceptors 文档](https://pub.dev/packages/dio#interceptors) ## 附录:dio 是什么,日常怎么用 给还没接触过这套网络层封装方式的同学看的入门说明。 ### 要解决的问题 Dart 内置的 `http` 包只提供最基础的请求能力,实际项目里几乎总会遇到这些需求: 1. **每个请求都要带 token**,token 过期了要先刷新再重试,不能让每个业务代码自己判断"要不要刷新 token"。 2. **统一的错误处理**:网络超时、服务端返回的业务错误码、HTTP 状态码错误,最终都要转换成 UI 层能直接判断的统一异常类型,而不是每个 feature 各自写一遍 `try/catch` 判断状态码。 3. **请求/响应日志**:调试时想看到完整的请求参数和返回值,上线后又不希望日志暴露敏感数据。 `http` 包本身没有"拦截器"这个概念,要实现上面这些都得自己在每次调用前后手动包一层逻辑,容易漏、容易不一致。**dio** 内置了 [`Interceptor`](https://pub.dev/packages/dio#interceptors) 机制,可以把这些横切逻辑集中写一次,所有请求自动生效。 ### 核心概念 1. **`Dio` 实例**:一个 client 对象,带 `BaseOptions`(`baseUrl`、`connectTimeout` 等默认配置),项目里应该只有一个全局实例,而不是每个地方各建一个。 2. **`Interceptor`**:可以拦截请求发出前(`onRequest`)、响应回来后(`onResponse`)、出错时(`onError`)三个时机,多个拦截器按注册顺序像洋葱一样依次包裹。 3. **`DioException`**:dio 所有的错误(超时、连接失败、非 2xx 状态码等)都会包装成这个类型,可以在拦截器里统一识别、转换。 4. **`CancelToken`**:用来主动取消一个还没完成的请求,常见场景是页面销毁或用户切走时中断请求,避免拿到结果时 widget 已经不存在了。 ### 使用示例(token 自动附加 + 401 自动刷新排队 + 异常归一化) ```dart // packages/core_network/lib/src/dio_client.dart final dioProvider = Provider((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; }); ``` ```dart // packages/core_network/lib/src/auth_interceptor.dart class AuthInterceptor extends Interceptor { final Ref _ref; bool _isRefreshing = false; final _pendingRequests = >[]; 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(); _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 _retry(RequestOptions options) { final dio = _ref.read(dioProvider); return dio.fetch(options); } } ``` ```dart // 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 内部细节: ```dart try { final stores = await storeRepository.fetchNearbyStores(lat, lng); } on UnauthorizedException { // 跳登录页 } on AppException catch (e) { // 统一展示 e.message } ```