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

相关推荐
微祎_3 小时前
Flutter for OpenHarmony:单词迷宫一款基于 Flutter 构建的手势驱动字母拼词游戏,通过滑动手指连接字母路径来组成单词。
flutter·游戏
ujainu4 小时前
护眼又美观:Flutter + OpenHarmony 鸿蒙记事本一键切换夜间模式(四)
android·flutter·harmonyos
ujainu4 小时前
让笔记触手可及:为 Flutter + OpenHarmony 鸿蒙记事本添加实时搜索(二)
笔记·flutter·openharmony
一只大侠的侠4 小时前
Flutter开源鸿蒙跨平台训练营 Day 13从零开发注册页面
flutter·华为·harmonyos
一只大侠的侠4 小时前
Flutter开源鸿蒙跨平台训练营 Day19自定义 useFormik 实现高性能表单处理
flutter·开源·harmonyos
恋猫de小郭5 小时前
Flutter Zero 是什么?它的出现有什么意义?为什么你需要了解下?
android·前端·flutter
一只大侠的侠10 小时前
Flutter开源鸿蒙跨平台训练营 Day 10特惠推荐数据的获取与渲染
flutter·开源·harmonyos
renke336413 小时前
Flutter for OpenHarmony:色彩捕手——基于HSL色轮与感知色差的交互式色觉训练系统
flutter
子春一15 小时前
Flutter for OpenHarmony:构建一个 Flutter 四色猜谜游戏,深入解析密码逻辑、反馈算法与经典益智游戏重构
算法·flutter·游戏
铅笔侠_小龙虾16 小时前
Flutter 实战: 计算器
开发语言·javascript·flutter