flutter架 构项目基础
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user