前言:为什么需要图片编辑?
在如今的应用开发中,图片处理几乎是社交、电商、工具类 App 的标配功能。用户上传头像需要裁剪,分享美图需要编辑,添加水印需要文字功能,为 App 添加一个头像上传功能等等...这些听起来像是一个再普通不过的需求。产品经理的要求也很简单:"用户选完图片,能把它裁成 1:1 的正方形就行。"
于是,我像往常一样打开 pub.dev,很快就找到 image_cropper。看起来完美,对吧?
然而,现实很快就给了我一记重拳。
当我把这个功能集成进我跨平台的 Flutter 应用后,一连串的问题浮出水面:
- UI/UX 的巨大鸿沟 :
image_cropper的核心是通过MethodChannel调用原生插件。这直接导致了 Android 和 iOS 两端的裁剪界面(UI)和交互体验(UX)存在巨大差异。对于我这样追求品牌一致性和统一用户体验的团队来说,这几乎是不可接受的。 - 桌面端的缺失 :我的应用需要支持 Windows 和 Mac,而
image_cropper在桌面端的支持并不理想,这直接堵死了我的全平台之路。 - 扩展性的枷锁:产品经理紧接着说:"我后续的内容模块需要16:9的封面图,商品模块可能需要自由裁剪......" 我意识到,一个基于原生UI封装的库,很难满足我未来灵活多变的裁剪比例需求。
- 性能的隐忧 :后端同事也发来提醒:"上传的图片不能太大,最好能在客户端压缩一下,不然服务器和带宽顶不住!" 这意味着我不仅要裁剪,还需要在不严重损失画质的前提下,对图片进行缩放(Scale)处理,以达到压缩体积的效果 。而这一点,
image_cropper提供的控制力非常有限。
面对这些"卡脖子"的问题,我意识到,我需要的不只是一个"能用"的裁剪工具。我需要的是一个纯 Dart 实现、跨平台 UI 完全一致、功能高度可控、并且能精细化处理图片尺寸的解决方案。
市面上没有现成的轮子,那就自己造一个!
于是,我肝了半个月,从零开始,用 CustomPaint 和矩阵变换硬核打造了这款全新的 Flutter 图片编辑器。它不仅解决了上述所有痛点,还带来了更多惊喜:
- 像素级无损裁剪:支持任意比例和自由裁剪,保证画质。
- 内置缩放压缩 :在裁剪的同时,通过精密的
scale计算,有效控制输出图片的尺寸和体积。 - 跨平台体验完全一致:纯 Dart 绘制,告别平台差异。
- 不止是裁剪:它还集成了 360° 自由旋转、功能完善的文本添加与编辑,以及健全的撤销/重做历史管理机制。
先来看看最终效果

- 图1:图片编辑主界面:回滚、裁剪、渲染、贴图、撤销

- 图2:图片裁剪主界面:自由比例、特定比例

- 图3:图片旋转主界面:自由角度、特定角度

- 图4:图片贴图主界面

