152 lines
6.5 KiB
Markdown
152 lines
6.5 KiB
Markdown
# 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
|
||
}
|
||
```
|