6.5 KiB
6.5 KiB
05. 网络层设计
决策
使用 dio(^5.11.0,2026-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及其子类(如NetworkException、UnauthorizedException、BusinessException),不直接处理DioException——异常归一化只在core_network内部发生一次。 - 每个 feature 的 repository 只声明自己需要的接口方法,不直接拼接完整 URL 字符串到处写,
baseUrl和超时时间统一在core_network里按环境配置(见 08-build-flavors.md,待写)。 - 需要取消请求的场景(比如页面销毁时中断未完成的请求)统一使用
CancelToken,在对应Notifier/State的dispose时调用cancel()。
参考链接
附录:dio 是什么,日常怎么用
给还没接触过这套网络层封装方式的同学看的入门说明。
要解决的问题
Dart 内置的 http 包只提供最基础的请求能力,实际项目里几乎总会遇到这些需求:
- 每个请求都要带 token,token 过期了要先刷新再重试,不能让每个业务代码自己判断"要不要刷新 token"。
- 统一的错误处理:网络超时、服务端返回的业务错误码、HTTP 状态码错误,最终都要转换成 UI 层能直接判断的统一异常类型,而不是每个 feature 各自写一遍
try/catch判断状态码。 - 请求/响应日志:调试时想看到完整的请求参数和返回值,上线后又不希望日志暴露敏感数据。
http 包本身没有"拦截器"这个概念,要实现上面这些都得自己在每次调用前后手动包一层逻辑,容易漏、容易不一致。dio 内置了 Interceptor 机制,可以把这些横切逻辑集中写一次,所有请求自动生效。
核心概念
Dio实例:一个 client 对象,带BaseOptions(baseUrl、connectTimeout等默认配置),项目里应该只有一个全局实例,而不是每个地方各建一个。Interceptor:可以拦截请求发出前(onRequest)、响应回来后(onResponse)、出错时(onError)三个时机,多个拦截器按注册顺序像洋葱一样依次包裹。DioException:dio 所有的错误(超时、连接失败、非 2xx 状态码等)都会包装成这个类型,可以在拦截器里统一识别、转换。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
}