Flutter 封装:图片上传组件终极版 AssetUploadBox

基于 NUploadBox 的重构组件

一、需求来源

最近遇到上传图片的需求,周末花时间封装成组件,方便复用。支持多选,显示上传百分比,支持失败重连,支持失败删除;每张图片上传成功之后都会进行 url 回调。

新增功能:

  1. 相册中限制图片最大张数(基于 wechat_assets_picker );
  2. 增量上传(最大8张,默认2张,最多可以选择 6张);
  3. 加入了图片压缩功能(默认 2M 以下不显示进度,因为进度是跳闪);
  4. 新增图片显示构建器,支持外部自定义显示网络图片组件;
  5. 新增图片点击方法,支持外部自定义跳转图片预览组件;

效果如下:

选择图片
失败重连

二、使用示例

less 复制代码
/// 没有默认数据
// var selectedModels = <AssetUploadModel>[];

/// 有默认数据
var selectedModels = [  "https://yl-prescription-share.oss-cn-beijing.aliyuncs.com/beta/Health_APP/20230825/fb013ec6b90a4c5bb1059b003dada9ee.jpg",  "https://yl-prescription-share.oss-cn-beijing.aliyuncs.com/beta/Health_APP/20230825/ce326143c5b84fd9b99ffca943353b05.jpg",].map((e) => AssetUploadModel(url: e, entity: null)).toList();

/// 获取图片链接数组
List<String> urls = [];
less 复制代码
buildBody() {
  return SingleChildScrollView(
    child: Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        NText(data: "AssetUploadBox", fontSize: 16,),
        Container(
          padding: EdgeInsets.symmetric(horizontal: 20),
          child: AssetUploadBox(
            maxCount: 8,
            // rowCount: 4,
            items: selectedModels,
            // canEdit: false,
            // showFileSize: true,
            onChanged: (items){
              debugPrint("onChanged items.length: ${items.length}");
              selectedModels = items.where((e) => e.url?.startsWith("http") == true).toList();
              urls = selectedModels.map((e) => e.url ?? "").toList();
              setState(() {});
            },
          ),
        ),
      ],
    ),
  );
}

三、源码

dart 复制代码
/// 图片实体模型
class AssetUploadModel {

  AssetUploadModel({
    required this.entity,
    this.url,
    this.file,
  });

  final AssetEntity? entity;
  /// 上传之后的文件 url
  String? url;
  /// 压缩之后的文件
  File? file;
}

1、AssetUploadBox 源码,整个图片区域

php 复制代码
import 'dart:ffi';

import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_templet_project/basicWidget/n_image_preview.dart';
import 'package:flutter_templet_project/basicWidget/upload/asset_upload_button.dart';
import 'package:flutter_templet_project/basicWidget/upload/asset_upload_config.dart';
import 'package:flutter_templet_project/basicWidget/upload/asset_upload_model.dart';
import 'package:flutter_templet_project/extension/overlay_ext.dart';
import 'package:flutter_templet_project/extension/widget_ext.dart';
import 'package:wechat_assets_picker/wechat_assets_picker.dart';
import 'package:wechat_camera_picker/wechat_camera_picker.dart';


/// 上传图片单元(基于 wechat_assets_picker)
class AssetUploadBox extends StatefulWidget {

  AssetUploadBox({
    Key? key,
    required this.items,
    required this.onChanged,
    this.maxCount = 9,
    this.rowCount = 4,
    this.spacing = 3,
    this.runSpacing = 3,
    this.canCameraTakePhoto = false,
    this.canEdit = true,
    this.imgBuilder,
    this.onTap,
    this.showFileSize = false,
  }) : super(key: key);


