肝了半个月,我用 Flutter 写了个功能强大的图片编辑器,告别image_cropper

前言:为什么需要图片编辑?

在如今的应用开发中,图片处理几乎是社交、电商、工具类 App 的标配功能。用户上传头像需要裁剪,分享美图需要编辑,添加水印需要文字功能,为 App 添加一个头像上传功能等等...这些听起来像是一个再普通不过的需求。产品经理的要求也很简单:"用户选完图片,能把它裁成 1:1 的正方形就行。"

于是,我像往常一样打开 pub.dev,很快就找到 image_cropper。看起来完美,对吧?

然而,现实很快就给了我一记重拳。

当我把这个功能集成进我跨平台的 Flutter 应用后,一连串的问题浮出水面:

  1. UI/UX 的巨大鸿沟image_cropper 的核心是通过 MethodChannel 调用原生插件。这直接导致了 Android 和 iOS 两端的裁剪界面(UI)和交互体验(UX)存在巨大差异。对于我这样追求品牌一致性和统一用户体验的团队来说,这几乎是不可接受的。
  2. 桌面端的缺失 :我的应用需要支持 Windows 和 Mac,而 image_cropper 在桌面端的支持并不理想,这直接堵死了我的全平台之路。
  3. 扩展性的枷锁:产品经理紧接着说:"我后续的内容模块需要16:9的封面图,商品模块可能需要自由裁剪......" 我意识到,一个基于原生UI封装的库,很难满足我未来灵活多变的裁剪比例需求。
  4. 性能的隐忧 :后端同事也发来提醒:"上传的图片不能太大,最好能在客户端压缩一下,不然服务器和带宽顶不住!" 这意味着我不仅要裁剪,还需要在不严重损失画质的前提下,对图片进行缩放(Scale)处理,以达到压缩体积的效果 。而这一点,image_cropper 提供的控制力非常有限。

面对这些"卡脖子"的问题,我意识到,我需要的不只是一个"能用"的裁剪工具。我需要的是一个纯 Dart 实现、跨平台 UI 完全一致、功能高度可控、并且能精细化处理图片尺寸的解决方案。

市面上没有现成的轮子,那就自己造一个!

于是,我肝了半个月,从零开始,用 CustomPaint 和矩阵变换硬核打造了这款全新的 Flutter 图片编辑器。它不仅解决了上述所有痛点,还带来了更多惊喜:

  • 像素级无损裁剪:支持任意比例和自由裁剪,保证画质。
  • 内置缩放压缩 :在裁剪的同时,通过精密的 scale 计算,有效控制输出图片的尺寸和体积。
  • 跨平台体验完全一致:纯 Dart 绘制,告别平台差异。
  • 不止是裁剪:它还集成了 360° 自由旋转、功能完善的文本添加与编辑,以及健全的撤销/重做历史管理机制。

先来看看最终效果

  • 图1:图片编辑主界面:回滚、裁剪、渲染、贴图、撤销
  • 图2:图片裁剪主界面:自由比例、特定比例
  • 图3:图片旋转主界面:自由角度、特定角度
  • 图4:图片贴图主界面
  • 原图和编辑后的对比:压缩系数(可配置)

编辑器的架构设计

一个复杂的系统,好的架构是成功的一半。我不想把所有逻辑都堆在一个臃肿的 StatefulWidget 里,那样会变成难以维护的"上帝类"。因此,我从一开始就确立了**"关注点分离"**的核心设计原则。

整个编辑器被拆分为以下几个层次:

  • 视图层 (View) :由 ImageEditorViewCustomPainter 组成,纯粹负责UI的渲染和用户手势的捕获。它本身不包含任何业务逻辑。

  • 控制器 (Controller)ImageEditorController 是整个编辑器的"大脑"。它继承自 ChangeNotifier,负责管理所有状态(图片、旋转角度、裁剪框、文本层等),处理业务逻辑,并通知视图层更新。

  • 模型层 (Model) -:TextLayerData, ImageEditorConfig 等,就是最纯粹的数据结构。

  • 处理器/管理器 (Handlers/Managers) :这是本次设计的精髓。我将不同功能的复杂逻辑抽离成独立的类,让 Controller 保持清爽,只做"指挥官"。

    • CropHandler: 负责所有与裁剪相关的计算,如初始化裁剪框、限制边界、执行高保真裁剪。
    • RotationHandler: 负责渲染旋转后的图片。
    • TextLayerManager: 专门管理文本图层的增删改查和状态。
    • HistoryManager: 负责管理操作快照,实现撤销和重做。

