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 createState() => _DioProDemoPageState(); } class _DioProDemoPageState extends State { /// 大文件地址(约 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 _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 _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)), ), ], ), ), ); } }