用脚步丈量人生宽度

Flutter 从零开始(六):网络层怎么封,dio 拦截器与错误处理

我那个通讯录 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首包迟迟不来 / 传输中途停住服务端慢,给「再试一次」
connectionErrorsocket 层直接失败(断网、DNS 失败、连不上)离线,展示本地缓存
badCertificateTLS 证书校验不过归为安全错误,不自动重试
badResponse状态码没通过 validateStatus,或我自己在解包拦截器里 reject按 response.statusCode 与业务码细分
cancelCancelToken.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 黑名单,接口加到第三十个的时候,已经没人记得新接口要不要往里加;改成在调用点声明之后,「这条请求不带旧凭证」这个决定就写在使用它的那三行里了。

评论

暂未收到

写下评论

支持 Markdown 语法