flutter 文件上传组件和大文件分片上传

文件分片上传

flutter 文件上传组件和大文件分片上传_第1张图片

资料

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 哈希。

flutter 文件上传组件和大文件分片上传_第2张图片

flutter 文件上传组件和大文件分片上传_第3张图片

文件分片基本原理

前端使用插件获取到本地选择的文件,判断文件的大小,超过设置的限制数,就进行大文件分片上传逻辑

第一步

进行文件分片 下面这个方法 返回的是大文件分片后的开始索引和结束索引

 List> sliceFileIntoChunks(
      int fileSize, int sliceMinSize, int sliceMaxCount) {
    List> 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 config = await utils.getJsonFromSplitFileIntoChunks();
// 切片项
class SliceChunkItem {
  // 切片所在文件的位置
  final int start;
  final int end;
  final int partNumber;
  String uploadId = "";
  String tag = "";
  String checksum = "";
  List fileBytes = [];
  MultipartFile? multipartFile;

  SliceChunkItem({
    required this.start,
    required this.end,
    required this.partNumber,
  });

  setUploadId(id) {
    uploadId = id;
  }

  setTag(id) {
    tag = id;
  }

  Map toJson() {
    return {
      "partNumber": partNumber,
      "tag": tag,
      "checksum": checksum,
    };
  }

  List 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 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 calculateSHA1() async {
    if (await file.exists()) {
      List contents = await file.readAsBytes();
      Digest sha1Result = sha1.convert(contents);
      return sha1Result.toString();
    } else {
      throw const FileSystemException('File not found');
    }
  }

修改后的代码 (对内存基本没影响)

 Future 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> getJsonFromSplitFileIntoChunks() async {
    List sliceChunkList = [];
    // int i = 0;
    int partNumber = 1;
    // List 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> chunks =
        sliceFileIntoChunks(file.lengthSync(), sliceMinSize, sliceMaxCount);
    "[分片上传] ${chunks.length}".w();
    for (List 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 {
  MyCompleter();
  Completer completer = Completer();

  Future 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();

  FileUtils(this.file) {
    // 后端改动不需要l
    // // 默认后台进行文件求哈希
    // backstageCalculateSHA1();
  }

  // 读取文件的某个范围返回
  Future> 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 result = [];
    file.openRead(start, end).listen((data) {
      result.addAll(data);
    }).onDone(() {
      c.reply(result);
    });
    return c.future;
  }

  Stream> 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 splitFileIntoChunks() {
    final size = file.lengthSync();
    int chunkSize = size ~/ sliceMaxCount;
    chunkSize = chunkSize < sliceMinSize ? sliceMinSize : chunkSize;

    List 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> sliceFileIntoChunks(
      int fileSize, int sliceMinSize, int sliceMaxCount) {
    List> 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> getJsonFromSplitFileIntoChunks() async {
    List sliceChunkList = [];
    int partNumber = 1;
    List> chunks =
        sliceFileIntoChunks(file.lengthSync(), sliceMinSize, sliceMaxCount);
    "[分片上传] 当前分片 ${chunks.length}".w();

    for (List 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 calculateSHA1(Stream> 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 mergeChunksIntoFile(
      List> chunks, String outputPath) async {
    File outputFile = File(outputPath);
    outputFile.createSync();
    IOSink output = outputFile.openWrite(mode: FileMode.writeOnlyAppend);
    "将数组数据重新组合成文件 a".w();
    for (List chunk in chunks) {
      output.add(chunk);
      "将数组数据重新组合成文件 b".w();
    }
    "将数组数据重新组合成文件 c".w();

    await output.close();
  }

  String calculateSHA1FormList(List data) {
    Digest digest = sha1.convert(data);
    return base64.encode(digest.bytes);
  }

  static String staticCalculateSHA1FormList(List data) {
    Digest digest = sha1.convert(data);
    return base64.encode(digest.bytes);
  }

  // 后台进行对文件的哈希
  // backstageCalculateSHA1() async {
  //   hash = await calculateSHA1();
  //   return hash;
  // }

  // 同时对分片进行并发
  Future sendItems({
    required List 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 fileBytes = [];
  MultipartFile? multipartFile;

  SliceChunkItem({
    required this.start,
    required this.end,
    required this.partNumber,
  });

  setUploadId(id) {
    uploadId = id;
  }

  setTag(id) {
    tag = id;
  }

  Map toJson() {
    return {
      "partNumber": partNumber,
      "tag": tag,
      "checksum": checksum,
    };
  }

  List 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 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? allowedExtensions;
  final int? limit;

  const UploadWidget({
    super.key,
    this.type = UploadType.image,
    this.onSuccess,
    this.onDelete,
    this.limit,
    this.onPickAssets,
    this.allowedExtensions,
  });

  @override
  State createState() => _UploadWidgetState();
}

class _UploadWidgetState extends State {
  // 这两个是上传图片的时候存的
  List webImageList = [];
  List appImageList = [];

  // 这两个是上传文件的时候存的 文件上传只有 接口上传的时候 有不同所有只要一个
  List 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 createState() => _UploadingImageWidgetState();
}

class _UploadingImageWidgetState extends State {
  // 是否上传失败
  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(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 createState() => _UploadingFileWidgetState();
}

class _UploadingFileWidgetState extends State {
  // 是否上传失败
  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(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 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(() {});
                                },
                              ),

支持撤销文件上传

你可能感兴趣的:(flutter,前端)