Files
App.K12.Student.Miniapp_Flu…/lib/pages/demo/dio_pro_demo.dart
T
2026-09-04 15:22:20 +08:00

219 lines
8.6 KiB
Dart
Raw 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.
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)),
),
],
),
),
);
}
}