文件分片上传
资料
https://www.cnblogs.com/caijinglong/p/11558389.html
使用分段上传来上传和复制对象 - Amazon Simple Storage Service
因为公司使用的是亚马逊的s3桶 下面是查阅资料获得的
亚马逊s3桶的文件上传分片
分段上分为三个步骤:开始上传、上传对象分段,以及在上传所有分段后完成分段上传。在收到完成分段上传请求后,Amazon S3 会利用上传的分段创建对象,然后您可以像在您的存储桶中访问任何其他对象一样访问该对象。
您可以列出所有正在执行的分段上传,或者获取为特定分段上传操作上传的分段列表。以上每个操作都在本节中进行了说明。
分段上传开始
当您发送请求以开始分段上传时,Amazon S3 将返回具有上传 ID 的响应,此 ID 是分段上传的唯一标识符。无论您何时上传分段、列出分段、完成上传或停止上传,您都必须包括此上传 ID。如果您想要提供描述已上传的对象的任何元数据,必须在请求中提供它以开始分段上传。
分段上传
上传分段时,除了指定上传 ID,还必须指定分段编号。您可以选择 1 和 10000 之间的任意分段编号。分段编号在您正在上传的对象中唯一地识别分段及其位置。您选择的分段编号不必是连续序列(例如,它可以是 1、5 和 14)。如果您使用之前上传的分段的同一分段编号上传新分段,则之前上传的分段将被覆盖。
无论您何时上传分段,Amazon S3 都将在其响应中返回实体标签 (ETag) 标头。对于每个分段上传,您必须记录分段编号和 ETag 值。所有对象分段上传的 ETag 值将保持不变,但将为每个分段分配不同的分段号。您必须在随后的请求中包括这些值以完成分段上传。
分段上传完成
完成分段上传时,Amazon S3 通过按升序的分段编号规范化分段来创建对象。如果在开始分段上传请求中提供了任何对象元数据,则 Amazon S3 会将该元数据与对象相关联。成功完成请求后,分段将不再存在。
完成分段上传请求必须包括上传 ID 以及分段编号和相应的 ETag 值的列表。Amazon S3 响应包括可唯一地识别组合对象数据的 ETag。此 ETag 无需成为对象数据的 MD5 哈希。
文件分片基本原理
前端使用插件获取到本地选择的文件,判断文件的大小,超过设置的限制数,就进行大文件分片上传逻辑
第一步
进行文件分片 下面这个方法 返回的是大文件分片后的开始索引和结束索引
List<List<int>> sliceFileIntoChunks(
int fileSize, int sliceMinSize, int sliceMaxCount) {
List<List<int>> slices = [];
int start = 0;
while (start < fileSize) {
int end = start + sliceMinSize;
if (end > fileSize || slices.length + 1 >= sliceMaxCount) {
end = fileSize;
}
slices.add([start, end]);
start = end;
}
return slices;
}
第二步
将分片信息 生成新的配置对象 配置对象会导出 分片的json
List<SliceChunkItem> config = await utils.getJsonFromSplitFileIntoChunks();
// 切片项
class SliceChunkItem {
// 切片所在文件的位置
final int start;
final int end;
final int partNumber;
String uploadId = "";
String tag = "";
String checksum = "";
List<int> fileBytes = [];
MultipartFile? multipartFile;
SliceChunkItem({
required this.start,
required this.end,
required this.partNumber,
});
setUploadId(id) {
uploadId = id;
}
setTag(id) {
tag = id;
}
Map<String, dynamic> toJson() {
return {
"partNumber": partNumber,
"tag": tag,
"checksum": checksum,
};
}
List<int> toClunk() {
return fileBytes;
}
}
第三步
接口发送,分为三个接口,第一个为初始化接口、第二个为分片上传接口、第三个为文件合成接口(因为需求原因 希望存的是一个完整的文件,且不做分片下载功能)
第一个接口 传递了文件名 和加密的类型 因为是亚马逊 hashMethod= SHA1
String fileName = file.path.split('/').last;
final aa = await multipartUploadInit(
fileName: fileName, checksumType: FileUtils.hashMethod);
第二个接口 因为需要并发去发分片
分片使用FormData进行存 其他信息也加在FormData中
static multipartUpload({
required FormData formData,
}) async {
final res = await dio.post(
Url.multipartUpload,
data: formData,
options: Options(
method: "post",
contentType: "multipart/form-data",
sendTimeout: const Duration(days: 5),
receiveTimeout: const Duration(days: 5),
),
);
return res.data;
}
// 同时对分片进行并发
Future sendItems({
required List<SliceChunkItem> config,
required int concurrentLimit,
required Function(SliceChunkItem) callback,
}) async {
return await Future.wait(config.map((item) async {
// 判断是否需要取消请求
if (cancelCompleter.isCompleted) {
throw 'Requests are cancelled';
}
// 控制并发数量
while (count >= concurrentLimit) {
await Future.delayed(const Duration(milliseconds: 100));
}
count++;
try {
await callback(item);
} finally {
count--;
}
}));
}
使用
await utils.sendItems(
config: config,
concurrentLimit: 5,
callback: (item) async {
item.setUploadId(uploadId);
"[分片上传] bb 开始上传 partNumber ${item.partNumber} ".w();
var fileBytes = await utils.getRange(item.start, item.end);
item.checksum = utils.calculateSHA1FormList(fileBytes);
// 直接传递数组fileBytes 给dio 会导致内存崩溃
formData = FormData.fromMap({
'file': MultipartFile.fromBytes(fileBytes, filename: "11"),
'partNumber': item.partNumber,
'checksum': item.checksum,
'uploadId': item.uploadId,
});
final b = await multipartUpload(formData: formData);
finalUploadSliceCount++;
onSendProgress?.call(finalUploadSliceCount, config.length);
"[分片上传] bb 结束上传 partNumber ${item.partNumber} $b".w();
String tag = b["data"]["tag"];
item.setTag(tag);
});
第三个接口
final cc = await multipartUploadComplete(
// checksum: checksum,
uploadId: uploadId,
partList: config.map((e) => e.toJson()).toList(),
);
大文件加载到内存问题
1、读大文件的时候
去拿文件的句柄 然后通过移动获取不同的文件段
file.openRead();
2、读取后存储问题
如果将获取的分片数据直接存到一个类里面,这样的操作会导致内存被撑爆,
必须发送接口的时候再进行 文件指针方式进行文件数据读取 然后发送接口后直接释放
所有分片上传的接口 做了 读取大文件分片数据的逻辑操作
3、对大文件分片 进行哈希计算
错误代码示范
// ShA 1 进行文件哈希
Future<String> calculateSHA1() async {
if (await file.exists()) {
List<int> contents = await file.readAsBytes();
Digest sha1Result = sha1.convert(contents);
return sha1Result.toString();
} else {
throw const FileSystemException('File not found');
}
}
修改后的代码 (对内存基本没影响)
Future<String> calculateSHA1() async {
if (await file.exists()) {
Digest value = await sha1.bind(file.openRead()).first;
return value.toString();
} else {
throw const FileSystemException('File not found');
}
}
出现的问题 (就是内存被撑爆的原因)
E/DartVM (24105): Exhausted heap space, trying to allocate 67108872 bytes. E/flutter (24105): [ERROR:flutter/runtime/dart_vm_initializer.cc(41)] Unhandled Exception: Out of Memory
出现问题的地方 (分片完成后 直接加载每个分片到内存中了 所有导致内容崩溃)
如下面代码 将获得的分片数据 存在了内存中 ,如果文件过大 就会被撑爆
Future<List<SliceChunkItem>> getJsonFromSplitFileIntoChunks() async {
List<SliceChunkItem> sliceChunkList = [];
// int i = 0;
int partNumber = 1;
// List<int> chunks = splitFileIntoChunks();
// for (var v in chunks) {
// sliceChunkList.add(
// SliceChunkItem(
// start: i,
// end: v + i,
// fileBytes: await getRange(i, v + i),
// partNumber: partNumber,
// ),
// );
// i = v;
// partNumber++;
// }
List<List<int>> chunks =
sliceFileIntoChunks(file.lengthSync(), sliceMinSize, sliceMaxCount);
"[分片上传] ${chunks.length}".w();
for (List<int> v in chunks) {
sliceChunkList.add(
SliceChunkItem(
start: v[0],
end: v[1],
fileBytes: await getRange(v[0], v[1]),
partNumber: partNumber,
),
);
partNumber++;
}
return sliceChunkList;
}
修改如下
发送的时候 再去进行获取 fileBytes 和 checksum;
完整代码如下
大文件上传工具类
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:LS/common/extension/custom_ext.dart';
import 'package:crypto/crypto.dart';
import 'package:dio/dio.dart';
class MyCompleter<T> {
MyCompleter();
Completer<T> completer = Completer();
Future<T> get future => completer.future;
void reply(T result) {
if (!completer.isCompleted) {
completer.complete(result);
}
}
}
class FileUtils {
File file;
// 文件哈希
String hash = "";
static String hashMethod = "SHA1";
// 每个切片最小的大小
int sliceMinSize = 1024 * 1024 * 10; // 10MB
// 最大的切片数量
int sliceMaxCount = 10000;
// 限制并发数量的计数器
int count = 0;
// 用于取消请求的Completer
final cancelCompleter = Completer<void>();
FileUtils(this.file) {
// 后端改动不需要l
// // 默认后台进行文件求哈希
// backstageCalculateSHA1();
}
// 读取文件的某个范围返回
Future<List<int>> getRange(int start, int end) async {
if (start < 0) {
throw RangeError.range(start, 0, file.lengthSync());
}
if (end > file.lengthSync()) {
throw RangeError.range(end, 0, file.lengthSync());
}
final c = MyCompleter<List<int>>();
List<int> result = [];
file.openRead(start, end).listen((data) {
result.addAll(data);
}).onDone(() {
c.reply(result);
});
return c.future;
}
Stream<List<int>> getRangeStream(int start, int end) {
if (start < 0) {
throw RangeError.range(start, 0, file.lengthSync());
}
if (end > file.lengthSync()) {
throw RangeError.range(end, 0, file.lengthSync());
}
return file.openRead(start, end);
}
// 读取文件的前n个字节返回
List<int> splitFileIntoChunks() {
final size = file.lengthSync();
int chunkSize = size ~/ sliceMaxCount;
chunkSize = chunkSize < sliceMinSize ? sliceMinSize : chunkSize;
List<int> chunkSizes = [];
int currentPosition = 0;
while (currentPosition < size) {
int remainingSize = size - currentPosition;
int currentChunkSize =
remainingSize > chunkSize ? chunkSize : remainingSize;
chunkSizes.add(currentChunkSize);
currentPosition += currentChunkSize;
}
return chunkSizes;
}
List<List<int>> sliceFileIntoChunks(
int fileSize, int sliceMinSize, int sliceMaxCount) {
List<List<int>> slices = [];
int start = 0;
while (start < fileSize) {
int end = start + sliceMinSize;
if (end > fileSize || slices.length + 1 >= sliceMaxCount) {
end = fileSize;
}
slices.add([start, end]);
start = end;
}
return slices;
}
Future<List<SliceChunkItem>> getJsonFromSplitFileIntoChunks() async {
List<SliceChunkItem> sliceChunkList = [];
int partNumber = 1;
List<List<int>> chunks =
sliceFileIntoChunks(file.lengthSync(), sliceMinSize, sliceMaxCount);
"[分片上传] 当前分片 ${chunks.length}".w();
for (List<int> v in chunks) {
sliceChunkList.add(
SliceChunkItem(
start: v[0],
end: v[1],
partNumber: partNumber,
),
);
partNumber++;
}
return sliceChunkList;
}
// 将M 单位转为基本单位字节
static int mToSize(int m) {
return 1024 * 1024 * m;
}
// ShA 1 进行文件哈希
Future<String> calculateSHA1(Stream<List<int>> stream) async {
Digest digest = await sha1.bind(stream).first;
return base64.encode(digest.bytes);
}
// 将数组数据重新组合成文件
///
/// 测试使用 将分片合成一个文件 写到本地
// String appDocDir = (await getDownloadsDirectory())?.path ?? "";
// String filePath = '$appDocDir/new.zip';
// await FileUtils.mergeChunksIntoFile(
// config.map((e) => e.toClunk()).toList(), filePath);
static Future<void> mergeChunksIntoFile(
List<List<int>> chunks, String outputPath) async {
File outputFile = File(outputPath);
outputFile.createSync();
IOSink output = outputFile.openWrite(mode: FileMode.writeOnlyAppend);
"将数组数据重新组合成文件 a".w();
for (List<int> chunk in chunks) {
output.add(chunk);
"将数组数据重新组合成文件 b".w();
}
"将数组数据重新组合成文件 c".w();
await output.close();
}
String calculateSHA1FormList(List<int> data) {
Digest digest = sha1.convert(data);
return base64.encode(digest.bytes);
}
static String staticCalculateSHA1FormList(List<int> data) {
Digest digest = sha1.convert(data);
return base64.encode(digest.bytes);
}
// 后台进行对文件的哈希
// backstageCalculateSHA1() async {
// hash = await calculateSHA1();
// return hash;
// }
// 同时对分片进行并发
Future sendItems({
required List<SliceChunkItem> config,
required int concurrentLimit,
required Function(SliceChunkItem) callback,
}) async {
return await Future.wait(config.map((item) async {
// 判断是否需要取消请求
if (cancelCompleter.isCompleted) {
throw 'Requests are cancelled';
}
// 控制并发数量
while (count >= concurrentLimit) {
await Future.delayed(const Duration(milliseconds: 100));
}
count++;
try {
await callback(item);
} finally {
count--;
}
}));
}
// 取消所有请求
cancelSendItems() {
if (!cancelCompleter.isCompleted) {
cancelCompleter.complete();
}
count = 0;
"[切片上传] 取消并发成功".w();
}
}
// 切片项
class SliceChunkItem {
// 切片所在文件的位置
final int start;
final int end;
final int partNumber;
String uploadId = "";
String tag = "";
String checksum = "";
List<int> fileBytes = [];
MultipartFile? multipartFile;
SliceChunkItem({
required this.start,
required this.end,
required this.partNumber,
});
setUploadId(id) {
uploadId = id;
}
setTag(id) {
tag = id;
}
Map<String, dynamic> toJson() {
return {
"partNumber": partNumber,
"tag": tag,
"checksum": checksum,
};
}
List<int> toClunk() {
return fileBytes;
}
}
分片上传总接口
// 文件分片上传
static uploadSliceFile(
String path, {
ProgressCallback? onSendProgress,
required Function(FileUtils) getFileUtils,
}) async {
File file = File(path);
String fileName = file.path.split('/').last;
final utils = FileUtils(file);
getFileUtils.call(utils);
List<SliceChunkItem> config = await utils.getJsonFromSplitFileIntoChunks();
final aa = await multipartUploadInit(
fileName: fileName, checksumType: FileUtils.hashMethod);
String uploadId = aa['data']['uploadId'];
"[分片上传] aa $aa".w();
int finalUploadSliceCount = 0;
FormData formData;
// for (SliceChunkItem item in config) {
// item.setUploadId(uploadId);
// "[分片上传] bb 开始上传 partNumber ${item.partNumber} ".w();
// var fileBytes = await utils.getRange(item.start, item.end);
// item.checksum = utils.calculateSHA1FormList(fileBytes);
// // 直接传递数组fileBytes 给dio 会导致内存崩溃
// formData = FormData.fromMap({
// 'file': MultipartFile.fromBytes(fileBytes, filename: "11"),
// 'partNumber': item.partNumber,
// 'checksum': item.checksum,
// 'uploadId': item.uploadId,
// });
// final b = await multipartUpload(formData: formData);
// finalUploadSliceCount++;
// onSendProgress?.call(finalUploadSliceCount, config.length);
// "[分片上传] bb 结束上传 partNumber ${item.partNumber} $b".w();
// String tag = b["data"]["tag"];
// item.setTag(tag);
// }
await utils.sendItems(
config: config,
concurrentLimit: 5,
callback: (item) async {
item.setUploadId(uploadId);
"[分片上传] bb 开始上传 partNumber ${item.partNumber} ".w();
var fileBytes = await utils.getRange(item.start, item.end);
item.checksum = utils.calculateSHA1FormList(fileBytes);
// 直接传递数组fileBytes 给dio 会导致内存崩溃
formData = FormData.fromMap({
'file': MultipartFile.fromBytes(fileBytes, filename: "11"),
'partNumber': item.partNumber,
'checksum': item.checksum,
'uploadId': item.uploadId,
});
final b = await multipartUpload(formData: formData);
finalUploadSliceCount++;
onSendProgress?.call(finalUploadSliceCount, config.length);
"[分片上传] bb 结束上传 partNumber ${item.partNumber} $b".w();
String tag = b["data"]["tag"];
item.setTag(tag);
});
// 废弃 后端不需要整体文件的hash
// String checksum = utils.hash;
// if (checksum.isEmpty) {
// checksum = await utils.backstageCalculateSHA1();
// }
final cc = await multipartUploadComplete(
// checksum: checksum,
uploadId: uploadId,
partList: config.map((e) => e.toJson()).toList(),
);
// String filePath = cc["data"]["file_path"];
"[分片上传] cc $cc".w();
return cc;
}
文件上传组件代码
import 'dart:io';
import 'package:LS/common/index.dart';
import 'package:LS/gen/assets.gen.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:image_picker/image_picker.dart';
enum UploadType {
image,
file,
}
class UploadWidget extends StatefulWidget {
final UploadType type;
final Function(String, int)? onSuccess;
final Function(int, List, UploadType)? onDelete;
final Function()? onPickAssets;
final List<String>? allowedExtensions;
final int? limit;
const UploadWidget({
super.key,
this.type = UploadType.image,
this.onSuccess,
this.onDelete,
this.limit,
this.onPickAssets,
this.allowedExtensions,
});
@override
State<UploadWidget> createState() => _UploadWidgetState();
}
class _UploadWidgetState extends State<UploadWidget> {
// 这两个是上传图片的时候存的
List<Uint8List> webImageList = [];
List<File> appImageList = [];
// 这两个是上传文件的时候存的 文件上传只有 接口上传的时候 有不同所有只要一个
List<FilePickerResult> filesList = [];
FilePickerResult? files;
Widget get curContain {
switch (widget.type) {
case UploadType.image:
return uploadImage();
case UploadType.file:
return uploadFile();
}
}
onPreview(String url) {
if (url.isNotEmpty) {
Get.to(
() => ImagePreviewPage(
imageUrl: url,
),
);
}
}
// 选择图片
pickImages() async {
Uint8List? webImage;
File? appImage;
XFile? image = await AppToast.getLostData();
if (image != null) {
if (kIsWeb) {
webImage = await image.readAsBytes();
} else {
appImage = File(image.path);
}
}
if (webImage != null) {
webImageList.add(webImage);
}
if (appImage != null) {
appImageList.add(appImage);
}
widget.onPickAssets?.call();
setState(() {});
}
bool isValidExtension(FilePickerResult files) {
return files.files.every((file) {
String extension = (file.extension ?? "").toLowerCase();
return (widget.allowedExtensions ??
[
'jpg',
'png',
'doc',
'xls',
'pdf',
'ppt',
'docx',
'xlsx',
'pptx'
])
.contains(extension);
});
}
// 选择文件
pickFiles() async {
files = await AppToast.getLostFileData(
allowMultiple: false,
allowedExtensions: (widget.allowedExtensions ??
['jpg', 'png', 'doc', 'xls', 'pdf', 'ppt', 'docx', 'xlsx', 'pptx']),
);
if (files != null) {
if (!isValidExtension(files as FilePickerResult)) {
AppToast.show("请选择正确的文件格式");
return;
}
filesList.add(files!);
widget.onPickAssets?.call();
setState(() {});
}
}
// 上传图片
Widget uploadImage() {
return SizedBox(
width: double.infinity,
child: GridView.builder(
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
childAspectRatio: 1,
),
// +1 是为了添加图片按钮
itemCount: widget.limit != null &&
widget.limit! <=
(kIsWeb ? webImageList.length : appImageList.length)
? (kIsWeb ? webImageList.length : appImageList.length)
: (kIsWeb ? webImageList.length : appImageList.length) + 1,
itemBuilder: (c, i) {
if (kIsWeb) {
if (i >= webImageList.length) {
return addContainer(onTap: pickImages);
}
return UploadingImageWidget(
webImage: webImageList[i],
onDelete: () => onImageDelete(i),
onSuccess: (url) => onSuccess(i, url),
onPreview: onPreview,
);
} else {
if (i >= appImageList.length) {
return addContainer(onTap: pickImages);
}
return UploadingImageWidget(
image: appImageList[i],
onDelete: () => onImageDelete(i),
onSuccess: (url) => onSuccess(i, url),
onPreview: onPreview,
);
}
},
),
);
}
// 上传文件
Widget uploadFile() {
return SizedBox(
width: double.infinity,
child: GridView.builder(
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
childAspectRatio: 1,
),
// +1 是为了添加图片按钮
itemCount: widget.limit != null && widget.limit! <= filesList.length
? filesList.length
: (filesList.length + 1),
itemBuilder: (c, i) {
if (i >= filesList.length) {
return addContainer(onTap: pickFiles);
}
return UploadingFileWidget(
files: filesList[i],
index: i,
onDelete: () => onFileDelete(i),
onSuccess: (url) => onSuccess(i, url),
onPreview: onPreview,
);
},
),
);
}
onImageDelete(int i) {
if (kIsWeb) {
webImageList.removeAt(i);
widget.onDelete?.call(i, webImageList, UploadType.image);
} else {
appImageList.removeAt(i);
widget.onDelete?.call(i, appImageList, UploadType.image);
}
setState(() {});
}
onFileDelete(int i) {
filesList.removeAt(i);
widget.onDelete?.call(i, filesList, UploadType.file);
setState(() {});
}
onSuccess(int i, String url) {
widget.onSuccess?.call(url, i);
}
// 已上传完成的容器
Widget hasUploadContainer() {
return Container(
width: 75.w,
height: 75.w,
decoration: BoxDecoration(
color: HexColor("#F2F4F7"),
borderRadius: BorderRadius.circular(5.r),
),
);
}
// 点击添加
Widget addContainer({
Function? onTap,
}) {
return InkWell(
child: Container(
width: 75.w,
height: 75.w,
decoration: BoxDecoration(
color: HexColor("#F2F4F7"),
borderRadius: BorderRadius.circular(5.r),
image: DecorationImage(
image: Assets.images.uploadAdd.provider(),
),
),
alignment: Alignment.center,
),
onTap: () {
onTap?.call();
},
);
}
@override
Widget build(BuildContext context) {
return curContain;
}
}
// 上传图片 上传中的组件
class UploadingImageWidget extends StatefulWidget {
final File? image;
final Uint8List? webImage;
final Function(String)? onSuccess;
final Function()? onFail;
final Function()? onDelete;
final Function(String)? onPreview;
const UploadingImageWidget({
super.key,
this.image,
this.webImage,
this.onSuccess,
this.onFail,
this.onDelete,
this.onPreview,
});
@override
State<UploadingImageWidget> createState() => _UploadingImageWidgetState();
}
class _UploadingImageWidgetState extends State<UploadingImageWidget> {
// 是否上传失败
bool isUploadFail = false;
double cruProgress = 0.0;
String httpPath = "";
// 正在上传中
bool isUploading = false;
@override
void initState() {
super.initState();
initUpload();
}
// 立即进行上传
initUpload() {
try {
kIsWeb ? webUpload() : appUpload();
} catch (e) {
widget.onFail?.call();
setState(() {
isUploadFail = true;
});
}
}
onTapDelete() {
widget.onDelete?.call();
}
webUpload() async {
setState(() {
isUploading = true;
});
final res = await Api.uploadFileListInt(
widget.webImage as Uint8List,
name: "img",
onSendProgress: (count, total) {
setState(() {
cruProgress = count / total;
});
},
);
setState(() {
isUploading = false;
});
httpPath = res["data"] ?? "";
widget.onSuccess?.call(httpPath);
}
appUpload() async {
setState(() {
isUploading = true;
});
final res = await Api.uploadFile(
widget.image!.path,
onSendProgress: (count, total) {
setState(() {
cruProgress = count / total;
});
},
);
setState(() {
isUploading = false;
});
httpPath = res["data"] ?? "";
widget.onSuccess?.call(httpPath);
}
@override
Widget build(BuildContext context) {
return AnimatedContainer(
duration: const Duration(seconds: 1),
width: double.infinity,
height: double.infinity,
decoration: BoxDecoration(
color: HexColor("#F2F4F7"),
borderRadius: BorderRadius.circular(5.r),
),
child: isUploadFail
? failUploadContainer()
: (cruProgress == 1.0 && !isUploading
? finallyUploadContainer()
: beforeUploadContainer()),
);
}
// 上传之前的容器
Stack beforeUploadContainer() {
return Stack(
clipBehavior: Clip.none,
children: [
if (widget.webImage != null)
Container(
width: double.infinity,
height: double.infinity,
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(5.r),
),
child: Image.memory(
widget.webImage as Uint8List,
width: double.infinity,
height: double.infinity,
),
),
if (widget.image != null)
Container(
width: double.infinity,
height: double.infinity,
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(5.r),
),
child: Image.file(
widget.image as File,
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
),
),
Positioned.fill(
child: Opacity(
opacity: 0.6,
child: Container(
width: double.infinity,
height: double.infinity,
decoration: BoxDecoration(
color: HexColor("#000000"),
borderRadius: BorderRadius.circular(5.r),
),
),
),
),
Positioned(
top: -4.w,
right: -4.w,
child: InkWell(
onTap: onTapDelete,
child: Container(
width: 18.w,
height: 18.w,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Colors.white,
),
child: Assets.images.uploadClose.image(width: 18.w),
),
),
),
Positioned.fill(
child: Container(
width: double.infinity,
height: double.infinity,
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(5.r),
),
child: SizedBox(
width: 40.w,
child: LineProgressWidget(
cruProgress: cruProgress,
minHeight: 5.h,
color: HexColor("#CCCCCC"),
showText: false,
valueColor: AlwaysStoppedAnimation<Color>(HexColor("#F9DE4A")),
),
),
),
),
],
);
}
// 完成上传的容器
Stack finallyUploadContainer() {
return Stack(
clipBehavior: Clip.none,
children: [
if (widget.webImage != null)
InkWell(
onTap: () => widget.onPreview?.call(httpPath),
child: Container(
width: double.infinity,
height: double.infinity,
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(5.r),
),
child: Image.memory(
widget.webImage as Uint8List,
width: double.infinity,
height: double.infinity,
),
),
),
if (widget.image != null)
InkWell(
onTap: () => widget.onPreview?.call(httpPath),
child: Container(
width: double.infinity,
height: double.infinity,
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(5.r),
),
child: Image.file(
widget.image as File,
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
),
),
),
Positioned(
top: -4.w,
right: -4.w,
child: InkWell(
onTap: onTapDelete,
child: Container(
width: 18.w,
height: 18.w,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Colors.white,
),
child: Assets.images.uploadClose.image(width: 18.w),
),
),
),
],
);
}
// 上传失败重新上传
failReUpload() async {
cruProgress = 0.0;
isUploadFail = false;
setState(() {});
initUpload();
}
// 失败上传的容器
Stack failUploadContainer() {
return Stack(
clipBehavior: Clip.none,
children: [
if (widget.webImage != null)
Container(
width: double.infinity,
height: double.infinity,
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(5.r),
),
child: Image.memory(
widget.webImage as Uint8List,
width: double.infinity,
height: double.infinity,
),
),
if (widget.image != null)
Container(
width: double.infinity,
height: double.infinity,
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(5.r),
),
child: Image.file(
widget.image as File,
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
),
),
Positioned.fill(
child: Container(
width: double.infinity,
height: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(5.r),
color: HexColor("#000000").withOpacity(0.6),
),
alignment: Alignment.center,
child: InkWell(
onTap: failReUpload,
child: SizedBox(
width: 20.w,
height: 20.w,
child: Assets.images.uploadReload.image(width: 20.w),
),
),
),
),
Positioned(
top: -4.w,
right: -4.w,
child: InkWell(
onTap: onTapDelete,
child: Container(
width: 18.w,
height: 18.w,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Colors.white,
),
child: Assets.images.uploadClose.image(width: 18.w),
),
),
),
],
);
}
}
// 上传文件 上传中的组件
class UploadingFileWidget extends StatefulWidget {
final int index;
final FilePickerResult? files;
final Function(String)? onSuccess;
final Function()? onFail;
final Function()? onDelete;
final Function(String)? onPreview;
const UploadingFileWidget({
super.key,
this.files,
required this.index,
this.onSuccess,
this.onFail,
this.onDelete,
this.onPreview,
});
@override
State<UploadingFileWidget> createState() => _UploadingFileWidgetState();
}
class _UploadingFileWidgetState extends State<UploadingFileWidget> {
// 是否上传失败
bool isUploadFail = false;
// 正在上传中
bool isUploading = false;
double cruProgress = 0.0;
String httpPath = "";
Function? cancelSendItems;
@override
void initState() {
super.initState();
initUpload();
}
// 立即进行上传
initUpload() {
try {
kIsWeb ? webUpload() : appUpload();
} catch (e) {
widget.onFail?.call();
setState(() {
isUploadFail = true;
});
}
}
onTapDelete() {
widget.onDelete?.call();
if (cancelSendItems != null) {
cancelSendItems!.call();
}
}
webUpload() async {
PlatformFile curFile = widget.files!.files.first;
setState(() {
isUploading = true;
});
final res = await Api.uploadFilePlatformFile(
curFile,
onSendProgress: (count, total) {
setState(() {
cruProgress = count / total;
});
},
);
httpPath = res["data"] ?? "";
setState(() {
isUploading = false;
});
widget.onSuccess?.call(httpPath);
}
appUpload() async {
var path = widget.files!.paths.first;
int size = widget.files?.files.first.size ?? 0;
if (size > FileUtils.mToSize(20)) {
appSliceUpload();
return;
}
setState(() {
isUploading = true;
});
final res = await Api.uploadFile(
path as String,
onSendProgress: (count, total) async {
// await Future.delayed(const Duration(seconds: 1));
setState(() {
cruProgress = count / total;
});
},
);
setState(() {
isUploading = false;
});
httpPath = res["data"] ?? "";
widget.onSuccess?.call(httpPath);
}
// 大文件切片上传
appSliceUpload() async {
var path = widget.files!.paths.first;
setState(() {
isUploading = true;
});
final res = await Api.uploadSliceFile(
path as String,
onSendProgress: (count, total) async {
// await Future.delayed(const Duration(seconds: 1));
setState(() {
cruProgress = count / total;
});
},
getFileUtils: (utils) {
"[分片上传] 获取 utils $utils".w();
cancelSendItems = () => utils.cancelSendItems();
},
);
setState(() {
isUploading = false;
});
httpPath = res["data"] ?? "";
widget.onSuccess?.call(httpPath);
}
@override
Widget build(BuildContext context) {
return AnimatedContainer(
duration: const Duration(seconds: 1),
width: double.infinity,
height: double.infinity,
decoration: BoxDecoration(
color: HexColor("#F2F4F7"),
borderRadius: BorderRadius.circular(5.r),
),
child: isUploadFail
? failUploadContainer()
: (cruProgress == 1.0 && !isUploading
? finallyUploadContainer()
: beforeUploadContainer()),
);
}
// 上传之前的容器
Stack beforeUploadContainer() {
return Stack(
clipBehavior: Clip.none,
children: [
if (widget.files?.files.first != null)
fileInfoContainer(widget.files?.files.first),
Positioned.fill(
child: Opacity(
opacity: 0.6,
child: Container(
width: double.infinity,
height: double.infinity,
decoration: BoxDecoration(
color: HexColor("#000000"),
borderRadius: BorderRadius.circular(5.r),
),
),
),
),
Positioned.fill(
child: Container(
width: double.infinity,
height: double.infinity,
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(5.r),
),
child: SizedBox(
width: 40.w,
child: LineProgressWidget(
cruProgress: cruProgress,
minHeight: 5.h,
color: HexColor("#CCCCCC"),
showText: false,
valueColor: AlwaysStoppedAnimation<Color>(HexColor("#F9DE4A")),
),
),
),
),
Positioned(
top: -4.w,
right: -4.w,
child: InkWell(
onTap: onTapDelete,
child: Container(
width: 18.w,
height: 18.w,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Colors.white,
),
child: Assets.images.uploadClose.image(width: 18.w),
),
),
),
],
);
}
// 文件信息展示容器
Widget fileInfoContainer(PlatformFile? file) {
String fileName = file?.name ?? "";
if (Utils.isImageFile(fileName)) {
if (kIsWeb) {
Uint8List webImageFile = file?.bytes as Uint8List;
return InkWell(
onTap: () => widget.onPreview?.call(httpPath),
child: Container(
width: double.infinity,
height: double.infinity,
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(5.r),
),
child: Image.memory(
webImageFile,
width: double.infinity,
height: double.infinity,
),
),
);
} else {
File imageFile = File(file?.path ?? "");
return InkWell(
onTap: () => widget.onPreview?.call(httpPath),
child: Container(
width: double.infinity,
height: double.infinity,
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(5.r),
),
child: Image.file(
imageFile,
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
),
),
);
}
}
return Container(
width: double.infinity,
height: double.infinity,
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(5.r),
),
alignment: Alignment.center,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Assets.images.uploadFileIcon.image(width: 20.w),
SizedBox(height: 5.h),
Text(
fileName,
style: TextStyle(
fontFamily: Font.pingFang,
fontWeight: FontWeight.w500,
fontSize: 12.sp,
color: HexColor("#1A1A1A"),
height: 1.1,
),
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
maxLines: 3,
),
],
),
);
}
// 完成上传的容器
Stack finallyUploadContainer() {
return Stack(
clipBehavior: Clip.none,
children: [
if (widget.files?.files.first != null)
fileInfoContainer(widget.files?.files.first),
Positioned(
top: -4.w,
right: -4.w,
child: InkWell(
onTap: onTapDelete,
child: Container(
width: 18.w,
height: 18.w,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Colors.white,
),
child: Assets.images.uploadClose.image(width: 18.w),
),
),
),
],
);
}
// 上传失败重新上传
failReUpload() async {
cruProgress = 0.0;
isUploadFail = false;
setState(() {});
initUpload();
}
// 失败上传的容器
Stack failUploadContainer() {
return Stack(
clipBehavior: Clip.none,
children: [
if (widget.files?.files.first != null)
fileInfoContainer(widget.files?.files.first),
Positioned.fill(
child: Container(
width: double.infinity,
height: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(5.r),
color: HexColor("#000000").withOpacity(0.6),
),
alignment: Alignment.center,
child: InkWell(
onTap: failReUpload,
child: SizedBox(
width: 20.w,
height: 20.w,
child: Assets.images.uploadReload.image(width: 20.w),
),
),
),
),
Positioned(
top: -4.w,
right: -4.w,
child: InkWell(
onTap: onTapDelete,
child: Container(
width: 18.w,
height: 18.w,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Colors.white,
),
child: Assets.images.uploadClose.image(width: 18.w),
),
),
),
],
);
}
}
// 已上传图片或文件展示
class HasUploadShowWidget extends StatelessWidget {
final List<String?> urls;
final Function(int)? onDelete;
final bool showDelete;
final Function(String)? onFileTap;
final TextDirection textDirection;
const HasUploadShowWidget({
super.key,
required this.urls,
this.onDelete,
this.showDelete = true,
this.onFileTap,
this.textDirection = TextDirection.ltr,
});
onTapDelete(int idx) {
onDelete?.call(idx);
}
onPreview(String url) {
if (url.isNotEmpty) {
Get.to(
() => ImagePreviewPage(
imageUrl: url,
),
);
}
}
@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity,
child: Directionality(
textDirection: textDirection,
child: GridView.builder(
shrinkWrap: true,
itemCount: urls.length,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
childAspectRatio: 1,
),
itemBuilder: (c, i) {
if (Utils.isImageFile(urls[i] ?? "")) {
return Stack(
clipBehavior: Clip.none,
children: [
InkWell(
onTap: () => onPreview(urls[i] ?? ""),
child: Container(
width: 75.w,
height: 75.w,
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(5.r),
),
child: urls[i] != null
? CachedNetworkImage(
width: 75.w,
height: 75.w,
imageUrl: urls[i] ?? "",
fit: BoxFit.cover,
)
: null,
),
),
if (showDelete)
Positioned(
top: -4.w,
right: -4.w,
child: InkWell(
onTap: () => onTapDelete(i),
child: Container(
width: 18.w,
height: 18.w,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Colors.white,
),
child: Assets.images.uploadClose.image(width: 18.w),
),
),
),
],
);
}
return Stack(
clipBehavior: Clip.none,
children: [
InkWell(
onTap: () => onFileTap?.call(urls[i] ?? ""),
child: Container(
width: 75.w,
height: 75.w,
clipBehavior: Clip.none,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(5.r),
color: HexColor("#F2F4F7"),
),
alignment: Alignment.center,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Assets.images.uploadFileIcon.image(width: 20.w),
SizedBox(height: 5.h),
Container(
constraints: BoxConstraints(maxWidth: 75.w),
child: Text(
Utils.getFileNameFromUrl((urls[i] ?? "")),
style: TextStyle(
fontFamily: Font.pingFang,
fontWeight: FontWeight.w500,
fontSize: 12.sp,
color: HexColor("#1A1A1A"),
height: 1.1,
),
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
maxLines: 2,
),
),
],
),
),
),
if (showDelete)
Positioned(
top: -4.w,
right: -4.w,
child: InkWell(
onTap: () => onTapDelete(i),
child: Container(
width: 18.w,
height: 18.w,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Colors.white,
),
child: Assets.images.uploadClose.image(width: 18.w),
),
),
),
],
);
}),
),
);
}
}
使用 文件上传 (如果上传的是图片 显示的也是图片样式)
UploadWidget(
type: UploadType.file,
limit: 5 - controller.lastFiles.length,
onPickAssets: () {
controller.curUploadCount++;
},
onDelete: (i, list, t) {
controller.curUploadCount--;
controller.state.files.removeAt(i);
controller.update();
},
onSuccess: (url, i) {
controller.curUploadCount--;
controller.state.files.add(url);
controller.update();
},
)
图片上传
UploadWidget(
type: UploadType.image,
limit: 9,
onPickAssets: () {
curUploadCount++;
setState(() {});
},
onDelete: (i, list, t) {
curUploadCount--;
print("文件 -- $curUploadCount");
imagesFiles.removeAt(i);
setState(() {});
},
onSuccess: (url, i) {
curUploadCount--;
// controller.state.files.add(url);
imagesFiles.add(url);
setState(() {});
},
),
支持撤销文件上传