基于 NUploadBox 的重构组件
一、需求来源
最近遇到上传图片的需求,周末花时间封装成组件,方便复用。支持多选,显示上传百分比,支持失败重连,支持失败删除;每张图片上传成功之后都会进行 url 回调。
新增功能:
- 相册中限制图片最大张数(基于 wechat_assets_picker );
- 增量上传(最大8张,默认2张,最多可以选择 6张);
- 加入了图片压缩功能(默认 2M 以下不显示进度,因为进度是跳闪);
- 新增图片显示构建器,支持外部自定义显示网络图片组件;
- 新增图片点击方法,支持外部自定义跳转图片预览组件;
效果如下:
选择图片
失败重连
二、使用示例
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