Files
conti-docs/05-networking.md
T

152 lines
6.5 KiB
Markdown
Raw Normal View History

# 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<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;
});
```
```dart
// 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);
}
}
```
```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
}
```