- 原图和编辑后的对比:压缩系数(可配置)
编辑器的架构设计
一个复杂的系统,好的架构是成功的一半。我不想把所有逻辑都堆在一个臃肿的 StatefulWidget 里,那样会变成难以维护的"上帝类"。因此,我从一开始就确立了**"关注点分离"**的核心设计原则。
整个编辑器被拆分为以下几个层次:
-
视图层 (View) :由
ImageEditorView和CustomPainter组成,纯粹负责UI的渲染和用户手势的捕获。它本身不包含任何业务逻辑。 -
控制器 (Controller) :
ImageEditorController是整个编辑器的"大脑"。它继承自ChangeNotifier,负责管理所有状态(图片、旋转角度、裁剪框、文本层等),处理业务逻辑,并通知视图层更新。 -
模型层 (Model) -:
TextLayerData,ImageEditorConfig等,就是最纯粹的数据结构。 -
处理器/管理器 (Handlers/Managers) :这是本次设计的精髓。我将不同功能的复杂逻辑抽离成独立的类,让 Controller 保持清爽,只做"指挥官"。
CropHandler: 负责所有与裁剪相关的计算,如初始化裁剪框、限制边界、执行高保真裁剪。RotationHandler: 负责渲染旋转后的图片。TextLayerManager: 专门管理文本图层的增删改查和状态。HistoryManager: 负责管理操作快照,实现撤销和重做。
这种分层架构带来了巨大的好处:
- 高内聚,低耦合:每个模块只关心自己的职责,修改裁剪逻辑不会影响文本功能。
- 易于测试 :可以单独对
CropHandler或HistoryManager进行单元测试。 - 可扩展性强 :未来想增加"滤镜"功能?只需要新增一个
FilterHandler,然后在 Controller 中集成即可,对现有代码的侵入极小。
万物皆可绘:CustomPaint 的魔力
编辑器的核心显示区域,本质上是一个画布(Canvas),我需要在上面自由地绘制图片、裁剪框、文本等元素。在 Flutter 中,CustomPaint 和 CustomPainter 是实现这一需求的最佳选择。
我的 ImageEditorPainter 的 paint 方法,就像一位画家在按步骤作画:
dart
// ImageEditorPainter.dart
@override
void paint(Canvas canvas, Size size) {
// ... 获取 controller 中的各种状态 ...
// --- 1. 绘制变换后的图片 ---
// 这是核心绘制逻辑,保证图片居中显示并应用旋转和缩放
final canvasCenterX = size.width / 2;
final canvasCenterY = size.height / 2;
canvas.save(); // 保存当前画布状态
canvas.translate(canvasCenterX, canvasCenterY); // 将画布原点移到中心
canvas.rotate(rotationAngle); // 旋转
canvas.scale(scale, scale); // 缩放
// 以图片中心为原点绘制
canvas.drawImage(image, Offset(-image.width / 2, -image.height / 2), paint);
canvas.restore(); // 恢复画布状态,后续绘制不受影响
// --- 2. 如果裁剪激活,则绘制裁剪UI ---
if (isCropping && cropRect != null) {
_drawCropUI(canvas, size, cropRect, handleSize);
}
// --- 3. 绘制所有文本图层 ---
_drawTextLayers(canvas, size);
}
这里有几个关键点:
canvas.save()和canvas.restore():这对组合是Canvas操作的黄金法则。它能确保我的变换(平移、旋转、缩放)只对绘制图片生效,而不会影响后续裁剪框和文本的绘制。- 中心点变换:所有的变换都围绕画布中心进行,这样可以保证用户在缩放和旋转时,视觉焦点始终在图片中心,体验更自然。
- 绘制裁剪蒙层 :一个有趣的小技巧是,如何绘制裁剪框外部的半透明蒙层?我可以利用
Path的fillType = PathFillType.evenOdd属性。
dart
// ImageEditorPainter.dart -> _drawCropUI
void _drawCropUI(...) {
// ...
// 创建一个包含两个矩形的路径:一个是整个画布,一个是裁剪框
Path overlayPath = Path()
..addRect(Rect.fromLTWH(0, 0, size.width, size.height))
..addRect(currentCropRect)
..fillType = PathFillType.evenOdd; // 设置为 evenOdd 填充规则
// 绘制这个路径,Canvas 会自动填充两个矩形之间的区域
canvas.drawPath(overlayPath, overlayPaint);
// ...
}
通过 CustomPaint,获得了对UI像素级的完全掌控力,为实现复杂的交互效果打下了基础。
坐标系与高保真裁剪的实现
这是整个编辑器技术含量最高,也是最难啃的一块硬骨头。
问题是什么?
用户在屏幕上看到的是一张经过缩放 和旋转 的图片。当他拖动裁剪框时,这个裁剪框的坐标是相对于屏幕 的(我称之为"屏幕坐标系")。但我的最终目标,是从那张未经任何变换的、原始高分辨率的图片("图片坐标系")中,精确地切出对应的部分。
如果只是简单地对屏幕内容进行截图,图片质量会严重下降,这是不可接受的。我必须实现高保真裁剪。
解决方案:矩阵变换的魔法!
线性代数中的矩阵,是连接不同坐标系的桥梁。我的思路是:
- 构建一个从"图片坐标系"到"屏幕坐标系"的变换矩阵
M。 - 求出
M的逆矩阵M⁻¹,它就是从"屏幕坐标系"返回"图片坐标系"的钥匙。 - 将屏幕上裁剪框的四个顶点,通过
M⁻¹变换,得到它们在原始图片上的精确坐标。 - 根据这四个点,计算出一个能完全包裹它们的最小矩形(
sourceRect)。 - 使用
canvas.drawImageRect,从原始图片中挖出sourceRect区域,绘制到一个新的、尺寸完美匹配的画布上,从而生成一张全新的、高保真裁剪后的图片。
核心代码在 CropHandler.captureHiResCroppedImage 中:
dart
// CropHandler.dart
static Future<ui.Image?> captureHiResCroppedImage(...) async {
// 1. 计算从"图片坐标系"到"屏幕坐标系"的变换矩阵
final Matrix4 matrixToScreen = CoordinateTransformer.createImageToScreenMatrix(...);
// 2. 求逆矩阵
final Matrix4 screenToImageMatrix = Matrix4.inverted(matrixToScreen);
// 3. 将屏幕上的裁剪框的四个角,通过逆矩阵变换回图片上的坐标
final topLeft = MatrixUtils.transformPoint(screenToImageMatrix, cropRect.topLeft);
// ... (transform other 3 corners)
// 4. 计算能完全包围这四个点的、在图片坐标系中的矩形边界 (sourceRect)
final double minX = [topLeft.dx, ...].reduce(math.min);
// ... (calculate maxX, minY, maxY)
final Rect sourceRect = Rect.fromLTRB(minX, minY, maxX, maxY);
// 5. 计算新图片的尺寸(高保真尺寸)
final int newWidth = sourceRect.width.round();
final int newHeight = sourceRect.height.round();
// 6. 使用 PictureRecorder 和 drawImageRect 进行高保真绘制
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder, Rect.fromLTWH(0, 0, newWidth.toDouble(), newHeight.toDouble()));
final Rect destinationRect = Rect.fromLTWH(0, 0, newWidth.toDouble(), newHeight.toDouble());
// 核心:从原图的 sourceRect 区域,绘制到新画布的 destinationRect 区域
canvas.drawImageRect(image, sourceRect, destinationRect, Paint());
// 7. 生成最终的高清图片
final picture = recorder.endRecording();
return await picture.toImage(newWidth, newHeight);
}
这个过程完美地解决了因旋转和缩放带来的坐标系差异问题,保证了每一次裁剪都是像素级的无损操作。同样,在最终导出图片时,文本的绘制也遵循了类似的坐标变换逻辑,确保文字能被高清地"印"在最终的图片上。
文本与历史记录管理
除了硬核的裁剪,一些"软"功能的设计同样重要,它们直接决定了用户体验。
1. 文本管理 (TextLayerManager)
添加文本不仅仅是在画布上画几个字那么简单,我需要管理多个文本层,支持对它们进行独立的移动、样式修改和删除。
TextLayerManager 内部使用一个 Map<String, TextLayerData> 来存储所有文本层,以 id 作为键,实现了 O(1) 复杂度的快速查找。
它负责处理:
- 命中测试 :当用户点击屏幕时,
selectLayerAt方法会遍历所有文本层,计算它们的边界框(_getTextLayerBounds),判断点击位置是否落在某个文本层内,从而实现选中。 - 状态管理 :管理当前选中的文本层 ID (
_selectedLayerId),并提供更新颜色、大小、位置的接口。 - 数据隔离 :所有文本相关的状态都内聚在
TextLayerManager中,ImageEditorController只需调用其API即可,无需关心内部实现。
2. 时间旅行 (HistoryManager)
一个专业的编辑器,怎能没有撤销/重做功能?我通过备忘录模式实现了这个功能。
EditorStateSnapshot:这是一个数据类,像一个"快照",保存了某一时刻编辑器的所有关键状态(ui.Image对象、文本层列表、旋转角度等)。HistoryManager:它内部维护一个List<EditorStateSnapshot>列表,作为历史堆栈。
工作流程是这样的:
- 保存快照 :在执行一个"破坏性"操作(如应用裁剪、应用旋转)之前 ,调用
_historyManager.saveSnapshot(),将当前状态推入堆栈。 - 撤销操作 :当用户点击"撤销"时,调用
_historyManager.popSnapshot(),取出最近的一个快照,然后用快照中的数据恢复ImageEditorController的状态。
这里需要注意一个细节:在保存快照时,对于 List<TextLayerData> 这样的可变对象,必须进行深拷贝 ,否则后续对文本的修改会污染历史记录。而 ui.Image 是不可变对象,可以直接引用,这为我省了不少事。
dart
// HistoryManager.dart
void saveSnapshot(...) {
// 深拷贝文本图层列表
final copiedTextLayers = textLayers.map((layer) => layer.clone()).toList();
final snapshot = EditorStateSnapshot(
image: image, // ui.Image 是不可变的,可以直接引用
textLayers: copiedTextLayers,
// ...
);
_snapshots.add(snapshot);
// ... (限制历史记录数量)
}
通过 HistoryManager,我赋予了用户"反悔"的权利,大大提升了编辑器的容错性和用户体验。
可配置的导出压缩,为服务器减负
在前面我提到了一个非常现实的痛点:用户上传的原始图片动辄几兆甚至十几兆,如果直接上传,会给服务器的带宽和存储 带来巨大压力,同时也会消耗用户大量的移动数据。因此,在客户端进行高质量的预压缩,是企业级应用不可或缺的一环。
我的目标很明确:
- 压缩过程对用户无感:用户只需要正常编辑,在最后导出时自动完成。
- 压缩比率可配置:允许开发者根据不同的业务场景(如头像、封面、普通插图)灵活设置压缩强度。
- 保证画质:不能因为压缩就让图片变得模糊不堪,要在体积和质量之间找到最佳平衡。
基于这些目标,我设计了一套优雅的、基于 scale 的压缩方案。
1. 设计思路:分离与配置
我没有将压缩逻辑耦合在裁剪或旋转等任何一个编辑步骤中。编辑过程始终应该在最高保真度的图像上进行,以保证操作的精确性。压缩,应该是导出前(Export)的最后一道工序。
为此,我引入了 ImageCompressionConfig 配置类,并将其作为 ImageEditorConfig 的一部分:
dart
// models/editor_models.dart
class ImageEditorConfig {
// ... 其他配置
final ImageCompressionConfig? compression;
// ...
}
class ImageCompressionConfig {
final bool enabled; // 是否启用压缩
final double scale; // 压缩比例,例如 0.5 表示尺寸变为原来的一半
const ImageCompressionConfig({
this.enabled = true,
this.scale = 0.5,
});
}
这样的设计让压缩功能变成了一个可插拔、可配置的模块。开发者在初始化编辑器时,可以轻松地决定是否启用压缩以及压缩到什么程度。
2. 实现揭秘:exportImage 的处理流水线
当用户点击"完成"并调用 exportImage() 方法时,一条精密的流水线就开始工作了:
exportImage() -> _captureTransformedImage() -> _applyCompressionIfNeeded() -> 返回最终图片
_captureTransformedImage(): 首先,无论是否压缩,我们都需要先将用户的所有编辑操作(旋转、裁剪、添加文本等)应用,生成一张"所见即所得"的高保真中间图。_applyCompressionIfNeeded(): 接着,这张高保真图被传递给压缩处理器。这个方法是实现压缩的核心。
让我们深入 _applyCompressionIfNeeded 的源码,看看魔法是如何发生的:
dart
// ImageEditorController.dart
Future<ui.Image> _applyCompressionIfNeeded(ui.Image image) async {
// 1. 从配置中读取压缩设置
final ImageCompressionConfig? compressionConfig = config.compression;
if (compressionConfig == null || !compressionConfig.enabled) {
return image; // 如果未配置或未启用,直接返回原图
}
final double scale = compressionConfig.scale;
if (scale <= 0 || scale >= 1.0) {
return image; // 无效的 scale 值,不处理
}
// 2. 根据 scale 计算目标尺寸
final int targetWidth = math.max(1, (image.width * scale).round());
final int targetHeight = math.max(1, (image.height * scale).round());
// 3. 使用 PictureRecorder 和 drawImageRect 进行高质量缩放
final ui.PictureRecorder recorder = ui.PictureRecorder();
final Canvas canvas = Canvas(recorder);
final Rect srcRect = Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble());
final Rect dstRect = Rect.fromLTWH(0, 0, targetWidth.toDouble(), targetHeight.toDouble());
// 4. [关键] 设置高质量的绘制滤镜
final Paint paint = Paint()..filterQuality = FilterQuality.high;
canvas.drawImageRect(image, srcRect, dstRect, paint);
// 5. 生成新的、尺寸更小的图片
final ui.Picture picture = recorder.endRecording();
final ui.Image resized = await picture.toImage(targetWidth, targetHeight);
// 6. [关键] 释放旧的大图内存
_disposeImage(image);
return resized;
}
这段代码虽然不长,但处处体现着精心的设计:
- 计算目标尺寸 (步骤2) :通过将原始宽高乘以
scale因子,我们得到了压缩后的目标尺寸。这是整个方案的数学基础。 drawImageRect(步骤3) :这个 FlutterCanvas的 API 是实现缩放的关键。它允许我们将一个源矩形(srcRect,即整个原始大图)绘制到一个目标矩形(dstRect,即我们计算出的小尺寸矩形)中。在绘制过程中,Flutter 引擎会自动为我们完成像素的插值和缩放。- 画质的秘密武器 (步骤4) :仅仅缩放是不够的,如何保证画质?答案是
Paint对象的filterQuality属性。将其设置为FilterQuality.high,会告诉 Flutter 引擎使用更高级、更平滑的算法(通常是双线性或双三次插值)来处理像素,从而在缩小图片时最大程度地保留细节,避免出现锯齿和马赛克。 - 内存管理的典范 (步骤6) :在生成了新的、小尺寸的
resized图片后,那张作为输入的、高保真的image就完成了它的历史使命。通过调用_disposeImage(image)主动释放其占用的内存,可以有效避免应用因处理大图而导致的内存峰值,这对于移动设备尤其重要。
3. 优势总结
通过这种基于 scale 和 drawImageRect 的方案,我们实现了一个非常理想的客户端压缩功能:
- 逻辑解耦:压缩与编辑功能完全分离,代码清晰,易于维护。
- 高度可控 :通过
ImageEditorConfig,开发者可以像开关一样控制压缩,并精确调整压缩比。 - 质量优先 :利用
FilterQuality.high保证了即使在较大比例压缩下,也能获得令人满意的视觉效果。 - 性能友好:即时释放无用的大图资源,体现了优秀的内存管理意识。
最终,这个"点睛之笔"不仅提升了用户体验(更快的上传速度),也实实在在地为后端的服务器减轻了负担,实现了双赢。
快速开始
方式
flutter pub add flutter_img_editor
或者
yaml
dependencies:
flutter_img_editor: ^0.0.3 # 使用最新版本
dart
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter_img_editor/image_editor.dart';
import 'package:image_picker/image_picker.dart';
import 'more_page.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Image Editor 示例',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const MyHomePage(),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key});
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
final ImagePicker _picker = ImagePicker();
// 相册图片
ui.Image? _pickerOriginal;
ui.Image? _pickerEdited;
String? _pickerTempPath;
Duration? _pickerTempDuration;
// 是否正在选择相册图片
bool _isPickingImage = false;
void _showSnack(String message) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Image Editor 示例'),
actions: [
TextButton(
onPressed: _openMorePage,
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.onPrimary,
),
child: const Text('更多示例'),
),
],
),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.all(16),
children: [
_buildIntroCard(),
_buildPickerDemo(),
_buildPerformanceNote(),
],
),
),
);
}
void _openMorePage() {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const MorePage(),
),
);
}
Card _buildIntroCard() {
return const Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'本页聚焦 image_picker 相册流程,演示如何快速打开图片编辑器并保存编辑结果。',
),
SizedBox(height: 8),
Text(
'想了解内置 Asset、相机拍照与网络下载场景,请点击右上角"更多示例"。',
style: TextStyle(color: Colors.black54),
),
],
),
),
);
}
Card _buildPickerDemo() {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(
title: '案例:Image Picker 相册',
actions: [
ElevatedButton.icon(
onPressed: _isPickingImage ? null : _handlePickerEdit,
icon: const Icon(Icons.photo_library),
label: Text(_isPickingImage ? '处理中...' : '选择并编辑'),
),
],
),
const SizedBox(height: 8),
const Text(
'直接通过 image_picker 选择本地相册图片后进入编辑器,'
'展示如何与外部端到端整合。',
),
if (_isPickingImage)
const Padding(
padding: EdgeInsets.only(top: 12),
child: LinearProgressIndicator(minHeight: 3),
),
const SizedBox(height: 16),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildImagePanel(label: '原始', image: _pickerOriginal),
const SizedBox(width: 16),
_buildImagePanel(label: 'ui.Image', image: _pickerEdited),
const SizedBox(width: 16),
_buildImagePanelFromPath(label: 'path', path: _pickerTempPath),
],
),
const SizedBox(height: 12),
_buildPixelStats(_pickerOriginal, _pickerEdited),
if (_pickerTempPath != null) ...[
const SizedBox(height: 16),
Text(
'临时文件路径 (saveImageToTempFile):',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 4),
SelectableText(_pickerTempPath!),
Text(
'耗时: ${_pickerTempDuration != null ? '${_pickerTempDuration!.inMilliseconds} ms' : '--'}',
),
],
],
),
),
);
}
Widget _buildHeader({
required String title,
required List<Widget> actions,
}) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
Wrap(
spacing: 8,
runSpacing: 8,
children: actions,
),
],
);
}
Widget _buildImagePanel({
required String label,
required ui.Image? image,
}) {
final TextStyle? captionStyle =
Theme.of(context).textTheme.labelMedium?.copyWith(
color: Colors.grey.shade600,
);
return Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: Theme.of(context).textTheme.bodySmall),
const SizedBox(height: 6),
Container(
height: 180,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: Colors.grey.shade100,
border: Border.all(color: Colors.grey.shade300),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: image != null
? RawImage(
image: image,
fit: BoxFit.contain,
)
: Center(
child: Text(
'暂无图片',
style: captionStyle,
),
),
),
),
const SizedBox(height: 6),
Text('像素: ${_pixelSize(image)}', style: captionStyle),
],
),
);
}
Widget _buildImagePanelFromPath({
required String label,
required String? path,
}) {
final captionStyle = Theme.of(context).textTheme.labelMedium?.copyWith(
color: Colors.grey.shade600,
);
Widget child;
if (path != null) {
child = Image.file(
File(path),
fit: BoxFit.contain,
);
} else {
child = Center(
child: Text(
'暂无图片',
style: captionStyle,
),
);
}
return Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: Theme.of(context).textTheme.bodySmall),
const SizedBox(height: 6),
Container(
height: 180,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: Colors.grey.shade100,
border: Border.all(color: Colors.grey.shade300),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: child,
),
),
const SizedBox(height: 6),
if (path != null) Text('文件大小: ${_fileSize(path)}', style: captionStyle),
],
),
);
}
Widget _buildPixelStats(ui.Image? original, ui.Image? edited) {
final String delta = _pixelDelta(original, edited);
final String originalMemory = _memoryUsage(original);
final String editedMemory = _memoryUsage(edited);
final String memoryDelta = _memoryDelta(original, edited);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'像素对比',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 4),
Text('原始: ${_pixelSize(original)}'),
Text('编辑后: ${_pixelSize(edited)}'),
if (delta.isNotEmpty) Text(delta),
const SizedBox(height: 8),
Text(
'内存估算(RGBA 32bit)',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 4),
Text('原始: $originalMemory'),
Text('编辑后: $editedMemory'),
if (memoryDelta.isNotEmpty) Text(memoryDelta),
],
);
}
String _pixelSize(ui.Image? image) {
if (image == null) return '--';
return '${image.width} x ${image.height}';
}
String _pixelDelta(ui.Image? original, ui.Image? edited) {
if (original == null || edited == null) {
return '';
}
final int dw = edited.width - original.width;
final int dh = edited.height - original.height;
String fmt(int value) => value > 0 ? '+$value' : value.toString();
if (dw == 0 && dh == 0) {
return '变化: 无 (像素保持不变)';
}
return '变化: 宽度 ${fmt(dw)},高度 ${fmt(dh)}';
}
String _memoryUsage(ui.Image? image) {
if (image == null) return '--';
final double bytes = image.width * image.height * 4;
final double mb = bytes / (1024 * 1024);
return '${mb.toStringAsFixed(2)} MB';
}
String _memoryDelta(ui.Image? original, ui.Image? edited) {
if (original == null || edited == null) {
return '';
}
final double originalPixels = original.width * original.height.toDouble();
final double editedPixels = edited.width * edited.height.toDouble();
final double diffBytes = (editedPixels - originalPixels) * 4;
if (diffBytes == 0) {
return '内存变化: 无 (像素点数量一致)';
}
final double diffMb = diffBytes / (1024 * 1024);
final String sign = diffMb > 0 ? '+' : '';
return '内存变化: $sign${diffMb.toStringAsFixed(2)} MB';
}
String _fileSize(String path) {
try {
final int bytes = File(path).lengthSync();
if (bytes < 1024) {
return '$bytes B';
}
final double kb = bytes / 1024;
if (kb < 1024) {
return '${kb.toStringAsFixed(1)} KB';
}
final double mb = kb / 1024;
return '${mb.toStringAsFixed(2)} MB';
} catch (error) {
return '--';
}
}
Future<void> _handlePickerEdit() async {
if (_isPickingImage) return;
setState(() {
_isPickingImage = true;
});
try {
final XFile? picked = await _picker.pickImage(source: ImageSource.gallery);
if (picked == null) {
return;
}
final ui.Image image = await loadImageFromFile(picked.path);
if (!mounted) return;
setState(() {
_pickerOriginal = image;
_pickerEdited = null;
});
final ui.Image? result = await _openEditor(
image,
config: const ImageEditorConfig(
topToolbar: TopToolbarConfig(
titleText: '相册图片编辑',
cancelText: '取消',
confirmText: '完成',
),
compression: ImageCompressionConfig(
enabled: true,
scale: 0.3,
),
),
);
if (!mounted || result == null) return;
setState(() {
_pickerEdited = result;
});
await _processPickerResult(result);
await _logImageBytes(result, 'picker');
} catch (error) {
_showSnack('选择图片失败: $error');
} finally {
if (!mounted) return;
setState(() {
_isPickingImage = false;
});
}
}
Future<ui.Image?> _openEditor(
ui.Image image, {
ImageEditorConfig? config,
}) {
return Navigator.push<ui.Image?>(
context,
MaterialPageRoute(
builder: (context) => ImageEditor(
image: image,
config: config ?? const ImageEditorConfig(),
),
),
);
}
Future<void> _logImageBytes(ui.Image image, String tag) async {
final bytes = await convertUiImageToBytes(image);
if (bytes == null) return;
// 此处仅演示如何获取可直接用于上传的字节数据,可替换为实际网络请求。
debugPrint('[image_editor_demo][$tag] export bytes=${bytes.lengthInBytes}');
}
/// 处理相册图片编辑结果为临时文件
Future<void> _processPickerResult(ui.Image result) async {
final Stopwatch stopwatch = Stopwatch()..start();
final String? tempPath = await saveImageToTempFile(result);
stopwatch.stop();
setState(() {
_pickerTempPath = tempPath;
_pickerTempDuration = stopwatch.elapsed;
});
if (tempPath == null) {
_showSnack('图片保存失败');
} else {
debugPrint('[image_editor_demo][picker] tempPath=$tempPath in ${stopwatch.elapsedMilliseconds}ms');
}
}
Card _buildPerformanceNote() {
return Card(
color: Colors.blueGrey.shade50,
child: const Padding(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'性能提示',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
SizedBox(height: 12),
Text(
'· `ui.Image` 在 Flutter 中以原始 RGBA 32 位像素存储,内存占用约等于像素总数 × 4 字节,读取速度快但占用高。\n'
'· 若目标是压缩图片体积,建议在裁剪后进一步按需缩放,并使用 JPG/PNG/WebP 编码保存或上传。\n'
'· 对于批量压缩,可结合 `ui.PictureRecorder` 或第三方图像处理库(例如 `image` 包)在后台处理,降低内存峰值。\n'
'· 可通过 Flutter DevTools 的 Memory 面板,或在控制台打印 `(image.width * image.height * 4 / 1024 / 1024)` 观察实时内存占用,帮助评估方案。',
),
],
),
),
);
}
}
欢迎大家 Star 和 Fork。如果你对 Flutter 图形图像处理有兴趣,或者在寻找一个可以轻松集成到自己项目中的图片编辑器,希望我的这个轮子能帮到你。 感谢你的阅读!如果觉得这篇文章对你有帮助,不妨点个赞吧!
总结和展望
从一个想法,到一个功能完备的图片编辑器,这个过程充满了挑战,也充满了乐趣。回顾整个项目,我认为有几点关键的收获:
- 架构先行 :良好的分层和模块化设计是应对复杂度的不二法门。将职责拆分到独立的
Handler和Manager中,让代码逻辑清晰,易于维护和扩展。 - 拥抱底层API :
CustomPaint和Matrix4虽然概念上有些抽象,但它们是实现高级自定义UI和复杂图形操作的利器。深入理解它们,能让你在 Flutter 的世界里拥有更大的创造自由。 - 关注核心问题:高保真处理是图片编辑器的灵魂。始终围绕"如何操作原始数据"来思考,而不是"如何修改屏幕显示",是保证最终输出质量的关键。
当然,这个编辑器还有很多可以完善的地方,比如:
- 性能优化:对于超大尺寸的图片,可以引入瓦片化渲染技术。
- 功能扩展:增加画笔、马赛克、滤镜等更丰富的编辑工具。
- 手势体验:支持双指旋转和缩放文本层等更精细的交互。