三方库开源地址:https://atomgit.com/nutpi/flutter_ohos_image_editor_dove

前言

请添加图片描述

说到图片编辑器,涂鸦功能绝对是最核心的部分之一。用户拿起手指在屏幕上划一划,就能在图片上留下痕迹,这个交互看起来简单,但背后的实现其实挺有意思的。

我第一次看 image_editor_dove 的涂鸦代码时,对它的设计印象很深。它用 CustomPainter 配合 ValueNotifier 来驱动重绘,再加上多图层管理和撤销/重做机制,整个思路非常清晰。这篇文章就来拆解一下涂鸦功能的实现。


从一个点开始

涂鸦的本质是什么?其实就是把用户手指经过的每一个位置记录下来,然后连成线。所以第一步,我们需要一个数据结构来表示"点"。

enum PointType {
  tap,   // 点击
  move,  // 移动
}

为什么要区分点击和移动?想象一下,用户快速点一下屏幕,和用户按住屏幕拖动,这两种操作是不一样的。点击应该画一个圆点,拖动应该画一条线。PointType 就是用来区分这两种情况的。


class Point {
  Point(this.offset, this.type, this.eventId);

  Offset offset;      // 坐标位置
  PointType type;     // 点的类型
  int eventId;        // 事件标识
}

Offset 是 Flutter 里表示二维坐标的类,包含 dx(x 坐标)和 dy(y 坐标)两个值。

eventId 这个字段很关键。用户可能画完一笔抬起手指,然后再画第二笔。这两笔之间是断开的,不应该连在一起。通过 eventId 就能区分不同的笔画,绘制的时候就知道哪些点应该连起来,哪些点应该断开。


SignatureController - 画板的大脑

控制器是整个涂鸦功能的核心,它继承自 ValueNotifier<List<Point>>。这意味着它本身就是一个可观察对象,当点数据变化时会自动通知所有监听者。

class SignatureController extends ValueNotifier<List<Point>> {
  SignatureController({
    List<Point>? points,
    this.penColor = Colors.black,
    this.penStrokeWidth = 3.0,
    this.onDrawStart,
    this.onDrawMove,
    this.onDrawEnd,
  }) : super(points ?? <Point>[]);

构造函数接收一堆参数,但都有默认值。penColor 是画笔颜色,penStrokeWidth 是画笔粗细。三个回调函数分别在开始绘制、移动、结束时触发,方便外部做一些额外处理。


  final Color penColor;
  final double penStrokeWidth;
  
  DrawStyle drawStyle = DrawStyle.normal;

这些属性控制绘制的外观。drawStyle 默认是普通模式,也可以切换成马赛克模式(这个在马赛克功能文章里详细讲)。


添加点并触发重绘

void addPoint(Point point) {
  value.add(point);
  notifyListeners();
}

这个方法看起来简单,但它是整个涂鸦功能的关键。每当用户手指移动,就会调用这个方法添加一个新的点。notifyListeners() 会通知所有监听者数据变了,然后 CustomPainter 就会重新绘制。

这就是 ValueNotifier 的魅力——你不需要手动管理重绘时机,只要数据变了,UI 就会自动更新。


撤销和重做

涂鸦功能怎么能没有撤销呢?画错了总得能改吧。SignatureController 用两个栈来实现撤销和重做。

final List<List<Point>> _latestActions = <List<Point>>[];
final List<List<Point>> _revertedActions = <List<Point>>[];

_latestActions 是撤销栈,存的是每一步操作完成后的点列表快照。_revertedActions 是重做栈,存的是被撤销的操作。


当用户完成一笔(抬起手指)时,保存当前状态:

void pushCurrentStateToUndoStack() {
  _latestActions.add(<Point>[...points]);
  _revertedActions.clear();
}

注意这里用了 [...points] 来复制列表。为什么不直接存 points?因为 points 是一个引用,如果直接存引用,后续的修改会影响之前保存的状态。所以必须复制一份。

另外,每次新操作都会清空重做栈。这很合理——用户做了新操作,之前撤销的历史就没意义了。


撤销操作的实现:

void undo() {
  if (_latestActions.isNotEmpty) {
    final List<Point> lastAction = _latestActions.removeLast();
    _revertedActions.add(<Point>[...lastAction]);
    
    if (_latestActions.isNotEmpty) {
      points = <Point>[..._latestActions.last];
      return;
    }
    points = <Point>[];
    notifyListeners();
  }
}

逻辑是这样的:从撤销栈取出最后一个状态,放到重做栈里。然后把画布恢复到上一个状态。如果撤销栈空了,就清空画布。


重做操作正好相反:

void redo() {
  if (_revertedActions.isNotEmpty) {
    final List<Point> lastRevertedAction = _revertedActions.removeLast();
    _latestActions.add(<Point>[...lastRevertedAction]);
    points = <Point>[...lastRevertedAction];
    notifyListeners();
  }
}

从重做栈取出最后一个状态,放回撤销栈,然后恢复画布。


Signature Widget - 捕获手指事件

有了控制器,还需要一个 Widget 来捕获用户的手指事件。Signature 就是干这个的。

class Signature extends StatefulWidget {
  const Signature({
    required this.controller,
    Key? key,
    this.backgroundColor = Colors.grey,
    this.width,
    this.height,
  }) : super(key: key);