  List<AssetUploadModel> items;
  /// 全部结束(有成功有失败 url="")或者删除完失败图片时会回调
  ValueChanged<List<AssetUploadModel>> onChanged;
  /// 做大个数
  int maxCount;
  /// 每行个数
  int rowCount;
  /// 水平间距
  double spacing;
  /// 垂直间距
  double runSpacing;
  /// 可以 拍摄图片
  bool canCameraTakePhoto;
  /// 可以编辑
  bool canEdit;
  /// 网络图片url转为组件
  Widget Function(String url)? imgBuilder;
  /// 图片点击事件
  Void Function(List<String> urls, int index)? onTap;
  /// 显示文件大小
  bool showFileSize;

  @override
  _AssetUploadBoxState createState() => _AssetUploadBoxState();
}

class _AssetUploadBoxState extends State<AssetUploadBox> {

  late List<AssetUploadModel> selectedModels = widget.items;
  // List<AssetEntity> selectedEntitys = [];

  @override
  void initState() {
    // TODO: implement initState
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return photoSection(
      items: selectedModels,
      maxCount: widget.maxCount,
      rowCount: widget.rowCount,
      spacing: widget.spacing,
      runSpacing: widget.runSpacing,
      canEdit: widget.canEdit,
    );
  }

  photoSection({
    List<AssetUploadModel> items = const [],
    int maxCount = 9,
    int rowCount = 4,
    double spacing = 10,
    double runSpacing = 10,
    bool canEdit = true,
  }) {
    return LayoutBuilder(
      builder: (BuildContext context, BoxConstraints constraints){
        var itemWidth = ((constraints.maxWidth - spacing * (rowCount - 1))/rowCount).truncateToDouble();
        // print("itemWidth: $itemWidth");
        return Wrap(
          spacing: spacing,
          runSpacing: runSpacing,
          alignment: WrapAlignment.start,
          children: [
            ...items.map((e) {
              // final size = await e.length()/(1024*1024);

              final index = items.indexOf(e);

              return Container(
                child: Column(
                  children: [
                    ClipRRect(
                      borderRadius: BorderRadius.all(Radius.circular(8)),
                      child: SizedBox(
                        width: itemWidth,
                        height: itemWidth,
                        child: InkWell(
                          onTap: (){
                            // debugPrint("onTap: ${e.url}");
                            final urls = items.where((e) => e.url?.startsWith("http") == true)
                              .map((e) => e.url ?? "").toList();
                            final index = urls.indexOf(e.url ?? "");
                            // debugPrint("urls: ${urls.length}, $index");
                            FocusScope.of(context).unfocus();

                            if (widget.onTap != null) {
                              widget.onTap?.call(urls, index);
                              return;
                            }
                            showEntry(
                              child: NImagePreview(
                                urls: urls,
                                index: index,
                                onBack: (){
                                  hideEntry();
                                },
                              ),
                            );
                          },
                          child: AssetUploadButton(
                            model: e,
                            urlBlock: (url){
                              // e.url = url;
                              // debugPrint("e: ${e.data?.name}_${e.url}");
                              final isAllFinished = items.where((e) =>
                              e.url == null).isEmpty;
                              // debugPrint("isAllFinsied: ${isAllFinsied}");
                              if (isAllFinished) {
                                final urls = items.map((e) => e.url).toList();
                                debugPrint("isAllFinsied urls: ${urls}");
                                widget.onChanged(items);
                              }
                            },
                            onDelete: canEdit == false ? null : (){
                              debugPrint("onDelete: $index, lenth: ${items[index].file?.path}");
                              items.remove(e);
                              setState(() {});
                              widget.onChanged(items);
                            },
                            showFileSize: widget.showFileSize,
                          ),
                        ),
                      ),
                    ),
                  ],
                ),
              );
            }).toList(),
            if (items.length < maxCount && canEdit)
              InkWell(
                onTap: () {
                  onPicker(maxCount: maxCount);
                },
                child: Container(
                  margin: EdgeInsets.only(top: 10, right: 10),
                  width: itemWidth - 10,
                  height: itemWidth - 10,
                  decoration: BoxDecoration(
                    color: Colors.black.withOpacity(0.1),
                    // border: Border.all(width: 1),
                    borderRadius: BorderRadius.all(Radius.circular(4)),
                  ),
                  // child: Icon(Icons.camera_alt, color: Colors.black12,),
                  child: Center(
                    child: Image(
                      image: AssetImage("assets/images/icon_camera.png"),
                      width: 24.w,
                      height: 24.w,
                    ),
                  ),
                ),
              )
          ]
        );
      }
    );
  }

