用 Flutter + BLoC 写一个顺手的涂鸦画板(支持撤销 / 重做 / 橡皮擦 / 保存相册)

这篇文章记录一下我最近用 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(...),
  ),
);

因为外层还有 SafeAreaClipRRect 等包裹,所以拿到的是全局坐标,需要转成画布局部坐标:

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 中:

  1. 遍历所有笔画(包括已完成的 lines 和当前 currentLine);
  2. 对橡皮擦笔画设置 blendMode = BlendMode.clear
  3. 使用 getStroke 生成轮廓点,再用 Path 连接;
  4. 调用 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. 截图原理

核心思路:

  1. 画布区域使用 RepaintBoundary 包裹,并绑定一个 GlobalKey
  2. 用户点击"保存"时,通过 contentKey.currentContext 拿到对应的 RenderRepaintBoundary
  3. 调用 toImage(pixelRatio: 3.0) 得到一张 ui.Image
  4. 转成 PNG 字节,必要时压缩;
  5. 写入本地文件 / 保存到相册。
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 提示保存结果。

这些细节加起来,可以明显提升小组件的"完成度感"。


九、踩坑记录

项目中踩了一些小坑,也顺便记录一下:

  1. GestureDetector + RepaintBoundary 尺寸 & 坐标

    如果画布不是全屏,强烈建议用 GlobalKey + RenderBox.globalToLocal 做坐标转换,避免出现"画到别的地方"的问题。

  2. SafeArea & iOS 顶部高度

    iOS 上通过:

    dart 复制代码
    final paddingTop = MediaQuery.of(context).padding.top;

    再配合自定义的 canvasHeight,保证画布在各种设备上高度合适。

  3. 保存图片时的内存占用
    pixelRatio 太高 + 大尺寸画布,会明显增大内存和文件体积,可以根据业务场景做动态控制。

  4. 相册权限

    • iOS:在 Info.plist 中添加保存相册的权限描述;
    • Android:根据 targetSdk 配置存储 / 媒体权限。

十、总结 & 可扩展方向

这个小项目虽然功能不算复杂,但把以下几个关键知识点都串起来了:

  • Flutter 自定义绘制(CustomPainter + Canvas
  • BLoC 状态管理
  • 手势事件到绘图数据的转换
  • 撤销 / 重做的栈设计
  • 组件截图与保存相册

基于当前实现,很容易继续扩展更多能力:

  • 支持图形绘制:直线、矩形、圆等;
  • 支持文字输入、贴纸;
  • 支持多页画布 / 图层;
  • 支持导出分享(海报、长图等);
  • 支持自定义笔刷(荧光笔、毛笔、虚线笔等)。

如果你也在做类似的画板 / 手写 / 签名组件,可以把 BLoC + CustomPainter 这一套直接抽出来复用,修改 UI 和交互即可。

完整可运行代码、依赖配置、平台权限示例都在 GitHub 仓库中:

欢迎 fork / star / 提 issue 交流 👋。

相关推荐
bqliang23 分钟前
从喝水到学会 Android ASM 插桩
android·kotlin·android studio
一名普通的程序员31 分钟前
在 Flutter + GetX 中实现 Design Tokens 的完整方案
flutter
轮孑哥36 分钟前
flutter flutter_distributor打包错误
windows·flutter
肠胃炎38 分钟前
Flutter 线性组件详解
前端·flutter
肠胃炎41 分钟前
Flutter 布局组件详解
前端·flutter
不爱吃糖的程序媛42 分钟前
彻底解决 Flutter 开发 HarmonyOS 应用:No Hmos SDK found 报错
flutter·华为·harmonyos
liuxf12341 小时前
fvm管理鸿蒙flutter
flutter·华为·harmonyos
HAPPY酷1 小时前
Flutter 开发环境搭建全流程
android·python·flutter·adb·pip
圆肖1 小时前
File Inclusion
android·ide·android studio