网络层封装:请求、拦截器、统一错误码
系列:网络与数据篇· 第 1/6 篇
1. 问题背景
业务里网络代码最容易长成这样:
- 每个接口各写一套
try/catch,错误文案有的弹 Toast、有的只打日志、有的直接白屏。 - 后端返回结构不统一:有时是
{code, message, data},有时 HTTP 200 包里塞业务失败;客户端用dynamic一路解析到底。 - Token 过期、切换环境、埋点、耗时统计全散在页面或某个「工具类」里,改一个地方要全文搜索。
- 测试/联调时要换 Host、Mock、加日志,只能改代码或注释拦截器。
表面上「能发请求」,实际上没有一层能把「协议约定」钉死,稳定性完全依赖个人习惯。
2. 原因分析
核心就两点:缺边界、缺契约。
- 边界:HTTP 客户端(Dio/http)、业务解析、UI 反馈是三件事。混写时,拦截器不敢动——怕影响某个页面的奇怪逻辑。
- 契约:没有统一的「业务错误模型」(code + message + trace + 是否可重试),就不会有一致的
onError分支;int code和字符串魔法值满天飞。 - 排查:线上问题来一句「接口报错」,若没有 requestId / 统一日志格式,基本只能猜。
所以封装的目标不是「包一层 Dio」,而是固定流水线:发什么头、怎么序列化、失败怎么归类、成功怎么落到类型上。
3. 解决方案
3.1 整体形状(建议)
分层可以很薄,但角色要清楚:
| 层 | 职责 |
|---|---|
| Client 工厂 | BaseUrl、超时、证书(如需)、单例/按环境注入 |
| 拦截器链 | Token、公共 Header、日志、耗时、刷新 Token、错误映射入口 |
| ApiService | 按资源/模块声明方法,返回 Future<Response<T>> 或自定义 ApiResult |
| Mapper | DTO → Domain(下一篇会展开),网络层只认 DTO |
| 错误类型 | AppException 枚举或 sealed class:network、business、auth、cancel 等 |
拦截器顺序(常见做法,按项目微调):
请求侧:追加 Header → 打印/埋点
响应侧:先看 HTTP 状态 → 再解包业务 code → 映射为统一异常 → 必要时刷新 Token 重放(单独拆开,避免递归地狱)
3.2 统一错误码怎么处理
- HTTP 层:4xx/5xx 先归到「传输/网关」类,带
statusCode。 - 业务层:只认后端的
code(或errno),在一张表里映射:SESSION_EXPIRED→ 跳转登录;NEED_VERIFY→ 特定弹窗。不要到处if (code == 40001)。 - 无法识别的 code:打上
unknown,带上原始 body 片段(注意脱敏)和requestId,方便后端对账。
3.3 和 UI 的边界
网络层抛出或返回 已经分好类 的结果;是否 Toast、是否重试按钮,由页面或全局 ErrorHandler 决定。拦截器里直接 Navigator 弹全局登录 —— 小团队可以,但要承认这是技术债,至少集中在一处。
4. 关键代码
下面是最小可运行思路的片段(Dio 为例,http 包同理只是没有拦截器链,需自己包装)。
4.1 统一异常
sealed class AppException implements Exception {
const AppException(this.message, {this.code, this.cause});
final String message;
final String? code;
final Object? cause;
}
class NetworkException extends AppException {
const NetworkException(super.message, {super.code, super.cause, this.statusCode});
final int? statusCode;
}
class BusinessException extends AppException {
const BusinessException(super.message, {super.code, super.cause});
}
class AuthException extends BusinessException {
const AuthException(super.message, {super.code, super.cause});
}
4.2 响应体约定 + 解析
class ApiEnvelope<T> {
ApiEnvelope({required this.code, required this.message, this.data});
final int code;
final String message;
final T? data;
bool get isSuccess => code == 0; // 按后端约定改
}
4.3 拦截器里收口业务 code
class BizCodeInterceptor extends Interceptor {
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
final data = response.data;
if (data is! Map) {
return handler.next(response);
}
final code = data['code'];
if (code is! int) {
return handler.next(response);
}
if (code == 0) {
return handler.next(response);
}
final msg = data['message']?.toString() ?? '业务失败';
// 按 code 表映射
if (code == 40101) {
return handler.reject(DioException(
requestOptions: response.requestOptions,
response: response,
type: DioExceptionType.badResponse,
error: AuthException(msg, code: '$code'),
));
}
return handler.reject(DioException(
requestOptions: response.requestOptions,
response: response,
type: DioExceptionType.badResponse,
error: BusinessException(msg, code: '$code'),
));
}
}
4.4 Client 组装
Dio createDio({required String baseUrl, String? token}) {
final dio = Dio(BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 20),
));
dio.interceptors.addAll([
LogInterceptor(requestBody: true, responseBody: true),
if (token != null)
InterceptorsWrapper(onRequest: (options, handler) {
options.headers['Authorization'] = 'Bearer $token';
handler.next(options);
}),
BizCodeInterceptor(),
]);
return dio;
}
4.5 调用侧统一 runCatching(可选)
Future<R> guardApi<R>(Future<R> Function() run) async {
try {
return await run();
} on DioException catch (e) {
final err = e.error;
if (err is AppException) throw err;
throw NetworkException(e.message ?? '网络异常', cause: e, statusCode: e.response?.statusCode);
}
}
真实项目里还可以:请求 ID(x-request-id 或用 uuid)在 onRequest 写入、onError 打一行结构化日志。
5. 效果验证
可以从这几件事看有没有包好:
- 改后端错误码:只动映射表 + 1~2 个 UI 分支,而不是全仓库 grep 魔法数字。
- 联调:换 BaseUrl、开关日志、临时 Mock,只动 Client/注入,不动业务页面。
- 线上问题:日志里能看到同一请求的
path + method + requestId + businessCode,基本能定位是网关还是业务。 - Code Review:新接口 PR 里不应该再出现手写
dio.post+ 散落的try/catch解析Map。
若你还没有「错误码表」,至少先维护一个 Markdown/枚举文档,和拦截器里的分支同步更新。
6. 可复用结论
- 拦截器做「协议与横切」,ApiService 做「资源与方法」,页面只做展示与交互。
- 业务失败与 HTTP 失败分开建模,否则重试、登录跳转、统计口径会搅在一起。
- 魔法数字集中映射,比「每个接口各自判断」便宜得多。
- 不要在拦截器里藏过多业务(例如复杂弹窗链);刷新 Token 要小心并发与重入。
避坑清单
- 拦截器里
reject了但外层又handler.next,重复回调。 - 刷新 Token 时所有请求一起重放,导致风暴;应用队列或「单飞」刷新。
- 日志打完整 body,泄露手机号、Token。
-
dynamic解析无兜底,null崩溃留在生产。
下一篇预告:DTO/Model 转换策略与类型安全(怎么避免「半个 Map 在全网跑」)。