肝了半个月,我用 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日历组件

日期选择器

相关推荐
Karl_wei3 小时前
桌面应用开发,Flutter 与 Electron如何选
windows·flutter·electron
veneno4 小时前
大量异步并发请求控制并发解决方案
前端
i***t9195 小时前
Spring Boot项目接收前端参数的11种方式
前端·spring boot·后端
oden5 小时前
2025博客框架选择指南:Hugo、Astro、Hexo该选哪个?
前端·html
小光学长5 小时前
基于ssm的宠物交易系统的设计与实现850mb48h(程序+源码+数据库+调试部署+开发环境)带论文文档1万字以上,文末可获取,系统界面在最后面。
java·前端·数据库
小小前端要继续努力5 小时前
渐进增强、优雅降级及现代Web开发技术详解
前端
小兔薯了6 小时前
7. LNMP-wordpress
android·运维·服务器·数据库·nginx·php
老前端的功夫6 小时前
前端技术选型的理性之道:构建可量化的ROI评估模型
前端·javascript·人工智能·ubuntu·前端框架
狮子座的男孩6 小时前
js函数高级:04、详解执行上下文与执行上下文栈(变量提升与函数提升、执行上下文、执行上下文栈)及相关面试题
前端·javascript·经验分享·变量提升与函数提升·执行上下文·执行上下文栈·相关面试题
爱学习的程序媛7 小时前
《JavaScript权威指南》核心知识点梳理
开发语言·前端·javascript·ecmascript