Flutter 封装:最佳实践 —— 头像更新 BottomSheetAvatarMixin

一、需求来源

最近遇到群头像更新的需求,花点时间封装成组件,方便复用。默认支持压缩、裁剪(可选),使用极其简单。

效果如下:

二、使用示例

方式一:

kotlin 复制代码
onTap: () async {
  final val = await handleImageFromPhotoAlbum();
  if (val == null) {
    return;
  }
  debugPrint("updateAvatar: $val");
},

方式二:

kotlin 复制代码
onTap: () async {
    updateAvatar(
      cb: (val){
        if (val == null) {
          return;
        }
        debugPrint("updateAvatar: $val");
      }
    );
},

三、源码

1、BottomSheetAvatarMixin 源码,

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

import 'package:flutter/cupertino.dart';
import 'package:flutter_templet_project/basicWidget/n_overlay.dart';
import 'package:flutter_templet_project/basicWidget/upload/image_service.dart';
import 'package:flutter_templet_project/extension/widget_ext.dart';
import 'package:image_picker/image_picker.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:wechat_assets_picker/wechat_assets_picker.dart';


/// 头像更换(回调返回单张图片的路径)
/// 默认图片压缩,图片裁剪
mixin BottomSheetAvatarMixin<T extends StatefulWidget> on State<T> {


    // 相册选择器
    final ImagePicker _picker = ImagePicker();

    // 更新头像
    updateAvatar({
      bool needCropp = true,
      Function(String? path)? cb,
    }) {
      final titles = ['拍摄', '从相册选择'];

      CupertinoActionSheet(
        actions: titles.map((e) {
          return CupertinoActionSheetAction(
            onPressed: () {
              Navigator.of(context).pop();
              if (e == titles[0]) {
                handleImageFromCamera(needCropp: needCropp, cb: cb);
              } else {
                handleImageFromPhotoAlbum(needCropp: needCropp, cb: cb);
              }
            },
            child: Text(e),
          );
        }).toList(),
        cancelButton: CupertinoActionSheetAction(
          isDestructiveAction: true,
          onPressed: () {
            Navigator.pop(context);
          },
          child: const Text('取消'),
        ),
      ).toShowCupertinoModalPopup(context: context);
    }


    /// 拍照获取图像
    Future<String?> handleImageFromCamera({
      bool needCropp = true,
      required Function(String? path)? cb,
    }) async {
      final file = await _takePhoto();
      if (file == null) {
        NOverlay.showToast(context, message: '请重新拍摄',);
        return null;
      }

      NOverlay.showLoading(context, message: "图片处理中...");

      final compressImageFile = await file.toCompressImage();
      if (!needCropp) {
        NOverlay.hide();
        cb?.call(compressImageFile.path);
        return compressImageFile.path;
      }

      final cropImageFile = await compressImageFile.toCropImage();
      NOverlay.hide();

      cb?.call(cropImageFile.path);
      return cropImageFile.path;
    }

    /// 从相册获取图像
    Future<String?> handleImageFromPhotoAlbum({
      bool needCropp = true,
      Function(String? path)? cb,
    }) async {
      final file = await _chooseAvatarByWechatPicker();
      if (file == null) {
        NOverlay.showToast(context, message: '请重新选择',);
        return null;
      }
      NOverlay.showLoading(context, message: "图片处理中...");

      final compressImageFile = await file.toCompressImage();
      if (!needCropp) {
        NOverlay.hide();
        cb?.call(compressImageFile.path);
        return compressImageFile.path;
      }

      final cropImageFile = await compressImageFile.toCropImage();
      NOverlay.hide();

      cb?.call(cropImageFile.path);
      return cropImageFile.path;
    }


  /// 拍照
  Future<File?> _takePhoto() async {
    try {
      final file = await _picker.pickImage(
        source: ImageSource.camera,
        imageQuality: 50,
      );
      if (file == null) {
        return null;
      }
      var fileNew = File(file.path);
      return fileNew;
    } catch (err) {
      openAppSettings();
    }
    return null;
  }

  /// 通过微信相册选择器选择头像
  Future<File?> _chooseAvatarByWechatPicker() async {
    var maxCount = 1;

    final entitys = await AssetPicker.pickAssets(
      context,
      pickerConfig: AssetPickerConfig(
        requestType: RequestType.image,
        specialPickerType: SpecialPickerType.noPreview,
        selectedAssets: [],
        maxAssets: maxCount,
      ),
    ) ?? [];

    if (entitys.isEmpty) {
      return null;
    }
    final item = entitys[0];
    final file = await item.file;
    if (file == null) {
      return null;
    }
    return file;
  }

}

