Flutter for OpenHarmony 三方库适配教学 - image_editor_dove 涂鸦功能
三方库开源地址: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;
}
}
这个方法做了几件事:
- 获取手指在画布上的相对位置
- 检查是否在画布范围内
- 如果用户从画布外回到画布内,把点类型改成
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 的逻辑。确保只有一个手指在绑制,其他手指的事件被忽略。
小结
涂鸦功能的核心要点:
Point和PointType是基础数据模型,记录用户的每一个操作SignatureController继承自ValueNotifier,管理点数据和撤销/重做SignatureWidget 捕获手指事件,转换成Point对象SignaturePainter负责把点数据绑制到画布上- 多图层管理支持颜色切换后保留历史笔画
- 保存到相册需要调用鸿蒙原生 API
ValueNotifier + CustomPainter 的组合是 Flutter 里实现自定义绑制的经典模式,值得好好学习。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)