Files
2026-09-04 15:22:20 +08:00

138 lines
5.1 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 【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<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. 进度回调的三个坑
```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 够用)。