219 lines
8.6 KiB
Dart
219 lines
8.6 KiB
Dart
import 'package:dio/dio.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||
import 'package:new_learn/core/network/dio_client.dart';
|
||
|
||
/// ============================================================
|
||
/// 【DioProDemoPage】Dio 生产级能力 Demo(取消 / 进度 / 重试)
|
||
///
|
||
/// 一、演示三个生产级场景:
|
||
/// 1. 下载进度:onReceiveProgress 实时百分比(配合 LinearProgressIndicator);
|
||
/// 2. 请求取消:CancelToken 用户手动取消 + 页面销毁自动取消(防泄漏);
|
||
/// 3. 失败重试:网络层错误自动指数退避重试 N 次(控制台看 ↻ 日志)。
|
||
///
|
||
/// 二、为什么需要 CancelToken:
|
||
/// 页面销毁但请求还在跑 → Future 没人接,响应回调还在 setState
|
||
/// (对已销毁的 State setState 会报错/泄漏)。做法:
|
||
/// 页面持有一个 CancelToken,dispose 时 cancel(),请求立即抛
|
||
/// DioExceptionType.cancel → _mapError 翻译成"请求已取消",
|
||
/// 回调里的 setState 不会执行到。
|
||
///
|
||
/// 三、为什么重试只对"网络层错误"生效:
|
||
/// - 可重试:connectionError(断网/域名解析失败)、超时、闪断;
|
||
/// - 不可重试:主动取消(用户意图)、badResponse(4xx/5xx,
|
||
/// 重试大概率同样失败,POST 还会重复提交数据)。
|
||
/// 这是生产级的取舍,见 dio_client.dart 的 _canRetry。
|
||
///
|
||
/// 四、高频坑:
|
||
/// 1. onReceiveProgress 里 setState 前必须判 mounted(回调频率高);
|
||
/// 2. total <= 0 时(响应没有 Content-Length)进度除零,要兜底;
|
||
/// 3. CancelToken 一旦取消就不能复用,每次请求要新建;
|
||
/// 4. 取消会走 onError 分支(DioExceptionType.cancel),不是不走回调;
|
||
/// 5. Web 调试跨域(CORS):application/json 是非简单头,跨域 GET 会先
|
||
/// 发 OPTIONS 预检,静态服务器不支持就 405 被拦。下载媒体/图片时
|
||
/// 用 headers 覆盖成 text/plain 简单头即可绕过(真机无此问题)。
|
||
/// ============================================================
|
||
class DioProDemoPage extends StatefulWidget {
|
||
const DioProDemoPage({super.key});
|
||
|
||
@override
|
||
State<DioProDemoPage> createState() => _DioProDemoPageState();
|
||
}
|
||
|
||
class _DioProDemoPageState extends State<DioProDemoPage> {
|
||
/// 大文件地址(约 2MB,用来让进度条动起来;绝对 URL 会覆盖 baseUrl)
|
||
static const String _bigUrl =
|
||
'https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4';
|
||
|
||
CancelToken? _token; // 当前下载的取消凭证
|
||
double _progress = 0; // 0.0 ~ 1.0(未开始为 0)
|
||
bool _downloading = false; // 下载中
|
||
bool _retryEnabled = true; // 重试开关
|
||
String _log = ''; // 操作日志
|
||
|
||
@override
|
||
void dispose() {
|
||
// 页面销毁 → 自动取消在途请求(核心防泄漏点)
|
||
_token?.cancel('页面已销毁');
|
||
super.dispose();
|
||
}
|
||
|
||
/// 开始下载:带进度回调 + 可取消 + 可重试
|
||
Future<void> _startDownload() async {
|
||
if (_downloading) return;
|
||
final token = CancelToken(); // 每个请求新建一个(取消后不可复用)
|
||
_token = token;
|
||
setState(() {
|
||
_downloading = true;
|
||
_progress = 0;
|
||
_log = '';
|
||
});
|
||
try {
|
||
await DioClient.instance.get(
|
||
_bigUrl,
|
||
cancelToken: token,
|
||
// Web 调试必加!全局头是 application/json(非简单头),跨域 GET
|
||
// 会先发 OPTIONS 预检,flutter.github.io 的静态服务器不支持预检
|
||
// (返回 405)→ 被浏览器 CORS 拦截。改成 text/plain 简单头后
|
||
// 不再预检,直接放行(源已带 Access-Control-Allow-Origin: *)。
|
||
headers: const {'Content-Type': 'text/plain'},
|
||
// 下载进度回调:(已接收字节, 总字节)。total<=0 时兜底成 0
|
||
onReceiveProgress: (received, total) {
|
||
if (!mounted || token.isCancelled) return;
|
||
setState(() => _progress = total <= 0 ? 0 : received / total);
|
||
},
|
||
retries: _retryEnabled ? 2 : 0,
|
||
);
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_log += '✅ 下载完成,共 ${(_progress * 100).round()}%\n';
|
||
_downloading = false;
|
||
});
|
||
} on ApiException catch (e) {
|
||
// 取消也会走到这里("请求已取消"),统一复位 UI
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_log += '✖ ${e.message}\n';
|
||
_downloading = false;
|
||
});
|
||
}
|
||
}
|
||
|
||
/// 用户手动取消当前下载
|
||
void _cancelDownload() {
|
||
_token?.cancel('用户点击取消');
|
||
setState(() => _log += '⏹ 已发起取消,等待请求中断...\n');
|
||
}
|
||
|
||
/// 重试演示:请求不存在的域名 → DNS 解析失败(connectionError)
|
||
/// → 自动重试 N 次(指数退避)→ 抛"网络异常"
|
||
Future<void> _testRetry() async {
|
||
if (_downloading) return;
|
||
final stopwatch = Stopwatch()..start();
|
||
setState(() => _log = '');
|
||
try {
|
||
await DioClient.instance.get(
|
||
'https://no-such-domain-abcxyz.com/api/test',
|
||
retries: _retryEnabled ? 3 : 0,
|
||
);
|
||
} on ApiException catch (e) {
|
||
if (!mounted) return;
|
||
stopwatch.stop();
|
||
setState(() {
|
||
_log = '✖ ${e.message}\n'
|
||
'⏱ 共耗时 ${stopwatch.elapsed.inMilliseconds}ms\n'
|
||
'(重试开关:${_retryEnabled ? '开' : '关'})\n'
|
||
'📋 看控制台日志:重试开时有 4 行请求 + 3 行 ↻ 重试';
|
||
});
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Scaffold(
|
||
appBar: AppBar(title: const Text('Dio 生产级能力 Demo')),
|
||
body: SingleChildScrollView(
|
||
padding: EdgeInsets.all(16.w),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
// 重试开关
|
||
SwitchListTile(
|
||
value: _retryEnabled,
|
||
onChanged: (v) => setState(() => _retryEnabled = v),
|
||
title: const Text('失败自动重试(指数退避)'),
|
||
subtitle: const Text('重试开 → 下载重试 2 次 / 错误域名重试 3 次'),
|
||
contentPadding: EdgeInsets.zero,
|
||
),
|
||
SizedBox(height: 8.h),
|
||
|
||
// 操作按钮
|
||
Wrap(
|
||
spacing: 8,
|
||
runSpacing: 8,
|
||
children: [
|
||
FilledButton.icon(
|
||
onPressed: _downloading ? null : _startDownload,
|
||
icon: const Icon(Icons.download),
|
||
label: const Text('开始下载'),
|
||
),
|
||
OutlinedButton.icon(
|
||
onPressed: _downloading ? _cancelDownload : null,
|
||
icon: const Icon(Icons.stop_circle_outlined),
|
||
label: const Text('取消下载'),
|
||
),
|
||
OutlinedButton.icon(
|
||
onPressed: _downloading ? null : _testRetry,
|
||
icon: const Icon(Icons.refresh),
|
||
label: const Text('重试演示'),
|
||
),
|
||
],
|
||
),
|
||
SizedBox(height: 20.h),
|
||
|
||
// 下载进度
|
||
Text(
|
||
'下载进度:${(_progress * 100).round()}%',
|
||
style: TextStyle(fontSize: 13.sp, color: const Color(0xFF666666)),
|
||
),
|
||
SizedBox(height: 8.h),
|
||
LinearProgressIndicator(
|
||
value: _progress,
|
||
minHeight: 8,
|
||
borderRadius: BorderRadius.circular(4.r),
|
||
backgroundColor: const Color(0xFFE3E8EF),
|
||
color: const Color(0xFF3B82F6),
|
||
),
|
||
SizedBox(height: 20.h),
|
||
|
||
// 日志区
|
||
Container(
|
||
width: double.infinity,
|
||
padding: EdgeInsets.all(16.w),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(12.r),
|
||
border: Border.all(color: const Color(0xFFE5E7EB)),
|
||
),
|
||
child: SelectableText(
|
||
_log.isEmpty ? '(操作日志,如:✅ 下载完成 / ✖ 网络异常)' : _log,
|
||
style: TextStyle(
|
||
fontSize: 13.sp,
|
||
height: 1.6,
|
||
color: const Color(0xFF666666),
|
||
),
|
||
),
|
||
),
|
||
SizedBox(height: 12.h),
|
||
Text(
|
||
'💡 上传进度用法:DioClient.post(path, data: FormData.fromMap({...}), '
|
||
'onSendProgress: (sent, total) {...}) —— 笔记图片上传的下一步就接它。',
|
||
style: TextStyle(fontSize: 12.sp, color: const Color(0xFF999999)),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|