# 【Demo】Dio 生产级能力(进度 / 取消 / 重试) > 源文件:`lib/pages/demo/dio_pro_demo.dart` | 分类:网络 > 一句话:下载进度条、页面销毁自动取消请求、网络错误自动重试 —— 生产环境三件套。 --- ## 一、这个 Demo 解决什么问题 [dio_demo.md](dio_demo.md) 讲了基础请求,本页升级到生产级场景: 1. **下载进度**:`onReceiveProgress` 实时百分比 + 进度条; 2. **请求取消**:用户手动取消 + **页面销毁自动取消**(防泄漏); 3. **失败重试**:网络层错误自动指数退避重试 N 次(控制台看 ↻ 日志)。 --- ## 二、怎么运行 / 演示内容 ```dart runApp(const MaterialApp(home: DioProDemoPage())); ``` - "开始下载":下载约 2MB 的视频文件,进度条动起来; - "取消下载":手动中断; - "重试演示":请求不存在的域名 → DNS 解析失败 → 自动重试 3 次(指数退避)→ 抛"网络异常"; - 顶部开关控制是否启用重试,方便对比。 --- ## 三、核心知识点(新手向) ### 1. 为什么需要 CancelToken 页面销毁但请求还在跑 → Future 没人接,响应回调还可能在 setState(对已销毁的 State setState 会报错/泄漏)。 做法: ```dart CancelToken? _token; Future _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. 进度回调的三个坑 ```dart 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 简单头**,浏览器不再预检直接放行(真机无此问题)。 ```dart headers: const {'Content-Type': 'text/plain'}, // Web 调试必加! ``` --- ## 四、关键代码剖析 ```dart // 手动取消 void _cancelDownload() { _token?.cancel('用户点击取消'); setState(() => _log += '⏹ 已发起取消,等待请求中断...\n'); } ``` 取消后请求抛 `DioExceptionType.cancel` → 封装层翻译成 `ApiException('请求已取消')` → 走 onError 分支。 ```dart // 重试演示: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_upload_demo.md) 的 OSS 直传 PUT 就带 onSendProgress; - 页面级取消还可以用 `DisposeBag` 模式统一管理多个 token(本项目单 token 够用)。