Files
App.K12.Student.Miniapp_Flu…/docs/demos/dio_pro_demo.md
T
2026-09-04 15:22:20 +08:00

5.1 KiB
Raw Blame History

【Demo】Dio 生产级能力(进度 / 取消 / 重试)

源文件:lib/pages/demo/dio_pro_demo.dart | 分类:网络 一句话:下载进度条、页面销毁自动取消请求、网络错误自动重试 —— 生产环境三件套。


一、这个 Demo 解决什么问题

dio_demo.md 讲了基础请求,本页升级到生产级场景:

  1. 下载进度:onReceiveProgress 实时百分比 + 进度条;
  2. 请求取消:用户手动取消 + 页面销毁自动取消(防泄漏);
  3. 失败重试:网络层错误自动指数退避重试 N 次(控制台看 ↻ 日志)。

二、怎么运行 / 演示内容

runApp(const MaterialApp(home: DioProDemoPage()));
  • "开始下载":下载约 2MB 的视频文件,进度条动起来;
  • "取消下载":手动中断;
  • "重试演示":请求不存在的域名 → DNS 解析失败 → 自动重试 3 次(指数退避)→ 抛"网络异常";
  • 顶部开关控制是否启用重试,方便对比。

三、核心知识点(新手向)

1. 为什么需要 CancelToken

页面销毁但请求还在跑 → Future 没人接,响应回调还可能在 setState(对已销毁的 State setState 会报错/泄漏)。

做法:

CancelToken? _token;

Future<void> _startDownload() async {
  final token = CancelToken(); // 每个请求新建一个(取消后不可复用!)
  _token = token;
  try {
    await DioClient.instance.get(
      _bigUrl,
      cancelToken: token,
      onReceiveProgress: (received, total) {
        if (!mounted || token.isCancelled) return;
        setState(() => _progress = total <= 0 ? 0 : received / total);
      },
      retries: _retryEnabled ? 2 : 0,
    );
  } on ApiException catch (e) {
    // 取消也会走到这里("请求已取消"),统一复位 UI
    if (!mounted) return;
    setState(() { _log += '✖ ${e.message}\n'; _downloading = false; });
  }
}

@override
void dispose() {
  _token?.cancel('页面已销毁'); // 页面销毁 → 自动取消在途请求(核心防泄漏点)
  super.dispose();
}

2. 进度回调的三个坑

onReceiveProgress: (received, total) {
  if (!mounted || token.isCancelled) return; // ① 回调频率高,必判 mounted
  setState(() => _progress = total <= 0 ? 0 : received / total); // ② total<=0 除零兜底
},
  • total <= 0:响应没有 Content-Length 时 total 为 -1,直接除会算出负数进度;
  • 进度回调每秒触发很多次,每次 setState 前判 mounted 是性能与安全双重保险。

3. 重试为什么只对"网络层错误"生效

错误类型 可重试? 原因
connectionError(断网/域名解析失败)、超时、闪断 ✅ 网络恢复后大概率成功
主动取消(用户意图) ❌ 用户不要了还重试 = 反人性
badResponse(4xx/5xx) ❌ 重试大概率同样失败,POST 还会重复提交数据

这是生产级的取舍,见 dio_client.dart 的 _canRetry。

4. Web 调试的 CORS 坑

  • application/json 是非简单头,跨域 GET 会先发 OPTIONS 预检,静态服务器不支持就 405 被拦;
  • 下载媒体/图片时用 headers 覆盖成 text/plain 简单头,浏览器不再预检直接放行(真机无此问题)。
headers: const {'Content-Type': 'text/plain'}, // Web 调试必加!

四、关键代码剖析

// 手动取消
void _cancelDownload() {
  _token?.cancel('用户点击取消');
  setState(() => _log += '⏹ 已发起取消,等待请求中断...\n');
}

取消后请求抛 DioExceptionType.cancel → 封装层翻译成 ApiException('请求已取消') → 走 onError 分支。

// 重试演示:Stopwatch 统计总耗时
final stopwatch = Stopwatch()..start();
try {
  await DioClient.instance.get('https://no-such-domain-abcxyz.com/api/test',
      retries: _retryEnabled ? 3 : 0);
} on ApiException catch (e) {
  stopwatch.stop();
  setState(() => _log = '✖ ${e.message}\n⏱ 共耗时 ${stopwatch.elapsed.inMilliseconds}ms\n'
      '(重试开关:${_retryEnabled ? '开' : '关'})');
}

五、踩坑记录

  1. onReceiveProgress 里 setState 前必须判 mounted(回调频率高,页面销毁后必崩);
  2. total <= 0 时进度除零,要兜底成 0;
  3. CancelToken 一旦取消就不能复用,每次请求要新建;
  4. 取消走 onError 分支(DioExceptionType.cancel),不是"不走回调"——UI 复位逻辑要放对地方;
  5. Web 跨域下载:Content-Type 改 text/plain 绕过预检;
  6. 上传进度同理:onSendProgress: (sent, total),笔记图片上传的下一步就接它。

六、扩展思考

  • 重试策略进阶:RetryInterceptor 的 retryableExtra / 重试次数按接口配置;
  • 下载文件落盘:dio 的 download() + 进度回调 + path_provider 存沙盒;
  • 上传场景:oss_upload_demo.md 的 OSS 直传 PUT 就带 onSendProgress;
  • 页面级取消还可以用 DisposeBag 模式统一管理多个 token(本项目单 token 够用)。