  onPicker({
    int maxCount = 4,
    // required Function(int length, String result) cb,
  }) async {
    try {
      final tmpUrls = selectedModels.map((e) => e.url).where((e) => e != null).toList();
      final tmpEntitys = selectedModels.map((e) => e.entity).where((e) => e != null).toList();
      final selectedEntitys = List<AssetEntity>.from(tmpEntitys);

      final result = await AssetPicker.pickAssets(
        context,
        pickerConfig: AssetPickerConfig(
          requestType: RequestType.image,
          specialPickerType: SpecialPickerType.noPreview,
          selectedAssets: selectedEntitys,
          maxAssets: maxCount - tmpUrls.length,
          specialItemPosition: SpecialItemPosition.prepend,
          specialItemBuilder: (context, AssetPathEntity? path, int length,) {
            if (path?.isAll != true) {
              return null;
            }
            if (!widget.canCameraTakePhoto) {
              return null;
            }

            const textDelegate = AssetPickerTextDelegate();
            return Semantics(
              label: textDelegate.sActionUseCameraHint,
              button: true,
              onTapHint: textDelegate.sActionUseCameraHint,
              child: GestureDetector(
                behavior: HitTestBehavior.opaque,
                onTap: () async {
                  Feedback.forTap(context);
                  final takePhoto = await CameraPicker.pickFromCamera(
                    context,
                    pickerConfig: const CameraPickerConfig(enableRecording: true),
                  );
                  if (takePhoto != null) {
                    selectedModels.add(AssetUploadModel(entity: takePhoto));
                    debugPrint("selectedEntitys:${selectedEntitys.length} ${selectedModels.length}");
                    setState(() {});
                  }
                },
                child: const Center(
                  child: Icon(Icons.camera_enhance, size: 42.0),
                ),
              ),
            );
          },
        ),
      ) ?? [];

      // BrunoUtil.showLoading("图片处理中...");
      final same = result.map((e) => e.id).join() == selectedEntitys.map((e) => e.id).join();
      if (result.isEmpty || same) {
        debugPrint("没有添加新图片");
        return;
      }

      for (final e in result) {
        if (!selectedEntitys.contains(e)) {
          selectedModels.add(AssetUploadModel(entity: e));
        }
      }
      debugPrint("selectedEntitys:${selectedEntitys.length} ${selectedModels.length}");
      setState(() {});
    } catch (err) {
      debugPrint("err:$err");
      // BrunoUtil.showToast('$err');
      showToast(message: '$err');
    }
  }

  Future<String?> uploadFile({
    required String filePath,
    ProgressCallback? onSendProgress,
    ProgressCallback? onReceiveProgress,
  }) async {
    final url = AssetUploadConfig.uploadUrl;
    assert(url.startsWith("http"), "请设置上传地址");

    final formData = FormData.fromMap({
      'files': await MultipartFile.fromFile(filePath),
    });
    final response = await Dio().post<Map<String, dynamic>>(
      url,
      data: formData,
      onSendProgress: onSendProgress,
      onReceiveProgress: onReceiveProgress,
    );
    final res = response.data ?? {};
    final result = res['result'];
    return result;
  }

  showToast({required String message}) {
    Text(message).toShowCupertinoDialog(context: context);
  }
}

2、AssetUploadButton 源码,单个图片组件

php 复制代码
class NUploadButton extends StatefulWidget {

  NUploadButton({
    Key? key,
    required this.path,
    this.urlBlock,
    this.onDelete,
    this.radius = 8,
  }) : super(key: key);