  final SignatureController controller;
  final double? width;
  final double? height;
  final Color backgroundColor;

controller 是必需的,其他参数都是可选的。如果不指定宽高,画板会占满整个可用空间。


处理多点触控

手机屏幕支持多点触控,但涂鸦的时候我们只想响应一个手指。怎么办?

class SignatureState extends State<Signature> {
  bool _isOutsideDrawField = false;
  int? activePointerId;

activePointerId 用来锁定当前活动的手指。一旦某个手指开始绘制,其他手指的事件就会被忽略。

_isOutsideDrawField 标记用户是否把手指移出了画布区域。这个后面会用到。


手指按下时:

onPointerDown: (PointerDownEvent event) {
  if (activePointerId == null || activePointerId == event.pointer) {
    activePointerId = event.pointer;
    widget.controller.onDrawStart?.call();
    _addPoint(event, PointType.tap);
  }
}

检查是否已经有手指在绘制。如果没有,就锁定当前手指,调用 onDrawStart 回调,然后添加一个 tap 类型的点。


手指移动时:

onPointerMove: (PointerMoveEvent event) {
  if (activePointerId == event.pointer) {
    _addPoint(event, PointType.move);
    widget.controller.onDrawMove?.call();
  }
}

只有当前活动的手指移动时才处理,添加 move 类型的点。


手指抬起时:

onPointerUp: (PointerUpEvent event) {
  if (activePointerId == event.pointer) {
    _addPoint(event, PointType.tap);
    widget.controller.pushCurrentStateToUndoStack();
    widget.controller.onDrawEnd?.call();
    activePointerId = null;
  }
}

添加最后一个点,保存当前状态到撤销栈,调用 onDrawEnd 回调,然后释放指针锁定。


边界检测

用户的手指可能会移出画布区域,这时候需要特殊处理。

void _addPoint(PointerEvent event, PointType type) {
  final Offset o = event.localPosition;
  
  if ((widget.width == null || o.dx > 0 && o.dx < widget.width!) &&
      (widget.height == null || o.dy > 0 && o.dy < widget.height!)) {
    
    PointType t = type;
    if (_isOutsideDrawField) {
      t = PointType.tap;
    }
    
    setState(() {
      _isOutsideDrawField = false;
      widget.controller.addPoint(Point(o, t, event.pointer));
    });
  } else {
    _isOutsideDrawField = true;
  }
}

这个方法做了几件事:

  1. 获取手指在画布上的相对位置
  2. 检查是否在画布范围内
  3. 如果用户从画布外回到画布内,把点类型改成 tap

为什么要改成 tap?因为用户的手指离开画布又回来,这中间的轨迹我们是不知道的。如果还用 move 类型,绘制的时候会把离开前的点和回来后的点连起来,画出一条奇怪的直线。改成 tap 就能避免这个问题。


SignaturePainter - 真正的画笔

前面都是在收集数据,真正把东西画出来的是 SignaturePainter。它继承自 CustomPainter

class SignaturePainter extends CustomPainter {
  SignaturePainter(this._controller)
      : _penStyle = Paint(),
        super(repaint: _controller) {
    _penStyle
      ..color = _controller.penColor
      ..style = PaintingStyle.stroke
      ..strokeWidth = _controller.penStrokeWidth;
  }