这种分层架构带来了巨大的好处:

  1. 高内聚,低耦合:每个模块只关心自己的职责,修改裁剪逻辑不会影响文本功能。
  2. 易于测试 :可以单独对 CropHandlerHistoryManager 进行单元测试。
  3. 可扩展性强 :未来想增加"滤镜"功能?只需要新增一个 FilterHandler,然后在 Controller 中集成即可,对现有代码的侵入极小。

万物皆可绘:CustomPaint 的魔力

编辑器的核心显示区域,本质上是一个画布(Canvas),我需要在上面自由地绘制图片、裁剪框、文本等元素。在 Flutter 中,CustomPaintCustomPainter 是实现这一需求的最佳选择。

我的 ImageEditorPainterpaint 方法,就像一位画家在按步骤作画:

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);
}

这里有几个关键点:

  1. canvas.save()canvas.restore() :这对组合是 Canvas 操作的黄金法则。它能确保我的变换(平移、旋转、缩放)只对绘制图片生效,而不会影响后续裁剪框和文本的绘制。
  2. 中心点变换:所有的变换都围绕画布中心进行,这样可以保证用户在缩放和旋转时,视觉焦点始终在图片中心,体验更自然。
  3. 绘制裁剪蒙层 :一个有趣的小技巧是,如何绘制裁剪框外部的半透明蒙层?我可以利用 PathfillType = 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像素级的完全掌控力,为实现复杂的交互效果打下了基础。

坐标系与高保真裁剪的实现

这是整个编辑器技术含量最高,也是最难啃的一块硬骨头。

问题是什么?

用户在屏幕上看到的是一张经过缩放旋转 的图片。当他拖动裁剪框时,这个裁剪框的坐标是相对于屏幕 的(我称之为"屏幕坐标系")。但我的最终目标,是从那张未经任何变换的、原始高分辨率的图片("图片坐标系")中,精确地切出对应的部分。

如果只是简单地对屏幕内容进行截图,图片质量会严重下降,这是不可接受的。我必须实现高保真裁剪

解决方案:矩阵变换的魔法!

线性代数中的矩阵,是连接不同坐标系的桥梁。我的思路是:

  1. 构建一个从"图片坐标系"到"屏幕坐标系"的变换矩阵 M
  2. 求出 M 的逆矩阵 M⁻¹,它就是从"屏幕坐标系"返回"图片坐标系"的钥匙。
  3. 将屏幕上裁剪框的四个顶点,通过 M⁻¹ 变换,得到它们在原始图片上的精确坐标。
  4. 根据这四个点,计算出一个能完全包裹它们的最小矩形(sourceRect)。
  5. 使用 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> 列表,作为历史堆栈。

工作流程是这样的:

  1. 保存快照 :在执行一个"破坏性"操作(如应用裁剪、应用旋转)之前 ,调用 _historyManager.saveSnapshot(),将当前状态推入堆栈。
  2. 撤销操作 :当用户点击"撤销"时,调用 _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,我赋予了用户"反悔"的权利,大大提升了编辑器的容错性和用户体验。

可配置的导出压缩,为服务器减负

在前面我提到了一个非常现实的痛点:用户上传的原始图片动辄几兆甚至十几兆,如果直接上传,会给服务器的带宽和存储 带来巨大压力,同时也会消耗用户大量的移动数据。因此,在客户端进行高质量的预压缩,是企业级应用不可或缺的一环。

我的目标很明确:

  1. 压缩过程对用户无感:用户只需要正常编辑,在最后导出时自动完成。
  2. 压缩比率可配置:允许开发者根据不同的业务场景(如头像、封面、普通插图)灵活设置压缩强度。
  3. 保证画质:不能因为压缩就让图片变得模糊不堪,要在体积和质量之间找到最佳平衡。