  /// 文件本地路径
  final String path;
  /// 上传成功获取 url 回调
  final ValueChanged<String>? urlBlock;
  /// 返回删除元素的 id
  final VoidCallback? onDelete;
  /// 圆角 默认8
  final double radius;

  @override
  _NUploadButtonState createState() => _NUploadButtonState();
}

class _NUploadButtonState extends State<NUploadButton> {
  /// 防止触发多次上传动作
  var _isLoading = false;
  /// 请求成功或失败
  final _successVN = ValueNotifier(true);
  /// 上传进度
  final _percentVN = ValueNotifier(0.0);

  @override
  void initState() {
    // TODO: implement initState
    onRefresh();
    super.initState();
  }

  @override
  void didUpdateWidget(covariant NUploadButton oldWidget) {
    // TODO: implement didUpdateWidget
    super.didUpdateWidget(oldWidget);
    // debugPrint("didUpdateWidget:${widget.path == oldWidget.path}");
    if (widget.path == oldWidget.path) {
      // BrunoUtil.showInfoToast("path相同");
      return;
    }

    onRefresh();
  }

  @override
  Widget build(BuildContext context) {
    return Stack(
      fit: StackFit.expand,
      children: [
        ClipRRect(
          borderRadius: BorderRadius.all(Radius.circular(widget.radius)),
          child: Image.file(
            File(widget.path),
            fit: BoxFit.cover,
          ),
        ),
        Positioned(
          top: 0,
          right: 0,
          bottom: 0,
          left: 0,
          child: buildUploading(),
        ),
      ],
    );
  }

  Widget buildUploading() {
    return AnimatedBuilder(
        animation: Listenable.merge([
          _successVN,
          _percentVN,
        ]),
        builder: (context, child) {
          if (_successVN.value == false) {
            return buildUploadFail();
          }

          final value = _percentVN.value;
          if (value >= 1) {
            return SizedBox();
          }
          return Container(
            color: Colors.black45,
            alignment: Alignment.center,
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                NText(
                  data: value.toStringAsPercent(2),
                  fontSize: 16,
                  fontColor: Colors.white,
                ),
                NText(
                  data: "上传中",
                  fontSize: 14,
                  fontColor: Colors.white,
                ),
              ],
            ),
          );
        }
    );
  }

  Widget buildUploadFail() {
    return Stack(
      children: [
        InkWell(
          onTap: (){
            debugPrint("onTap");
            onRefresh();
          },
          child: Container(
            color: Colors.black45,
            // margin: EdgeInsets.only(top: 12, right: 12),
            alignment: Alignment.center,
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                Icon(Icons.refresh, color: Colors.red),
                NText(
                  data: "点击重试",
                  fontSize: 14,
                  fontColor: Colors.white,
                ),
              ],
            ),
          ),
        ),
        Positioned(
          top: 0,
          right: 0,
          child: IconButton(
            padding: EdgeInsets.zero,
            constraints: BoxConstraints(),
            onPressed: widget.onDelete,
            icon: Icon(Icons.cancel, color: Colors.red,),
          ),
        ),
      ],
    );
  }


  Future<String?> uploadImage({
    required String path,
  }) async {
    // 上传url
    String uploadUrl = '图片存储地址';
    var res = await RequestManager.upload(uploadUrl, path,
        onSendProgress: (int count, int total){
          _percentVN.value = (count/total);
          // debugPrint("${count}/${total}_${_percentVN.value}_${_percentVN.value.toStringAsPercent(2)}");
        }
    );
    if (res['code'] == 'OK') {
      debugPrint("res: $res");
    }
    return res['result'];
  }

  onRefresh() {
    debugPrint("onRefresh");
    if (_isLoading) {
      debugPrint("_isLoading: $_isLoading");
      return;
    }
    _isLoading = true;
    _successVN.value = true;
    uploadImage(
      path: widget.path,
    ).then((value) {
      if (value?.isNotEmpty == false) {
        _successVN.value = false;
        debugPrint("上传失败:${widget.path}");
        return;
      }
      _successVN.value = true;
      widget.urlBlock?.call(value!);
    }).catchError((err){
      debugPrint("err:${err}");
      _successVN.value = false;
    }).whenComplete(() {
      _isLoading = false;
    });
  }
}