2、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;
  }

}


/// 图片文件扩展方法
extension ImageFileExt on File {

  /// 图片压缩
  Future<File> toCompressImage() async {
    var compressFile = await ImageService().compressAndGetFile(this);
    compressFile ??= this;// 压缩失败则使用原图
    return compressFile;
  }

  /// 图像裁剪
  Future<File> toCropImage({
    int? maxWidth,
    int? maxHeight,
  }) async {

    try {
      final sourcePath = path;

      var croppedFile = await ImageCropper().cropImage(
        sourcePath: sourcePath,
        maxWidth: maxWidth,
        maxHeight: maxHeight,
        aspectRatioPresets: [
          CropAspectRatioPreset.square,
        ],
        uiSettings: [
          AndroidUiSettings(
            toolbarTitle: '',
            toolbarColor: Colors.blue,
            toolbarWidgetColor: Colors.white,
            initAspectRatio: CropAspectRatioPreset.original,
            lockAspectRatio: false,
          ),
          IOSUiSettings(
            title: 'Cropper',
          ),
        ],
      );
      if (croppedFile == null) {
        return this;
      }
      return File(croppedFile.path);
    } catch (e) {
      debugPrint("toCropImage: $e");
      return this;
    }
  }
}

总结

1、最终只涉及方法的调用(不涉及状态)所以用 mixin 进行了代码拆分,方便多项目复用。
2、压缩和裁剪比较耗时,需要在执行前加上指示器,不然会严重影响用户体验;
3、NOverlay 是个人弹窗封装类,大家可以替换成自己项目的即可;
4、执行结果返回图片文件路径,大家调用自己封装网络模块上传即可;
涉及第三方库 复制代码
// 图片选择
wechat_assets_picker

// 图片压缩
flutter_image_compress

// 负责缓存文件清理
path_provider

github

相关推荐
lqj_本人6 小时前
鸿蒙next选择 Flutter 开发跨平台应用的原因
flutter·华为·harmonyos
lqj_本人9 小时前
Flutter&鸿蒙next 状态管理框架对比分析
flutter·华为·harmonyos
起司锅仔13 小时前
Flutter启动流程(2)
flutter
hello world smile17 小时前
最全的Flutter中pubspec.yaml及其yaml 语法的使用说明
android·前端·javascript·flutter·dart·yaml·pubspec.yaml
lqj_本人17 小时前
Flutter 的 Widget 概述与常用 Widgets 与鸿蒙 Next 的对比
flutter·harmonyos
iFlyCai17 小时前
极简实现酷炫动效:Flutter隐式动画指南第二篇之一些酷炫的隐式动画效果
flutter
lqj_本人17 小时前
Flutter&鸿蒙next 中使用 MobX 进行状态管理
flutter·华为·harmonyos
lqj_本人18 小时前
Flutter&鸿蒙next 中的 setState 使用场景与最佳实践
flutter·华为·harmonyos
hello world smile19 小时前
Flutter常用命令整理
android·flutter·移动开发·android studio·安卓
lqj_本人21 小时前
Flutter&鸿蒙next 中的 Expanded 和 Flexible 使用技巧详解
flutter·harmonyos