5.1 KiB
5.1 KiB
【Demo】Dio 生产级能力(进度 / 取消 / 重试)
源文件:
lib/pages/demo/dio_pro_demo.dart| 分类:网络 一句话:下载进度条、页面销毁自动取消请求、网络错误自动重试 —— 生产环境三件套。
一、这个 Demo 解决什么问题
dio_demo.md 讲了基础请求,本页升级到生产级场景:
- 下载进度:
onReceiveProgress实时百分比 + 进度条; - 请求取消:用户手动取消 + 页面销毁自动取消(防泄漏);
- 失败重试:网络层错误自动指数退避重试 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 ? '开' : '关'})');
}
五、踩坑记录
onReceiveProgress里 setState 前必须判 mounted(回调频率高,页面销毁后必崩);total <= 0时进度除零,要兜底成 0;- CancelToken 一旦取消就不能复用,每次请求要新建;
- 取消走 onError 分支(DioExceptionType.cancel),不是"不走回调"——UI 复位逻辑要放对地方;
- Web 跨域下载:Content-Type 改 text/plain 绕过预检;
- 上传进度同理:
onSendProgress: (sent, total),笔记图片上传的下一步就接它。
六、扩展思考
- 重试策略进阶:
RetryInterceptor的 retryableExtra / 重试次数按接口配置; - 下载文件落盘:
dio的download()+ 进度回调 +path_provider存沙盒; - 上传场景:oss_upload_demo.md 的 OSS 直传 PUT 就带 onSendProgress;
- 页面级取消还可以用
DisposeBag模式统一管理多个 token(本项目单 token 够用)。