class NUploadModel<T> {

  NUploadModel({
    required this.data,
    this.url,
  });

  /// 上传之后的文件 url
  String? url;
  /// 挂载数据,一般是模型
  T data;
}

3、ImageService 源码,图片处理工具类

dart 复制代码
import 'dart:io';

import 'package:flutter/cupertino.dart';
import 'package:flutter_image_compress/flutter_image_compress.dart';
import 'package:flutter_templet_project/cache/cache_asset_service.dart';
import 'package:flutter_templet_project/extension/num_ext.dart';



/// 图片处理工具类
class ImageService{

  /// 图片压缩
   Future<File?> compressAndGetFile(File file, [String? targetPath]) async {
    try {
      var fileName = file.absolute.path.split('/').last;

      // Directory tempDir = await getTemporaryDirectory();
      // Directory assetDir = Directory('${tempDir.path}/asset');
      // if (!assetDir.existsSync()) {
      //   assetDir.createSync();
      //   debugPrint('assetDir 文件保存路径为 ${assetDir.path}');
      // }

      Directory? assetDir = await CacheAssetService().getDir();
      var tmpPath = '${assetDir.path}/$fileName';
      targetPath ??= tmpPath;
      // debugPrint('fileName_${fileName}');
      // debugPrint('assetDir_${assetDir}');
      // debugPrint('targetPath_${targetPath}');

      final compressQuality = file.lengthSync().compressQuality;

      var result = await FlutterImageCompress.compressAndGetFile(
        file.absolute.path, targetPath,
        quality: compressQuality,
        rotate: 0,
      );
      final path = result?.path;
      if (result == null || path == null || path.isEmpty) {
        debugPrint("压缩文件路径获取失败");
        return file;
      }
      final lenth = await result.length();

      final infos = [
        "图片名称: $fileName",
        "压缩前: ${file.lengthSync().fileSize}",
        "压缩质量: $compressQuality",
        "压缩后: ${lenth.fileSize}",
        "原路径: ${file.absolute.path}",
        "压缩路径: $targetPath",
      ];
      debugPrint("图片压缩: ${infos.join("\n")}");

      return File(path);
    } catch (e) {
      debugPrint("compressAndGetFile:${e.toString()}");
    }
    return null;
  }

  /// 图片压缩
  Future<String> compressAndGetFilePath(String imagePath, [String? targetPath,]) async {
    try {
      final file = File(imagePath);
      final fileNew = await compressAndGetFile(file, targetPath);
      final result = fileNew?.path ?? imagePath;
      return result;
    } catch (e) {
      debugPrint("compressAndGetFilePath:${e.toString()}");
    }
    return imagePath;
  }

}

4、CacheAssetService 源码,媒体缓存工具类

dart 复制代码
import 'dart:async';
import 'dart:io';

import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';

///缓存媒体文件
class CacheAssetService {
  CacheAssetService._();

  static final CacheAssetService _instance = CacheAssetService._();

  factory CacheAssetService() => _instance;


  Directory? _dir;

  Future<Directory> getDir() async {
    if (_dir != null) {
      return _dir!;
    }
    Directory tempDir = await getTemporaryDirectory();
    Directory targetDir = Directory('${tempDir.path}/asset');
    if (!targetDir.existsSync()) {
      targetDir.createSync();
      debugPrint('targetDir 路径为 ${targetDir.path}');
    }
    _dir = targetDir;
    return targetDir;
  }

  /// 清除缓存文件
  Future<void> clearDirCache() async {
    final dir = await getDir();
    await deleteDirectory(dir);
  }