基于这些目标,我设计了一套优雅的、基于 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() -> 返回最终图片

  1. _captureTransformedImage() : 首先,无论是否压缩,我们都需要先将用户的所有编辑操作(旋转、裁剪、添加文本等)应用,生成一张"所见即所得"的高保真中间图。
  2. _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) :这个 Flutter Canvas 的 API 是实现缩放的关键。它允许我们将一个源矩形(srcRect,即整个原始大图)绘制到一个目标矩形(dstRect,即我们计算出的小尺寸矩形)中。在绘制过程中,Flutter 引擎会自动为我们完成像素的插值和缩放。
  • 画质的秘密武器 (步骤4) :仅仅缩放是不够的,如何保证画质?答案是 Paint 对象的 filterQuality 属性。将其设置为 FilterQuality.high,会告诉 Flutter 引擎使用更高级、更平滑的算法(通常是双线性或双三次插值)来处理像素,从而在缩小图片时最大程度地保留细节,避免出现锯齿和马赛克。
  • 内存管理的典范 (步骤6) :在生成了新的、小尺寸的 resized 图片后,那张作为输入的、高保真的 image 就完成了它的历史使命。通过调用 _disposeImage(image) 主动释放其占用的内存,可以有效避免应用因处理大图而导致的内存峰值,这对于移动设备尤其重要。
3. 优势总结

通过这种基于 scaledrawImageRect 的方案,我们实现了一个非常理想的客户端压缩功能:

  • 逻辑解耦:压缩与编辑功能完全分离,代码清晰,易于维护。
  • 高度可控 :通过 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 图形图像处理有兴趣,或者在寻找一个可以轻松集成到自己项目中的图片编辑器,希望我的这个轮子能帮到你。 感谢你的阅读!如果觉得这篇文章对你有帮助,不妨点个赞吧!

总结和展望

从一个想法,到一个功能完备的图片编辑器,这个过程充满了挑战,也充满了乐趣。回顾整个项目,我认为有几点关键的收获:

  1. 架构先行 :良好的分层和模块化设计是应对复杂度的不二法门。将职责拆分到独立的 HandlerManager 中,让代码逻辑清晰,易于维护和扩展。
  2. 拥抱底层APICustomPaintMatrix4 虽然概念上有些抽象,但它们是实现高级自定义UI和复杂图形操作的利器。深入理解它们,能让你在 Flutter 的世界里拥有更大的创造自由。
  3. 关注核心问题:高保真处理是图片编辑器的灵魂。始终围绕"如何操作原始数据"来思考,而不是"如何修改屏幕显示",是保证最终输出质量的关键。

当然,这个编辑器还有很多可以完善的地方,比如:

  • 性能优化:对于超大尺寸的图片,可以引入瓦片化渲染技术。
  • 功能扩展:增加画笔、马赛克、滤镜等更丰富的编辑工具。
  • 手势体验:支持双指旋转和缩放文本层等更精细的交互。

往期文章介绍

Flutter 全链路监控 SDK

Flutter 全场景弹框

Flutter日历组件

日期选择器

相关推荐
ywf121512 分钟前
前端的dist包放到后端springboot项目下一起打包
前端·spring boot·后端
恋猫de小郭19 分钟前
2026,Android Compose 终于支持 Hot Reload 了,但是收费
android·前端·flutter
hpoenixf6 小时前
2026 年前端面试问什么
前端·面试
还是大剑师兰特6 小时前
Vue3 中的 defineExpose 完全指南
前端·javascript·vue.js
泯泷7 小时前
阶段一:从 0 看懂 JSVMP 架构,先在脑子里搭出一台最小 JSVM
前端·javascript·架构
mengchanmian7 小时前
前端node常用配置
前端
华洛8 小时前
利好打工人,openclaw不是企业提效工具,而是个人助理
前端·javascript·产品经理
xkxnq8 小时前
第六阶段:Vue生态高级整合与优化(第93天)Element Plus进阶:自定义主题(变量覆盖)+ 全局配置与组件按需加载优化
前端·javascript·vue.js
A黄俊辉A9 小时前
vue css中 :global的使用
前端·javascript·vue.js
小码哥_常9 小时前
被EdgeToEdge适配折磨疯了,谁懂!
前端