flutter架 构项目基础

This commit is contained in:
崔宝新
2026-09-04 15:22:20 +08:00
parent 70967229d0
commit 0c5aee59c0
265 changed files with 35484 additions and 0 deletions
+218
View File
@@ -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)),
),
],
),
),
);
}
}