我那个通讯录 App 的网络层,第一版是每个页面自己 Dio().get(...),catch 里各写各的提示。功能都能跑,我也没觉得有问题,直到后端把 token 的 header 名从 X-Token 换成 Authorization。我全文搜了一遍才找齐——11 处,其中 3 处藏在注销账号和退出登录这两个平时不怎么会走的分支里。改完整改了半小时。之后又遇到一次「所有请求统一加超时」,还是那 11 处,一轮重复。
真正让我觉得这层该重构的是另一件事:同一句「网络断了」,在四个页面里有四种文案,其中一种是「加载失败,请重试」,另一种直接是 err.toString(),屏幕上甩出一长串带英文的文本。还有一次更怪的:详情页请求还没回来我就退了出来,回到列表页,App 闪退了,控制台里是一行我没见过的框架报错。这一篇记录我从那种状态走到「配置只有一处、错误只翻译一次」的过程,中间几个决定和代价我都写下来。
http 与 dio 之间我的取舍
我一开始以为这个问题是「小项目用 http,大项目用 dio」,后来发现分界线不在项目大小,而在有几条要求是所有请求共用的。
package:http 给我的其实是 Client + Request/Response 这一层:发一个请求、拿回一个响应体,StreamedRequest 支持流式写。它没有拦截器的位置——鉴权头要么在每个调用点手写,要么我自己包一层函数把公共逻辑塞进去;那层函数写到第三次,形状就已经和拦截器很像了,只是自己的实现。它也没有取消的语义(Request.abort() 是有的,但和「把这个请求连同它等待的资源一起作废」这套用法差着一层),超时只能靠 Future.timeout 从外面罩住——罩住之后请求并没有停,socket 还在跑,只是我的 await 提前返回了,这一点我一开始没分清。
| 我需要的能力 | 裸 http 里它的形态 | dio 里它的形态 |
|---|---|---|
| 所有请求带 token | 每个调用点拼 header,或自己包函数 | Interceptor.onRequest 一处 |
响应统一解包 {code, data, msg} | 每个调用点 jsonDecode + 取字段 | onResponse 里替换 response.data |
| 页面离开时作废请求 | 自己管 abort() 与状态 | CancelToken |
| 超时 | 只有外层 Future.timeout | 建连 / 发送 / 接收三段,分别有类型 |
| 上传进度 | 需要分块自己算 | onSendProgress 直接给 |
| 失败重试 | 自己写循环 | 也得自己写,只是能塞进 onError |
我的结论只对我自己成立:横切要求到两条以上,dio 省掉的是重复;只有一两个公开接口、不需要鉴权和取消的项目,dio 那层抽象是要付利息的——它的行为边界我得读源码和 changelog 才确认得了(状态码怎么算失败、超时算的是哪一段,这两件事我在读源码之前都是靠猜的,见下文),而裸 http 基本没这种波动。要不要上 dio 取决于这个 App 的网络需求里「所有请求都要做同一件事」的有几条。
单例 Dio 与 BaseOptions:统一配置放在哪
第一版的 11 个 Dio() 是 11 个实例,各自带着自己的配置。我先把它们收成一台单例,配置全进 BaseOptions:
class ApiClient {
ApiClient._() {
_dio = Dio(BaseOptions(
baseUrl: 'https://api.example.com/',
// 建连阶段:连接还没建立起来就超时,落到 DioExceptionType.connectionTimeout
connectTimeout: const Duration(seconds: 8),
// 语义不是「整个响应的总时长」,见下方原理;这里只兜「首包迟迟不来」和「传输中途卡住」
receiveTimeout: const Duration(seconds: 15),
// 请求体写不出去:多半是上行带宽或大 body,我这边只有头像上传会撞
sendTimeout: const Duration(seconds: 15),
responseType: ResponseType.json,
// 只在 BaseOptions 里放真正全局的 header,带业务语义的 header 留给拦截器或请求级
headers: const {'Accept': 'application/json'},
// 默认只认 200..299;我给列表接口做过 304 缓存,所以自己给了判定
validateStatus: (code) =>
code != null && (code >= 200 && code < 300 || code == 304),
));
_dio.interceptors.addAll([
AuthInterceptor(_dio),
UnwrapInterceptor(),
ReportInterceptor(),
]);
}
static final ApiClient instance = ApiClient._();
late final Dio _dio;
// 只暴露实例,不让外面拿到 options 去改全局配置
Dio get dio => _dio;
}为什么三段超时是三件事。connectTimeout 管的是连接建立那一段;sendTimeout 管请求体往外写的过程;receiveTimeout 我在文档里读到定义时才确认自己一直理解偏了——它指的是「连接建立后到收到第一个响应字节」以及「传输过程中相邻两次数据事件之间的间隔」,而不是「这个响应从头到尾必须在 15 秒内到完」。所以一个大附件下载,只要数据一直在稳定地流,receiveTimeout 不会掐它;反过来,服务端迟迟不给首字节、或者中途卡住不再发数据,就会命中。null 和 Duration.zero 都表示不限时——这一条我踩过:以为 Duration.zero 是「立即超时」,实际是「关掉超时」,那阵子我的下载请求看起来像永远不会失败。
为什么要单例。dio 默认走 dart:io 的连接实现,一个 Dio 实例底下是一个 HttpClient,它自己维护到各个 host 的 keep-alive 连接池。共享实例时,同一个域名的第 N 个请求可以复用已经建好的连接;每个页面 new Dio() 则是各开一套池,页面并发几个请求就是几条新连接,握手成本按请求数叠上去。代价同样明确:配置也是共享的。任何一处直接写 dio.options.headers['X'] = ...,都会影响之后所有请求,我在退出登录时就因为忘了清掉 Authorization 而让下一个未登录用户继续带着旧 token 发请求。我的处理是把这类 header 的读写全部收进拦截器,options.headers 里只留常量,对外也只暴露 dio 而不暴露 options。
请求级参数怎么和全局合并。我在源码的 compose 里读到规则:标量参数是「请求级不为 null 才覆盖」,headers 和 queryParameters 是在 BaseOptions 的副本上追加,而不是整体替换。所以某个接口单独传 Options(headers: {'If-Match': etag}) 不会把全局的 Accept 弄丢;而 receiveTimeout 传 Duration.zero 是货真价实的覆盖,这一条要单独注意,因为它恰好是那个「关掉超时」的值。
拦截器我只放三类东西
翻回我最初的拦截器文件,第一版里面有四件事:注入 token、解包 data、上报耗时,以及第四件——在 onError 里弹一个红色 SnackBar。前三件我留下了,第四件删掉了,原因下面讲。
// 我自己定义的 extra key,dio 里没有对应常量,在框架 API 里搜不到
const skipAuthKey = 'skip_auth';
// 服务端在业务失败时给的那对字段,我先把它留住,映射时优先用它的话术
class ApiBusinessError {
const ApiBusinessError({required this.code, required this.message});
final int code;
final String message;
}
class AuthInterceptor extends Interceptor {
AuthInterceptor(this._dio);
final Dio _dio;
String? _token;
// 登录态只在变了的时候由外部推给它,外部拿不到 options
set token(String? value) => _token = value;
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
// 登录、刷新这类接口不该带旧凭证;第三方(图床、支付)用自己的 key
final skip = options.extra[skipAuthKey] == true;
if (!skip && _token != null) {
options.headers['Authorization'] = 'Bearer $_token';
}
// 记下发起时间,onResponse/onError 里算耗时用它
options.extra['startedAt'] = DateTime.now().microsecondsSinceEpoch;
// 不调 handler.next,这条请求就没有下一步,await 也永远不返回
handler.next(options);
}
}
class UnwrapInterceptor extends Interceptor {
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
final body = response.data;
if (body is Map<String, dynamic> && body.containsKey('code')) {
final code = body['code'];
if (code == 0) {
// 业务成功:把 payload 顶到 data 上,上层不用再写 response.data['data']
response.data = body['data'];
handler.next(response);
return;
}
// 业务失败:在这里转成 DioException,UI 层就只有一条错误路径
handler.reject(
DioException(
type: DioExceptionType.badResponse,
response: response,
requestOptions: response.requestOptions,
// 服务端的话术放进 error,映射时优先取它
error: ApiBusinessError(
code: code as int,
message: (body['msg'] as String?) ?? '',
),
),
true,
);
return;
}
handler.next(response);
}
}
class ReportInterceptor extends Interceptor {
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
_log(response.requestOptions, response.statusCode, null);
handler.next(response);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
_log(err.requestOptions, err.response?.statusCode, err);
handler.next(err);
}
void _log(RequestOptions o, int? status, DioException? err) {
final started = o.extra['startedAt'] as int?;
final cost = started == null
? 0
: DateTime.now().microsecondsSinceEpoch - started;
// 只上报,不弹 UI:这里没有 context 可用
debugPrint('${o.method} ${o.uri} $status ${err?.type.name ?? 'ok'} ${cost}us');
}
}为什么拦截器里放 UI 会付出代价。拦截器的回调发生在网络层的时间线上:响应落地那一刻是 Future 完成的时候,跟 widget 树没有任何关系。我要在那儿弹一个提示,手上只有一个全局 navigatorKey,能拿到的 context 是「整个 App 的根」,而不是「这次请求属于哪个页面」。这两件事一旦分开,就会出现我遇到的情况:请求发出后用户退出了那个页面,错误落地时我对着一个已经不在树上的 context 找东西。更麻烦的是判断「该不该弹」——用户在 A 页面发起了请求、切到 B 页面时 A 的请求失败了,弹出来的是 B 页面的Snackbar,用户看不到任何因果。我试过用「当前路由还是不是发起时那条」来兜,那种比较的代码长度已经超过它省下的东西。
我的处理是:错误只负责被翻译成业务语义,展示交给页面。拦截器里上报、注入、解包,这三类都不需要知道屏幕上有谁。
token 过期这件事上,我先把并发处理对
401 的统一处理很自然地被写成「在 onError 里刷新 token,再拿 err.requestOptions 重放一次」。我第一次就是这么写的,然后在冷启动时坏了:一个页面同时发 4 个请求,全都 401,于是 4 个刷新请求打出去,后一个把前一个的结果覆盖掉,重放时有的请求带的是已经作废的那一枚 token,继续 401。更糟的是刷新接口本身也走拦截器,形成第二次递归。
我的做法是把刷新做成单飞(single-flight):所有 401 等同一个 Future,只发一次刷新。
class AuthInterceptor extends Interceptor {
// 同一时刻只允许一次刷新,其余 401 等的是同一个 Future
static Future<String>? _refreshing;
String? _refresh;
@override
Future<void> onError(
DioException err, ErrorInterceptorHandler handler) async {
final is401 = err.response?.statusCode == 401;
// 刷新接口自己 401 时再进这里就绕不出来了
final isRefreshApi = err.requestOptions.path.startsWith('/auth/refresh');
if (!is401 || isRefreshApi) {
// 用 next 透传:reject 的第二个参数默认是 false,
// 直接 reject 会让后面的错误拦截器(我的上报)收不到这条错误
handler.next(err);
return;
}
// 并发 401 共用同一个刷新任务:谁先建谁负责发出去
_refreshing ??= _refreshToken().whenComplete(() => _refreshing = null);
try {
_token = await _refreshing!;
} catch (_) {
// 刷新失败就原样往下走,跳登录还是提示,交给页面判断
handler.next(err);
return;
}
try {
// 复用原 requestOptions:data、queryParameters、extra 和 cancelToken 都跟着走
final retry = await _dio.fetch<dynamic>(
err.requestOptions..headers['Authorization'] = 'Bearer $_token',
);
handler.resolve(retry);
} on DioException catch (e) {
handler.next(e);
}
}
// 刷新用一台不带拦截器的裸 Dio:它自己 401 时不该再触发一轮 401 处理
Future<String> _refreshToken() async {
final resp = await Dio(BaseOptions(baseUrl: _dio.options.baseUrl))
.post<dynamic>('/auth/refresh', data: {'refreshToken': _refresh});
return (resp.data as Map<String, dynamic>)['accessToken'] as String;
}
}_dio.fetch(err.requestOptions) 这条路我把原请求对象直接改 header 再打出去,data、queryParameters、extra 都跟着走,比手写一次重放少一层遗漏。代价也要说:重放会让这一次 await 的耗时变成两跳,如果那个接口本来就在关键路径上,页面转圈的时间会明显变长——我这边只在确实需要登录态的接口上开这条逻辑。
分层:ApiService、Repository、UI 各自管什么
我现在的分法是三层,但我不是一开始就分三层的。第一个版本只有「页面里直接调」,第二个版本是「一个 api.dart 里全是函数」,第三层是我加了本地缓存之后才出现的。
| 层 | 我知道它该管什么 | 它不该碰什么 |
|---|---|---|
ApiService | 接口路径、请求参数与返回字段的形状;返回模型对象 | 不判断业务分支,不关心缓存,不 import widget |
Repository | 缓存与远端怎么合并、分页游标、多接口拼一个页面数据 | 不知道 DioException 长什么样,只认封装好的结果类型 |
| UI | 状态机(加载 / 出错 / 空 / 有数据)和展示 | 不拼 URL、不解包 JSON、不看状态码 |
三层在只有五六个接口的项目里是净负担,我自己在 demo 工程里就只留 ApiService。让我抽出 Repository 的信号是「同一个接口被两个以上页面用到,而且其中一个想先显示缓存」。
class ContactApi {
const ContactApi(this._dio);
final Dio _dio;
Future<List<Contact>> list({required int page, int size = 50}) async {
// 只描述「这个接口怎么调、回来是什么形状」,错误原样往上抛
final resp = await _dio.get<dynamic>(
'/contacts',
queryParameters: {'page': page, 'size': size},
);
// 经过 UnwrapInterceptor,resp.data 已经是 payload 本体
return (resp.data as List).map((e) => Contact.fromJson(e)).toList();
}
}错误建模:把 DioException 翻译成业务语义
这一节是我收益最大的一块,因为它把 catch 从页面里彻底清出去了。
先讲我卡住过一次的那个事实:dio 默认的 validateStatus 是「status != null && status >= 200 && status < 300」。我在源码里读到 _defaultValidateStatus 那三行之前,写的是这种代码:
final resp = await _dio.post('/auth/login', data: {...});
if (resp.statusCode == 401) {
// 显示「账号或密码错误」
}这段永远走不到那个 if——401 直接被判定为失败、从 onError 走了,await 抛的是 DioException,类型是 DioExceptionType.badResponse。症状就是登录失败时页面停在原地转圈结束、什么提示都没有,而我完全没往状态码那想,还以为是 UnwrapInterceptor 把响应吃掉了。配套的一条是 receiveDataWhenStatusError 默认是 true,意思是非 2xx 的响应体照样会被读出来,所以服务端在 401 里返回的 {code, msg} 是拿得到的,就在 err.response.data;我一开始以为状态码一失败就没 body 了,白白把服务端话术丢了好几周。
DioExceptionType 在我用的版本里是这 9 项,以及我给的映射:
| type | 触发条件 | 翻成业务语义 |
|---|---|---|
connectionTimeout | 建连阶段超时 | 网络不通,给「检查网络」+ 重试 |
sendTimeout | 请求体写不出去 | 上行拥挤或 body 过大,重试 |
receiveTimeout | 首包迟迟不来 / 传输中途停住 | 服务端慢,给「再试一次」 |
connectionError | socket 层直接失败(断网、DNS 失败、连不上) | 离线,展示本地缓存 |
badCertificate | TLS 证书校验不过 | 归为安全错误,不自动重试 |
badResponse | 状态码没通过 validateStatus,或我自己在解包拦截器里 reject | 按 response.statusCode 与业务码细分 |
cancel | CancelToken.cancel | 静默,什么都不显示 |
unknown | 兜不住的其他异常(error 里是原始对象) | 记日志 + 通用提示 |
transformTimeout | 响应转换阶段超时(较新的 5.x 才有这一项) | 数据解析出问题,重试无意义 |
我用 Dart 3 的 sealed class 做这层,好处是 switch 少写一个分支就编译不过,不用靠我记得:
sealed class ApiResult<T> {
const ApiResult();
}
final class Ok<T> extends ApiResult<T> {
const Ok(this.data);
final T data;
}
final class Failure<T> extends ApiResult<T> {
const Failure(this.kind, this.message);
final FailureKind kind;
final String message;
}
enum FailureKind { offline, unstable, forbidden, unauthorized, rejected, canceled, unknown }
/// 整个 App 只有这一处 import dio 的错误类型
Failure<T> toFailure<T>(Object error) {
if (error is DioException) {
switch (error.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.connectionError:
return const Failure(FailureKind.offline, '网络不通,检查一下连接');
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
return const Failure(FailureKind.unstable, '网络有点慢,再试一次');
case DioExceptionType.cancel:
// 防抖和页面退出都会落到这里;给它弹提示的话,
// 屏幕上闪的全是自己刚刚取消掉的那批请求
return const Failure(FailureKind.canceled, '');
case DioExceptionType.badCertificate:
return const Failure(FailureKind.rejected, '证书校验没通过');
case DioExceptionType.badResponse:
// 非 2xx 的 body 默认还在(receiveDataWhenStatusError 默认 true),
// 所以服务端给的话术优先于我按状态码猜的那句
final biz = error.error;
if (biz is ApiBusinessError) {
return Failure(
biz.code == 403 ? FailureKind.forbidden : FailureKind.rejected,
biz.message,
);
}
final status = error.response?.statusCode;
if (status == 401) {
return const Failure(FailureKind.unauthorized, '登录过期了');
}
if (status == 403) {
return const Failure(FailureKind.forbidden, '没有权限');
}
if (status != null && status >= 500) {
return Failure(FailureKind.unknown, '服务端 $status,稍后再试');
}
return Failure(
FailureKind.unknown, '请求失败(${status ?? '无状态码'})');
case DioExceptionType.unknown:
default:
return Failure(
FailureKind.unknown, error.error?.toString() ?? '未知错误');
}
}
return Failure(FailureKind.unknown, error.toString());
}UI 层于是变成这样,dio 这个词它一次都不用见:
switch (await repo.loadContacts(page: 1)) {
case Ok(:final data):
_show(data);
case Failure(:final kind, :final message):
if (kind == FailureKind.canceled) break; // 被取消的请求不改变页面状态
if (kind == FailureKind.unauthorized) _goLogin();
else if (mounted) _showBanner(message);
}请求取消:页面先没了,回调后到
我那次闪退,控制台里是这一行:
Looking up a deactivated widget's ancestor is unsafe.
场景是详情页:initState 里发请求,回来以后我调 ScaffoldMessenger.of(context) 弹提示。我在请求飞在空中的那两秒里按了返回。原因在 Element 的生命周期上:BuildContext 指向的就是那个 State 对应的 Element,页面 pop 之后它被移出树、变成 deactivated,activate 阶段建立起来的那条祖先链已经不存在了;异步回调落地时框架允许它继续跑(Dart 的 Future 不认得 widget),可一旦进去找 ancestor,就没有「安全的祖先」可找了。同一条链上更早的那一次,我看到的是 setState() called after dispose(),其实是同一件事的两种表现——context 用早了还是用晚了。
我当时把修法理解成「回调第一行判 mounted」,这条后来被我当成兜底而不是主方案。判断 mounted 只是让我别崩溃,请求本身还在跑、回来还照样烧那份流量和解析。我后来的做法是把页面级请求都绑到一枚 CancelToken,dispose 里统一作废:
class ContactDetailPage extends State<ContactDetailPage> {
final CancelToken _token = CancelToken();
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final data = await ContactApi(ApiClient.instance.dio)
.detail(widget.id, cancelToken: _token);
// 页面还在才写状态;被取消的请求不会走到这一行
if (mounted) setState(() => _contact = data);
} on DioException catch (e) {
// 取消也会走到这里;这一分支认不出它的话,退出页面就变成一次报错
if (e.type == DioExceptionType.cancel) return;
if (mounted) setState(() => _error = toFailure(e));
}
}
@override
void dispose() {
// reason 会落在 DioException.error 上,排查时能看到是谁取消的
_token.cancel('ContactDetailPage disposed');
super.dispose();
}
}关于 CancelToken 有两条行为我是从源码确认的,它们决定了我怎么用它。一是同一枚 token 可以绑多个请求,文档里写得很直接:token 被 cancel 时,所有绑定到它的请求一起作废。这正是我想要的页面级语义——一次 dispose 清掉这一页所有在飞的请求。二是已经取消过的 token 不能再拿去发新请求:request 一进来就有 if (cancelToken != null && cancelToken.isCancelled) throw cancelToken.cancelError!;,这条请求连发出去的机会都没有,await 直接抛 cancel。我第一版防抖写法就中了这个——token 建在 State 上复用,第二次输入起,每个请求都在原地失败。
CancelToken 与搜索框防抖的组合
搜索框是我把上面两条一起用上的地方:既要让旧输入作废,又不能让下一次输入拿到一枚坏 token。
class _ContactSearchState extends State<ContactSearchPage> {
Timer? _debounce;
CancelToken? _running; // 只保留「当前这一次」,每次输入都换新的
void _onChanged(String keyword) {
_debounce?.cancel();
// 300ms 内的旧定时器作废,用户连续输入时不会每个字符发一次请求
_debounce = Timer(const Duration(milliseconds: 300), () => _search(keyword));
}
Future<void> _search(String keyword) async {
// 上一次输入的请求还没落地就先取消它;旧 token 不重用,用完即弃
_running?.cancel('superseded by a newer query');
final token = CancelToken();
_running = token;
setState(() => _loading = true);
try {
final list = await ContactApi(ApiClient.instance.dio)
.search(keyword, cancelToken: token);
// 输入框可能又变了,这一批结果不属于最新那次输入
if (!token.isCancelled && mounted) {
setState(() {
_results = list;
_loading = false;
});
}
} on DioException catch (e) {
// 被取代的输入落到这里,type 是 cancel,什么都不该显示
if (e.type == DioExceptionType.cancel) return;
if (mounted) setState(() => _error = toFailure(e));
}
}
@override
void dispose() {
_debounce?.cancel();
_running?.cancel('page disposed');
super.dispose();
}
}这里的顺序有一点讲究:先 cancel 再新建 token,取消的动作是同步的(它只把一个错误塞进 completer),真正的 socket 关闭在后面。另外我对同一枚 token 调过一次以上 cancel 且给了不同 reason,控制台里会出现一条 warning,内容是「The CancelToken was cancelled multiple times with different reason」——它不影响行为(第一次的 reason 会被保留),但我那次以为是新 bug,多查了一阵。
重试与幂等:我只对 GET 做
写自动重试之前,我想清楚的是「超时到底发生在哪一刻」。请求生命周期里有这么一个窗口:请求已经到达服务端、服务端已经执行完写操作、响应在回来的路上丢了(连接被掐、receiveTimeout 命中)。从客户端看这是一次失败,从服务端看这是一次成功。我在这种窗口上重试一次 POST /orders,就是两笔订单;扣款接口是两笔扣款。GET 没有这个问题,因为按语义它不改变服务端状态,重试只是多读一次。
所以「能不能重试」这件事的决定权其实在服务端:一个 POST /search 只是把查询条件放进了 body,它照样是幂等的,也可以重试;反过来一个用 GET 触发副作用的接口(我见过拿 /cart/remove?id= 做删除的),一旦重试就会删两次。我的判断依据始终是「这个操作重复执行两次,结果和一次相同吗」,方法名只是我平时偷懒的近似。
class RetryInterceptor extends Interceptor {
RetryInterceptor(this._dio, {this.maxRetries = 2});
final Dio _dio;
final int maxRetries;
@override
Future<void> onError(
DioException err, ErrorInterceptorHandler handler) async {
final method = err.requestOptions.method.toUpperCase();
// 只对读操作重试:写操作重试一次的后果由服务端决定,客户端这边看不到
if (method != 'GET') {
handler.next(err);
return;
}
// 只重试「不确定有没有到达服务端」的这一类错误
const retryable = {
DioExceptionType.connectionTimeout,
DioExceptionType.connectionError,
DioExceptionType.receiveTimeout,
};
if (!retryable.contains(err.type)) {
handler.next(err);
return;
}
final attempt = err.requestOptions.extra['retryCount'] as int? ?? 0;
if (attempt >= maxRetries) {
handler.next(err);
return;
}
// 指数退避 + jitter:jitter 是为了避免多个客户端在同一时刻齐刷刷重试
final base = 200 * (1 << attempt); // 200ms、400ms
await Future.delayed(
Duration(milliseconds: base + _rng.nextInt(base ~/ 2)),
);
// 次数记在 requestOptions.extra 上,重放的这一条会带着它再走一遍拦截器
err.requestOptions.extra['retryCount'] = attempt + 1;
try {
// 复用原 requestOptions,cancelToken 也一起带上:页面关了重放也会立刻被取消
final resp = await _dio.fetch<dynamic>(err.requestOptions);
handler.resolve(resp);
} on DioException catch (e) {
handler.next(e);
}
}
static final _rng = Random();
}写在 onError 里而不是包一层 retry(() => dio.get(...)),是因为我上层调用点散在各处,放拦截器只改一处;代价是重试次数被塞进 extra 传递,读代码时不明显,我在那行加了注释才算能维护。另外我给写接口留的口子是幂等键:请求头里带一个客户端生成的 X-Request-Id,服务端拿它去重,这样「超时后重试一次」才对写操作安全——这件事需要后端配合,我一个人做不到。
学习时要特别注意的几处
状态码分支写成了成功路径。 症状:登录失败时页面什么都不显示,转圈结束就停在原页;我一开始怀疑是解包拦截器吞了响应。原因是默认 validateStatus 只把 200–299 当成功,4xx 直接进错误通道,写在 await 之后的 if (resp.statusCode == 401) 永远不执行。怎么判断:在 onError 里打印 err.response?.statusCode,如果它先出现,说明这条请求走的是错误分支;再确认 err.response?.data 是不是还有服务端给的 body。
搜索提示闪成一片红色「请求失败」。 症状:搜索框每敲一个字闪一次错误提示,网络面板里请求数正常。原因是防抖取消掉的请求,落地时 type 是 DioExceptionType.cancel,被统一错误处理当成失败弹了出来。怎么判断:在提示那行前打印 err.type.name,看到 cancel 就是这条;DioException.message 里那句固定文本 The request was manually cancelled by the user. 也是一个信号,同时它的 response 一定是 null。
新请求瞬间失败,网络里没有这条记录。 症状:第二次输入、或者第二次进页面时,请求几乎立刻抛 cancel,耗时接近 0。原因是我复用了那枚已经 cancel 过的 CancelToken——request 在发出之前就会 throw cancelToken.cancelError!,请求根本没上网络。怎么判断:发请求之前打印 token.isCancelled;如果是 true,问题一定在 token 的生命周期而不是网络层。我后来的写法是:页面级那枚 token 活到 dispose 为止(之后这一页本来也不会再发请求),防抖那种「同一个入口反复调用」的场景每次新建一枚——出问题的从来不是「token 活得久」,而是「已经取消过的 token 又被拿去发起下一次调用」。
大文件偶发 receiveTimeout,但带宽看着很正常。 症状:头像批量下载时偶尔超时,抓包发现数据一直在流,只是慢。原因是我把 receiveTimeout 当成了整个响应的总时长,而它的定义是相邻两次数据事件之间的间隔——传输只要没停就不该命中;真命中的场景是首字节迟迟不来,或者中途服务端停顿超过阈值。怎么判断:临时把它设成 Duration.zero(也就是关掉),如果问题消失且总耗时本来就没超过设的值,那就是我把语义理解偏了;再看服务端在小响应上从不超时、只在大响应上超时,也是同一个信号。
刷新成功之后,几个请求还是 401。 症状:冷启动进主页报一次「登录过期」,重新登录以后拿到的数据像还是上一个人的。原因是并发 401 各自触发一次刷新,token 被后写的那次覆盖,重放时有的请求带着已经被换掉的旧值;刷新接口自己也走拦截器时还能绕出第二轮。怎么判断:在刷新函数第一行打一行日志,看一次冷启动它被调用几次——正常只有 1 次。
请求永久 pending,控制台干净。 症状:await 再也不返回,页面上就是那个转圈动画一直转,也没有超时错误。原因是拦截器链是靠 handler.next/resolve/reject 往前推的,我在 onRequest 里写了一个提前 return 的分支而没调 handler,那条请求就没有下一步;请求根本没发出去,所以三段超时都不参与计时。怎么判断:把 connectTimeout 临时调到 3 秒,如果连 connectionTimeout 都没出现,就说明卡点在拦截器而不在网络;也可以在后半个拦截器里打日志,看有没有走到。
最后合在一起的样子
把上面几块拼起来,我实际留在工程里的文件是这一个(登录调用跟在后面,因为它是我确认这层好不好用的唯一方式):
class ApiClient {
ApiClient._() {
_dio = Dio(BaseOptions(
baseUrl: 'https://api.example.com/',
connectTimeout: const Duration(seconds: 8),
receiveTimeout: const Duration(seconds: 15),
sendTimeout: const Duration(seconds: 15),
responseType: ResponseType.json,
headers: const {'Accept': 'application/json'},
validateStatus: (code) =>
code != null && (code >= 200 && code < 300 || code == 304),
));
// 上报放最后,它要看到前面所有人的结论;注入放最前,重放时 header 才是新的
// (重试和错误链的关系见下面那段)
_dio.interceptors.addAll([
AuthInterceptor(_dio),
RetryInterceptor(_dio),
UnwrapInterceptor(),
ReportInterceptor(),
]);
}
static final ApiClient instance = ApiClient._();
late final Dio _dio;
Dio get dio => _dio;
}
class AuthApi {
const AuthApi(this._dio);
final Dio _dio;
Future<Session> login(String phone, String code) async {
final resp = await _dio.post<dynamic>(
'/auth/login',
data: {'phone': phone, 'code': code},
// 这条请求不该带旧凭证,在调用点声明
options: Options(extra: {skipAuthKey: true}),
);
return Session.fromJson(resp.data);
}
}
class AuthRepo {
const AuthRepo(this._dio);
final Dio _dio;
Future<ApiResult<Session>> login(
String phone, String code, CancelToken token) async {
try {
return Ok(await AuthApi(_dio).login(phone, code));
} catch (e) {
// Dio 的异常在这一层就变成业务语义,页面拿不到 DioException
return toFailure<Session>(e);
}
}
}
class LoginPageState extends State<LoginPage> {
final CancelToken _token = CancelToken();
final _repo = AuthRepo(ApiClient.instance.dio);
Future<void> _submit() async {
setState(() => _busy = true);
final result = await _repo.login(_phone.text, _code.text, _token);
// 只有 Ok / 有语义的 Failure 会到这里:取消和 Dio 异常都在 Repository 收掉了
if (!mounted) return;
setState(() => _busy = false);
switch (result) {
case Ok(:final data):
AuthStore.instance.save(data);
Navigator.of(context).pushReplacementNamed('/home');
case Failure(:final kind, :final message):
if (kind == FailureKind.canceled) return;
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(message)));
}
}
@override
void dispose() {
_token.cancel('LoginPage disposed');
super.dispose();
}
}拦截器顺序这件事我一开始想歪了:以为把重试放在解包前面,业务失败就不会经过它。实际是错误链按添加顺序整条走一遍——解包里那句 handler.reject(err, true) 的第二个参数为真,就是把错误交给后续的错误拦截器,所以业务失败照样会落到重试的 onError 上。它没被重试,靠的是我那个可重试集合里只有三种传输层错误、badResponse 不在其中。也就是说这个保护在判定条件里,不在数组顺序里。
跳过 token 注入用的是我自己定义的 extra key(skipAuthKey),dio 里没有对应常量。我原来是在 AuthInterceptor 里维护一张 path 黑名单,接口加到第三十个的时候,已经没人记得新接口要不要往里加;改成在调用点声明之后,「这条请求不带旧凭证」这个决定就写在使用它的那三行里了。
评论
暂未收到