这篇文章记录一下我最近用 Flutter 写的一个小玩具:涂鸦画板。但把一套完整的交互链路串起来还是挺有意思的,很适合作为 Flutter 练手项目。
一、项目背景 & 功能概览
这是一个基于 Flutter + BLoC 的小型涂鸦画板应用,主要功能包括:
- ✏️ 支持手写笔迹(基于
perfect_freehand) - ↩️ 撤销 / ↪️ 重做
- 🧽 橡皮擦模式(长按一键清空)
- 🎚 笔刷粗细调节
- 🎨 颜色选择器
- 🌈 渐变背景画布
- 💾 一键保存到系统相册
整体目标是:在不依赖原生 View 的前提下,用纯 Flutter 写出一个体验顺滑、结构清晰、方便扩展的画板组件。

二、整体架构设计
项目大致拆成几块:
Painting页面:- 负责布局(画布 + 顶部工具栏 + 左侧粗细滑块 + 底部颜色条)
- 负责手势采集(
GestureDetector)
PaintingBloc:- 管理所有绘图相关状态(线条列表、当前颜色、当前粗细、橡皮擦模式、撤销栈等)
PaintingModel:- 描述一条笔画(点集合、颜色、粗细、是否橡皮擦等)
Sketcher:CustomPainter,负责真正把所有笔画画到Canvas上
- 一些 UI 组件:
Toptools顶部工具栏(撤销 / 重做 / 橡皮擦 / 保存)SizeSliderWidget左侧笔刷粗细调节ColorSelector底部颜色条
takePicture服务:- 基于
RepaintBoundary.toImage截取画布 - 使用
flutter_image_compress压缩 - 使用
ImageGallerySaverPlus保存到相册
- 基于
数据流可以简单概括为:
手势事件 → 派发 BLoC Event → 更新 PaintingState → 触发重绘
CustomPaint→ 用户看到最新笔迹
三、数据模型与状态管理
1. 笔画数据模型 PaintingModel
每一笔抽象成一个模型,核心信息:
- 一串点
points - 笔刷粗细
size - 颜色
lineColor - 是否为橡皮擦
isEraser
示意代码大概是这样(完整版本在仓库里):
dart
class PaintingModel {
List<PointVector> points;
double size;
Color lineColor;
bool isEraser;
PaintingModel({
required this.points,
required this.size,
required this.lineColor,
this.isEraser = false,
});
}
这里的 PointVector 来自 perfect_freehand,它对笔迹平滑、压感效果会更友好。
2. 全局绘图状态 PaintingState
PaintingState 包含:
lines:已完成笔画列表currentLine:当前正在画的那一笔lineColorIndex:当前颜色索引lineWidth:当前笔刷粗细isEraserMode:是否进入橡皮擦模式undoStack:撤销栈(用于重做)
简化后的结构:
dart
class PaintingState extends Equatable {
final List<PaintingModel> lines;
final PaintingModel? currentLine;
final List<PaintingModel> undoStack;
final int lineColorIndex;
final double lineWidth;
final bool isEraserMode;
}
这里通过 Equatable 做状态对比,配合 BlocBuilder 可以做到按需重建 UI。
3. BLoC 事件(Event)设计
比较自然的划分:
- 手势相关:
PanStartEvent,PanUpdateEvent,PanEndEvent - 配置相关:
UpdateLineColorIndexEvent,UpdateLineWidthEvent - 模式相关:
ToggleEraserModeEvent - 撤销 / 重做:
RemoveLastLineEvent,RedoLastLineEvent - 整体清空:
ClearAllEvent
大部分逻辑都被收敛到一个 PaintingBloc 中,方便维护和后续扩展。
四、从手势到笔画:坐标转换与笔迹生成
1. 手势采集 → 发送 BLoC Event
画布外层用 GestureDetector 包着 RepaintBoundary + CustomPaint:
dart
GestureDetector(
onPanStart: handlePanStart,
onPanUpdate: handlePanUpdate,
onPanEnd: handlePanEnd,
child: RepaintBoundary(
key: canvasKey,
child: CustomPaint(...),
),
);
因为外层还有 SafeArea、ClipRRect 等包裹,所以拿到的是全局坐标,需要转成画布局部坐标:
dart
Offset? _getLocalOffset(Offset global) {
final ctx = canvasKey.currentContext;
if (ctx == null) return null;
final box = ctx.findRenderObject() as RenderBox;
return box.globalToLocal(global);
}
最终在 handlePanStart / Update / End 中,把转换后的坐标包装成 PointVector,发给 BLoC 即可。
2. perfect_freehand:把一串点变成顺滑笔迹
BLoC 收集到的实际上是一串"离散点",真正渲染时交给 perfect_freehand 处理:
dart
final outlinePoints = getStroke(
line.points,
options: StrokeOptions(
size: line.size,
simulatePressure: true,
// 其他参数见源码
),
);
getStroke 会返回一组轮廓点 ,再用 Path 拼接成路径,画到 Canvas 上,就能得到非常顺滑、接近真实书写的效果。
五、画布绘制 & 橡皮擦实现
核心绘制逻辑在 Sketcher extends CustomPainter 中:
- 遍历所有笔画(包括已完成的
lines和当前currentLine); - 对橡皮擦笔画设置
blendMode = BlendMode.clear; - 使用
getStroke生成轮廓点,再用Path连接; - 调用
canvas.drawPath(path, paint)。
橡皮擦的关键不是"画出一个白色",而是:
dart
paint
..color = Colors.transparent
..blendMode = BlendMode.clear;
并且整段绘制包裹在:
dart
canvas.saveLayer(Offset.zero & size, Paint());
// ... draw
canvas.restore();
也就是说:橡皮擦其实是在当前图层上"抠除已有像素",对背景渐变不会造成影响。
六、撤销 / 重做:一条简单但好用的栈设计
1. 撤销思路
- 日常绘制时,所有完成的笔画都放在
lines; - 点击"撤销"时,将
lines最后一个弹出,塞进undoStack; - 为避免内存无限增长,引入
maxUndoStackSize控制最大撤销步数。
伪代码示意:
dart
if (lines.isNotEmpty) {
final removed = lines.removeLast();
undoStack.add(removed);
if (undoStack.length > maxUndoStackSize) {
undoStack.removeAt(0);
}
}
2. 重做思路
- 点击"重做"时,从
undoStack弹出最后一个,重新塞回lines;
dart
if (undoStack.isNotEmpty) {
final redoLine = undoStack.removeLast();
lines.add(redoLine);
}
这种双栈式的实现方式简单直观,非常适合画板类、编辑器类的场景。
七、保存画布到相册:RepaintBoundary + toImage
1. 截图原理
核心思路:
- 画布区域使用
RepaintBoundary包裹,并绑定一个GlobalKey; - 用户点击"保存"时,通过
contentKey.currentContext拿到对应的RenderRepaintBoundary; - 调用
toImage(pixelRatio: 3.0)得到一张ui.Image; - 转成 PNG 字节,必要时压缩;
- 写入本地文件 / 保存到相册。
dart
final boundary =
contentKey.currentContext?.findRenderObject() as RenderRepaintBoundary?;
final image = await boundary?.toImage(pixelRatio: 3.0);
final byteData = await image?.toByteData(format: ui.ImageByteFormat.png);
final pngBytes = byteData?.buffer.asUint8List();
2. 本地压缩 & 保存到相册
为了避免生成的 PNG 太大,使用 flutter_image_compress 做简单压缩,然后用 ImageGallerySaverPlus 写入相册。
伪代码:
dart
final compressed = await FlutterImageCompress.compressWithList(pngBytes);
final result = await ImageGallerySaverPlus.saveImage(compressed);
iOS / Android 均需要在对应平台配置相册权限,具体可参考仓库 README 或官方文档。
八、UI 交互 & 一些小设计
整个 UI 采用了 Stack + 对齐的方式:
- 背景画布 :
- 使用纵向渐变色,做出类似"天空 / 海面"效果;
- 左侧竖直滑块控制笔刷粗细 :
RotatedBox(quarterTurns: 3)把Slider横着转成竖的;AnimatedContainer做展开/收起的小动画;
- 底部颜色选择条 :
- 一排圆形颜色块,支持横向滚动;
- 左边用一个较大的圆形展示当前颜色;
- 顶部工具栏 :
- 撤销 / 重做:用左右翻转的同一图标;
- 橡皮擦:点击切换模式、长按清空画布;
- 保存:点击后给一个浮动 SnackBar 提示保存结果。
这些细节加起来,可以明显提升小组件的"完成度感"。
九、踩坑记录
项目中踩了一些小坑,也顺便记录一下:
-
GestureDetector+RepaintBoundary尺寸 & 坐标如果画布不是全屏,强烈建议用
GlobalKey+RenderBox.globalToLocal做坐标转换,避免出现"画到别的地方"的问题。 -
SafeArea & iOS 顶部高度
iOS 上通过:
dartfinal paddingTop = MediaQuery.of(context).padding.top;再配合自定义的
canvasHeight,保证画布在各种设备上高度合适。 -
保存图片时的内存占用
pixelRatio太高 + 大尺寸画布,会明显增大内存和文件体积,可以根据业务场景做动态控制。 -
相册权限
- iOS:在
Info.plist中添加保存相册的权限描述; - Android:根据 targetSdk 配置存储 / 媒体权限。
- iOS:在
十、总结 & 可扩展方向
这个小项目虽然功能不算复杂,但把以下几个关键知识点都串起来了:
- Flutter 自定义绘制(
CustomPainter+Canvas) - BLoC 状态管理
- 手势事件到绘图数据的转换
- 撤销 / 重做的栈设计
- 组件截图与保存相册
基于当前实现,很容易继续扩展更多能力:
- 支持图形绘制:直线、矩形、圆等;
- 支持文字输入、贴纸;
- 支持多页画布 / 图层;
- 支持导出分享(海报、长图等);
- 支持自定义笔刷(荧光笔、毛笔、虚线笔等)。
如果你也在做类似的画板 / 手写 / 签名组件,可以把 BLoC + CustomPainter 这一套直接抽出来复用,修改 UI 和交互即可。
完整可运行代码、依赖配置、平台权限示例都在 GitHub 仓库中:
- 👉 GitHub
欢迎 fork / star / 提 issue 交流 👋。