  final SignatureController _controller;
  final Paint _penStyle;

注意 super(repaint: _controller) 这行。通过把 _controller 传给 repaint 参数,CustomPainter 就会监听 _controller 的变化。当 _controller 调用 notifyListeners() 时,paint() 方法会自动被调用。

这就是为什么我们在 addPoint() 里调用 notifyListeners() 后,画布会自动更新。


绘制入口


void paint(Canvas canvas, _) {
  final List<Point> points = _controller.value;
  if (points.isEmpty) {
    return;
  }

  if (_controller.drawStyle == DrawStyle.normal) {
    canvas.drawPath(paintPath(), _penStyle);
  }
}

逻辑很简单:获取所有的点,如果没有点就直接返回,然后绘制路径。


普通涂鸦的绘制

Path paintPath() {
  final Path path = Path();
  final Map<int, List<Point>> pathM = {};
  
  points.forEach((element) {
    if(pathM[element.eventId] == null)
      pathM[element.eventId] = [];
    pathM[element.eventId]!.add(element);
  });

第一步是按 eventId 分组。还记得前面说的吗?不同的笔画有不同的 eventId,这样就能把属于同一笔画的点放在一起。


  pathM.forEach((key, value) {
    final first = value.first;
    path.moveTo(first.offset.dx, first.offset.dy);
    
    if(value.length <= 3) {
      _penStyle.style = PaintingStyle.fill;
      canvas.drawCircle(first.offset, _controller.penStrokeWidth, _penStyle);
      _penStyle.style = PaintingStyle.stroke;
    } else {
      value.forEach((e) {
        path.lineTo(e.offset.dx, e.offset.dy);
      });
    }
  });
  
  return path;
}

对每一笔画,从第一个点开始。如果这笔画只有 3 个点或更少,说明用户只是快速点了一下,这时候画一个圆点比画线更合适。否则就把所有点连起来。

path.moveTo() 是移动画笔到指定位置但不画线,path.lineTo() 是从当前位置画线到指定位置。


多图层管理

在主编辑器里,涂鸦功能通过 SignatureBinding Mixin 集成进来。这个 Mixin 有一个很巧妙的设计——多图层管理。

mixin SignatureBinding<T extends StatefulWidget> on State<ImageEditor> {
  final List<Widget> pathRecord = [];
  late SignatureController painterController;
  Color pColor = Colors.redAccent;

pathRecord 存的是每次换颜色之前的绘制内容。这样就实现了多图层的效果。


当用户切换颜色时:

void changePainterColor(Color color) async {
  pColor = color;
  realState?._panelController.selectColor(color);
  
  pathRecord.insert(0, RepaintBoundary(
    child: CustomPaint(
      painter: SignaturePainter(painterController),
      child: ConstrainedBox(
        constraints: const BoxConstraints(
          minWidth: double.infinity,
          minHeight: double.infinity,
        ),
      ),
    ),
  ));
  
  initPainter();
  _refreshBrushCanvas();
}

这里做了什么?把当前的画笔内容"固化"成一个静态的 CustomPaint Widget,插入到 pathRecord 里。然后创建一个新的画笔控制器,开始新的图层。

这样做的好处是,不同颜色的笔画互不干扰。红色的笔画在一个图层,蓝色的笔画在另一个图层,撤销的时候也方便处理。


构建画布时,把所有图层叠在一起:

Widget _buildBrushCanvas() {
  if (pathRecord.isEmpty) {
    pathRecord.add(Signature(
      controller: painterController,
      backgroundColor: Colors.transparent,
    ));
  }
  return StatefulBuilder(builder: (ctx, canvasSetter) {
    this.canvasSetter = canvasSetter;
    return realState?.ignoreWidgetByType(
      OperateType.brush,
      Stack(children: pathRecord),
    ) ?? SizedBox();
  });
}

Stack 把所有图层叠起来,最上面是当前可交互的 Signature Widget,下面是之前固化的静态图层。


OpenHarmony 适配:保存涂鸦到相册

涂鸦功能本身是纯 Dart 实现的,不需要原生代码。但如果想把涂鸦结果保存到系统相册,就需要调用鸿蒙原生 API 了。

Dart 端定义方法

import 'package:flutter/services.dart';

const platform = MethodChannel('image_editor');

Future<bool> saveDrawingToGallery(Uint8List imageBytes, String fileName) async {
  try {
    final result = await platform.invokeMethod('saveDrawingToGallery', {
      'imageData': imageBytes,
      'fileName': fileName,
    });
    return result == true;
  } catch (e) {
    print('保存失败: $e');
    return false;
  }
}

通过 MethodChannel 调用原生方法,传入图片数据和文件名。原生端负责把图片保存到相册。


获取涂鸦的图片数据

SignatureController 提供了 toPngBytes() 方法,可以把涂鸦内容导出为 PNG 格式的字节数组:

Future<Uint8List?> toPngBytes() async {
  final ui.Image? image = await toImage();
  if (image == null) {
    return null;
  }
  final ByteData? bytes = await image.toByteData(
    format: ui.ImageByteFormat.png,
  );
  return bytes?.buffer.asUint8List();
}

先把点数据转成 ui.Image,然后编码成 PNG 格式。


鸿蒙端处理方法调用

ImageEditorPlugin.ets 中添加方法处理:

import {
  FlutterPlugin,
  FlutterPluginBinding,
  MethodCall,
  MethodCallHandler,
  MethodChannel,
  MethodResult,
} from '@ohos/flutter_ohos';

export default class ImageEditorPlugin implements FlutterPlugin, MethodCallHandler {
  private channel: MethodChannel | null = null;

  onAttachedToEngine(binding: FlutterPluginBinding): void {
    this.channel = new MethodChannel(binding.getBinaryMessenger(), "image_editor");
    this.channel.setMethodCallHandler(this);
  }

  onMethodCall(call: MethodCall, result: MethodResult): void {
    if (call.method == "getPlatformVersion") {
      result.success("HarmonyOS");
    } else if (call.method == "saveDrawingToGallery") {
      this.saveDrawingToGallery(call, result);
    } else {
      result.notImplemented();
    }
  }

根据 call.method 分发到不同的处理函数。这是 Flutter 插件的标准模式。


保存到相册的实现

import { photoAccessHelper } from '@kit.MediaLibraryKit';
import { fileIo } from '@kit.CoreFileKit';

private async saveDrawingToGallery(call: MethodCall, result: MethodResult): Promise<void> {
  try {
    const imageData = call.argument('imageData') as Uint8Array;
    const fileName = call.argument('fileName') as string;
    
    const context = getContext(this);
    const helper = photoAccessHelper.getPhotoAccessHelper(context);

首先导入需要的模块,获取相册访问助手。


    const photoCreateOption: photoAccessHelper.PhotoCreateOptions = {
      subtype: photoAccessHelper.PhotoSubtype.DEFAULT,
    };
    
    const photoUri = await helper.createAsset(
      photoAccessHelper.PhotoType.IMAGE,
      'png',
      photoCreateOption
    );

在相册中创建一个新的图片资源。createAsset 返回新资源的 URI。


    const destFile = await fileIo.open(photoUri, fileIo.OpenMode.WRITE_ONLY);
    await fileIo.write(destFile.fd, imageData.buffer);
    await fileIo.close(destFile);
    
    result.success(true);
  } catch (e) {
    result.error("SaveError", e.toString(), null);
  }
}

打开目标文件,写入图片数据,关闭文件。成功后调用 result.success(true)


性能优化

涂鸦功能在低端设备上可能会遇到性能问题,特别是画了很多笔之后。

限制点的数量

void addPoint(Point point) {
  value.add(point);
  
  if (value.length > 10000) {
    value.removeAt(0);
  }
  
  notifyListeners();
}

如果点太多,删除最早的点。


降低采样频率

int _lastAddTime = 0;

void addPoint(Point point) {
  final now = DateTime.now().millisecondsSinceEpoch;
  
  if (now - _lastAddTime < 16) {
    return;
  }
  
  _lastAddTime = now;
  value.add(point);
  notifyListeners();
}

每 16 毫秒最多添加一个点,相当于 60fps。


使用 RepaintBoundary

RepaintBoundary(
  child: CustomPaint(
    painter: SignaturePainter(painterController),
    child: ConstrainedBox(...),
  ),
)

RepaintBoundary 会创建一个新的绑制层,重绘时不会影响其他部分。


常见问题

画出来的线断断续续

可能是采样频率太低,或者 PointType 处理有问题。检查一下 _addPoint() 方法里的边界检测逻辑。


撤销不生效

确保在手指抬起时调用了 pushCurrentStateToUndoStack()。如果没有保存状态,撤销栈就是空的。


多点触控时画出奇怪的线

检查 activePointerId 的逻辑。确保只有一个手指在绑制,其他手指的事件被忽略。


小结

涂鸦功能的核心要点:

  1. PointPointType 是基础数据模型,记录用户的每一个操作
  2. SignatureController 继承自 ValueNotifier,管理点数据和撤销/重做
  3. Signature Widget 捕获手指事件,转换成 Point 对象
  4. SignaturePainter 负责把点数据绑制到画布上
  5. 多图层管理支持颜色切换后保留历史笔画
  6. 保存到相册需要调用鸿蒙原生 API

ValueNotifier + CustomPainter 的组合是 Flutter 里实现自定义绑制的经典模式,值得好好学习。


欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