文件分片上傳
資料
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;// 用于取消請求的Completerfinal 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,});@overrideState<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();},);}@overrideWidget 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,});@overrideState<UploadingImageWidget> createState() => _UploadingImageWidgetState();
}class _UploadingImageWidgetState extends State<UploadingImageWidget> {// 是否上傳失敗bool isUploadFail = false;double cruProgress = 0.0;String httpPath = "";// 正在上傳中bool isUploading = false;@overridevoid 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);}@overrideWidget 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,});@overrideState<UploadingFileWidget> createState() => _UploadingFileWidgetState();
}class _UploadingFileWidgetState extends State<UploadingFileWidget> {// 是否上傳失敗bool isUploadFail = false;// 正在上傳中bool isUploading = false;double cruProgress = 0.0;String httpPath = "";Function? cancelSendItems;@overridevoid 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);}@overrideWidget 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,),);}}@overrideWidget 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(() {});},),
支持撤銷文件上傳
斷點續傳功能和秒傳
在原有的基礎上改動如下
所有上傳接口都加上文件的MD5 值,分片的幾個接口將uploadId改為整個文件的MD5
新增MD5的方法 依舊使用crypto 庫
crypto | Dart PackageImplementations of SHA, MD5, and HMAC cryptographic functions.https://pub-web.flutter-io.cn/packages/crypto
static staticCalculateMD5(File file) async {if (!file.existsSync()) {print('File "$file" does not exist.');return;}Digest digest = await md5.bind(file.openRead()).first;return digest.toString();}
?app 使用
FormData formData = FormData.fromMap({'file': await MultipartFile.fromFile(path),'md5': await FileUtils.staticCalculateMD5(File(path)),});
web端使用
static staticCalculateMD5Stream(List<int> input) async {Digest digest = md5.convert(input);return digest.toString();}
FormData formData = FormData.fromMap({'file': multipartFile,'md5': await FileUtils.staticCalculateMD5Stream(fileBytes),});
分片接口改動如下
1、初始化分片接口
新增參數?md5 文件的MD5? 這樣就可以驗證文件是否上傳過,如果上傳直接返回文件地址
未上傳完 會返回已上傳的分片 然后對求出未上傳的分片進行繼續上傳?
邏輯修改如下
String filePath = aa['data']['filePath'] ?? "";List<SliceChunkItem> sendSliceConfig = config;int finalUploadSliceCount = 0;// 說明是上傳過的文件 直接秒傳if (filePath.isNotEmpty) {onSendProgress?.call(1, 1);return aa;} else {List partList = (aa['data']['partList'] ?? []);List<SliceChunkItem> filterPartList = partList.map<SliceChunkItem>((e) => SliceChunkItem.fromJson(e)).toList();for (var it in filterPartList) {int idx = config.indexWhere((e) => e.partNumber == it.partNumber);if (idx != -1) {config[idx] = it;}}sendSliceConfig =config.where((e) => !(e.end == 0 && e.start == 0)).toList();if (sendSliceConfig.isNotEmpty) {finalUploadSliceCount = filterPartList.length;onSendProgress?.call(finalUploadSliceCount, config.length);}}
2、分片的接口 新增了文件的MD5
?完成接口一樣的新增md5
最后最新的分片上傳接口邏輯如下
?
// 文件分片上傳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();String curMd5Code = utils.hash;if (curMd5Code.isEmpty) {curMd5Code = await FileUtils.staticCalculateMD5(file);}final aa = await multipartUploadInit(fileName: fileName,checksumType: FileUtils.hashMethod,md5: curMd5Code,);String filePath = aa['data']['filePath'] ?? "";List<SliceChunkItem> sendSliceConfig = config;int finalUploadSliceCount = 0;// 說明是上傳過的文件 直接秒傳if (filePath.isNotEmpty) {onSendProgress?.call(1, 1);return aa;} else {List partList = (aa['data']['partList'] ?? []);List<SliceChunkItem> filterPartList = partList.map<SliceChunkItem>((e) => SliceChunkItem.fromJson(e)).toList();for (var it in filterPartList) {int idx = config.indexWhere((e) => e.partNumber == it.partNumber);if (idx != -1) {config[idx] = it;}}sendSliceConfig =config.where((e) => !(e.end == 0 && e.start == 0)).toList();if (sendSliceConfig.isNotEmpty) {finalUploadSliceCount = filterPartList.length;onSendProgress?.call(finalUploadSliceCount, config.length);}}// String uploadId = aa['data']['uploadId'];"[分片上傳] aa $aa".w();FormData formData;await utils.sendItems(config: sendSliceConfig,concurrentLimit: 4,callback: (item) async {// item.setUploadId(uploadId);"[分片上傳] bb 開始上傳 partNumber ${item.partNumber} ".w();var fileBytes = await utils.getRange(item.start ?? 0, item.end ?? 0);item.checksum = utils.calculateSHA1FormList(fileBytes);// 直接傳遞數組fileBytes 給dio 會導致內存崩潰formData = FormData.fromMap({'file': MultipartFile.fromBytes(fileBytes, filename: "11"),'partNumber': item.partNumber,'checksum': item.checksum,'md5': curMd5Code,});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,md5: curMd5Code,partList: config.map((e) => e.toJson()).toList(),);// String filePath = cc["data"]["file_path"];"[分片上傳] cc $cc".w();return cc;}
上傳組件發現刪除存在bug 修復如下
UploadingFileWidget 組件使用key: ValueKey('image_$i'),進行標識
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,key: ValueKey('image_$i'),);},),
在Flutter中,Key可以是任何類型的對象,但最常用的是 GlobalKey、ValueKey 和 ObjectKey。
GlobalKey: 用于在整個應用程序中唯一標識一個Widget。當你需要在程序的不同部分引用同一個組件時,可以使用GlobalKey。比如,如果你需要通過GlobalKey在一個頁面中的某個組件,可以使用GlobalKey。
ValueKey: 用于基于值的比較,可以根據給定的值來標識Widget,在列表、集合或父子關系中非常有用。比如,如果你有一個具有一組項目的列表,并且需要標識這些項目,可以使用ValueKey,并以項目的唯一標識符作為值。
ObjectKey: 與ValueKey類似,可以根據對象的身份來標識Widget。如果你需要根據對象的身份來標識Widget,而不是基于對象的值,可以使用ObjectKey。
除了這些內置的Key類型,你也可以創建自定義的Key類,只要它們遵循Key的方法和屬性即可。
在你的情況下,你可以使用GlobalKey來保證每個UploadingImageWidget都有唯一的標識,這樣就可以避免狀態混淆的問題。