  /// 递归方式删除目录
  Future<void> deleteDirectory(FileSystemEntity? file) async {
    if (file == null) {
      return;
    }

    if (file is Directory) {
      final List<FileSystemEntity> children = file.listSync();
      for (final FileSystemEntity child in children) {
        await deleteDirectory(child);
      }
    }
    await file.delete();
  }

}

总结

1、为了效果和微信保持一致;图片选择库用的是 wechat_assets_picker,但是该组件获取的 AssetEntity 模型的 file 是异步方法
dart 复制代码
Future<File?> get file => _getFile();

只能将其下沉到 AssetUploadButton 中进行请求,否则 file 获取时间时间会比较漫长,非常影响用户体验。上传之前的灰色显示时间就是获取 file 时间。

2、AssetEntity 的 file 有时会比原始图片大很多(用iPhone14模拟器测试,一张 11 m的图片,file 尺寸是 37M,原图 11M),问过 wechat_assets_picker 库作者 alex,他说以实际获为准;
3、因为图片过大所以添加图片压缩功能;根据多年经验,根据图片体积,压缩系数实现阶梯化排布;20m 以上的图片基本都可以压缩在 2m 以下,也没有发现图片压缩后模糊的问题(大家可以根据实际情况进行调整);
dart 复制代码
extension IntFileExt on int{
  /// length 转为 MB 描述
  String get fileSize {
    final result = this/(1024 *1024);
    final desc = "${result.toStringAsFixed(2)}MB";
    return desc;
  }

  /// 压缩质量( )
  int get compressQuality {
    int length = this;
    // var quality = 100;
    const mb = 1024 * 1024;
    if (length > 10 * mb) {
      return 20;
    }

    if (length > 8 * mb) {
      return 30;
    }

    if (length > 6 * mb) {
      return 40;
    }

    if (length > 4 * mb) {
      return 50;
    }

    if (length > 2 * mb) {
      return 60;
    }
    return 90;
  }
}
4、图片压缩采用的是 flutter_image_compress 库的
ini 复制代码
final fileNew = await compressAndGetFile(file, targetPath);

文件压缩方法,从原始文件生成一个新的目标文件;除了多了,缓存会变大,所有需要清理缓存,所以就有了 CacheAssetService 工具类,负责临时文件清理功能;

5、wechat_assets_picker 目前版本的UI在 iOS 和 andriod 手机上是不一致的;fork 了一份进行了修改:wechat_assets_picker 双端 UI 一致版
6、本来想封装成 pub 库的,但是涉及的库太多了,担心大家使用时有各种版本冲突的问题,就直接分享源码了;
涉及第三方库 复制代码
// 图片选择
wechat_assets_picker

// 图片压缩
flutter_image_compress

// 负责缓存文件清理
path_provider

// 图片上传
dio

// 网络图片显示
extended_image

// 网络图片预览
photo_view

// 网络图片保存到相册
image_gallery_saver

github

相关推荐
恋猫de小郭10 小时前
Flutter 新春第一弹,Dart 宏功能推进暂停,后续专注定制数据处理支持
android·java·flutter
LuiChun10 小时前
webview_flutter_wkwebview3.17.0 --Cookie认证
flutter
smart_ljh2 天前
国内flutter环境部署(记录篇)
flutter
LuiChun2 天前
Flutter中使用WebView加载html页面时下载js_css文件的流程
flutter
CherishTaoTao2 天前
Flutter子页面向父组件传递数据方法
开发语言·javascript·flutter
黄油奥特曼4 天前
Flutter解决macbook M芯片Android Studio中不显示IOS真机的问题
flutter·ios·android studio·m芯片
LuiChun4 天前
webview_flutter 4.10.0 技术文档
flutter
ssslar4 天前
FLUTTER 开发资料集(持续更新)
flutter
LuiChun4 天前
webview_flutter_wkwebview 3.17.0使用指南
flutter
愿天深海4 天前
Flutter TextPainter 计算文本高度和行数
flutter