From e07da7addbae5f10092172e50a02ed692fdd2d8e Mon Sep 17 00:00:00 2001 From: iven Date: Mon, 1 Jun 2026 13:18:36 +0800 Subject: [PATCH] =?UTF-8?q?perf(app):=20=E6=89=8B=E5=86=99=E5=BC=95?= =?UTF-8?q?=E6=93=8E=E6=80=A7=E8=83=BD=E4=BC=98=E5=8C=96=20=E2=80=94=20?= =?UTF-8?q?=E5=8F=8C=E5=B1=82=E6=9E=B6=E6=9E=84=20+=20=E5=85=89=E6=A0=85?= =?UTF-8?q?=E5=8C=96=E7=BC=93=E5=AD=98=20+=20O(1)=20=E7=82=B9=E7=BC=93?= =?UTF-8?q?=E5=86=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 性能优化: - 新建 StrokeRasterCache: 已完成笔画光栅化为 ui.Image 合成位图 - 新建 CachedStrokesPainter: 每帧仅 drawImage,O(1) 开销 - 新建 ActiveStrokePainter: 仅渲染当前笔画,isComplete: false - _currentPoints 改为可变缓冲区 + ValueNotifier 驱动,消除 O(N²) 列表拷贝 - 双层 Stack 架构: 已缓存层(不随指针移动重绘) + 实时层(仅当前笔画) Bug 修复: - 橡皮擦 saveLayer 合成: BlendMode.dstOut 在离屏缓冲区中正确工作 - pointsToOutline 新增 isComplete 参数: 实时绘制传 false,完成笔画传 true - 模式切换不再销毁 HandwritingCanvas: IgnorePointer 替代 if/else 分支 架构改进: - 提取 createPaintForStroke() 为顶层函数,供缓存和 Painter 共用 - 移除旧 StrokePainter 类,由双层 Painter 替代 - LayoutBuilder 跟踪画布尺寸,尺寸变化时缓存自动失效 文件变更: - 新建 stroke_cache.dart (~210 行) - 新建 cached_strokes_painter.dart (~35 行) - 新建 active_stroke_painter.dart (~70 行) - 重写 handwriting_canvas.dart (~300 行) - 重构 stroke_renderer.dart (~185 行, 移除旧 Painter) - 修改 editor_page.dart (IgnorePointer 模式切换) 验证: flutter analyze 0 error --- .../features/editor/views/editor_page.dart | 17 +- .../editor/widgets/active_stroke_painter.dart | 72 +++++ .../widgets/cached_strokes_painter.dart | 36 +++ .../editor/widgets/handwriting_canvas.dart | 165 +++++++--- .../features/editor/widgets/stroke_cache.dart | 303 ++++++++++++++++++ .../editor/widgets/stroke_renderer.dart | 133 +++----- 6 files changed, 573 insertions(+), 153 deletions(-) create mode 100644 app/lib/features/editor/widgets/active_stroke_painter.dart create mode 100644 app/lib/features/editor/widgets/cached_strokes_painter.dart create mode 100644 app/lib/features/editor/widgets/stroke_cache.dart diff --git a/app/lib/features/editor/views/editor_page.dart b/app/lib/features/editor/views/editor_page.dart index 86d395c..79c0e50 100644 --- a/app/lib/features/editor/views/editor_page.dart +++ b/app/lib/features/editor/views/editor_page.dart @@ -154,8 +154,10 @@ class _EditorStack extends StatelessWidget { fit: StackFit.expand, children: [ // Layer 1: 手写画布(底层) - if (state.isDrawingMode) - HandwritingCanvas( + // 始终渲染,通过 IgnorePointer 控制交互(避免模式切换时销毁重建) + IgnorePointer( + ignoring: !state.isDrawingMode, + child: HandwritingCanvas( brushType: state.brushType, brushColor: state.brushColor, brushWidth: state.brushWidth, @@ -163,17 +165,8 @@ class _EditorStack extends StatelessWidget { onStrokeCompleted: (stroke) { context.read().add(StrokeCompleted(stroke)); }, - ) - else - // 非绘画模式:显示已有笔画(不可交互) - IgnorePointer( - child: HandwritingCanvas( - brushType: state.brushType, - brushColor: state.brushColor, - brushWidth: state.brushWidth, - strokes: state.strokes, - ), ), + ), // Layer 2: 可拖拽元素(中层) if (state.elements.isNotEmpty) diff --git a/app/lib/features/editor/widgets/active_stroke_painter.dart b/app/lib/features/editor/widgets/active_stroke_painter.dart new file mode 100644 index 0000000..5a0d73d --- /dev/null +++ b/app/lib/features/editor/widgets/active_stroke_painter.dart @@ -0,0 +1,72 @@ +// 当前笔画实时 Painter — 绘制正在绘制中的笔画 +// +// 接收可变点缓冲区的直接引用 + 版本号驱动重绘。 +// 每帧仅计算当前笔画的轮廓路径,不影响已完成笔画层。 +// isComplete: false 让 perfect_freehand 对实时笔尖做端点平滑。 + +import 'package:flutter/widgets.dart'; + +import 'stroke_model.dart'; +import 'stroke_renderer.dart'; + +/// 当前笔画实时 Painter +/// +/// 由 ListenableBuilder 包裹,监听 ValueNotifier _strokeVersion。 +/// 每次 pointer move 递增 version,触发此 Painter 重绘。 +/// 仅渲染当前正在绘制的笔画,已完成笔画由 CachedStrokesPainter 处理。 +class ActiveStrokePainter extends CustomPainter { + /// 当前笔画的采样点(直接引用可变缓冲区,不拷贝) + final List points; + + /// 画笔类型 + final BrushType brushType; + + /// 画笔颜色(CSS 十六进制) + final String color; + + /// 画笔宽度 + final double width; + + /// 版本号,每次 pointer move 递增 + final int version; + + ActiveStrokePainter({ + required this.points, + required this.brushType, + required this.color, + required this.width, + required this.version, + }); + + @override + void paint(Canvas canvas, Size size) { + if (points.length < 2) return; + + // isComplete: false — 实时笔尖不做端点封口,视觉更自然 + final outlinePoints = pointsToOutline( + points, + brushType, + width, + isComplete: false, + ); + if (outlinePoints.isEmpty) return; + + final path = buildStrokePath(outlinePoints); + + // 构造临时 Stroke 用于获取 Paint + final stroke = Stroke( + id: '__active__', + points: points, + brushType: brushType, + color: color, + width: width, + ); + final paint = createPaintForStroke(stroke); + canvas.drawPath(path, paint); + } + + @override + bool shouldRepaint(covariant ActiveStrokePainter oldDelegate) { + return oldDelegate.version != version; + } +} diff --git a/app/lib/features/editor/widgets/cached_strokes_painter.dart b/app/lib/features/editor/widgets/cached_strokes_painter.dart new file mode 100644 index 0000000..471ad8d --- /dev/null +++ b/app/lib/features/editor/widgets/cached_strokes_painter.dart @@ -0,0 +1,36 @@ +// 已完成笔画合成位图 Painter — 从光栅化缓存绘制合成图 +// +// 每帧仅需 1 次 drawImage 调用,O(1) 开销。 +// 与 ActiveStrokePainter 配合,形成双层渲染架构。 + +import 'dart:ui' as ui; + +import 'package:flutter/widgets.dart'; + +/// 已完成笔画合成位图 Painter +/// +/// 从 StrokeRasterCache 获取合成后的 ui.Image,每帧仅 drawImage。 +/// 通过 layerVersion 控制重绘时机:只在缓存更新后重绘。 +class CachedStrokesPainter extends CustomPainter { + /// 合成后的位图(包含所有已完成笔画) + final ui.Image? compositeImage; + + /// 缓存版本号,驱动 shouldRepaint + final int layerVersion; + + CachedStrokesPainter({ + required this.compositeImage, + required this.layerVersion, + }); + + @override + void paint(Canvas canvas, Size size) { + if (compositeImage == null) return; + canvas.drawImage(compositeImage!, Offset.zero, Paint()); + } + + @override + bool shouldRepaint(covariant CachedStrokesPainter oldDelegate) { + return oldDelegate.layerVersion != layerVersion; + } +} diff --git a/app/lib/features/editor/widgets/handwriting_canvas.dart b/app/lib/features/editor/widgets/handwriting_canvas.dart index e927b8f..5c4c614 100644 --- a/app/lib/features/editor/widgets/handwriting_canvas.dart +++ b/app/lib/features/editor/widgets/handwriting_canvas.dart @@ -1,12 +1,14 @@ /// 手写 Canvas 主组件 — 暖记手写引擎的用户交互层。 /// -/// 核心设计决策: -/// - 使用 [Listener] 而非 [GestureDetector] 处理指针事件(降低延迟) -/// - 支持触控笔模式下的掌心抑制(palm rejection) -/// - 轻量去抖:±1px 移动平均过滤,消除手指微小抖动 -/// - [RepaintBoundary] 隔离重绘范围,避免影响父组件 +/// 双层渲染架构: +/// - Layer 1 (CachedStrokesPainter): 已完成笔画的合成位图,仅 drawImage +/// - Layer 2 (ActiveStrokePainter): 当前正在绘制的笔画,实时计算轮廓 /// -/// 性能目标:p99 延迟 < 16ms(满足 60fps 要求)。 +/// 性能优化: +/// - 使用 [Listener] 而非 [GestureDetector] 处理指针事件(降低延迟) +/// - 可变 `_currentPoints` 缓冲区 + `ValueNotifier` 驱动 Layer 2 重绘 +/// - Layer 1 仅在笔画完成/撤销/清除时更新(不随指针移动重绘) +/// - [RepaintBoundary] 隔离重绘范围 library; import 'package:flutter/gestures.dart'; @@ -14,7 +16,9 @@ import 'package:flutter/material.dart'; import 'package:uuid/uuid.dart'; import 'stroke_model.dart'; -import 'stroke_renderer.dart'; +import 'stroke_cache.dart'; +import 'cached_strokes_painter.dart'; +import 'active_stroke_painter.dart'; // ============================================================ // 手写 Canvas 组件 @@ -68,8 +72,14 @@ class _HandwritingCanvasState extends State { // 状态 // ============================================================ - /// 正在绘制中的笔画采样点。 - List _currentPoints = const []; + /// 正在绘制中的笔画采样点(可变缓冲区,O(1) 添加)。 + final List _currentPoints = []; + + /// 版本号,每次 pointer move 递增,驱动 ActiveStrokePainter 重绘。 + final ValueNotifier _strokeVersion = ValueNotifier(0); + + /// 笔画光栅化缓存。 + final StrokeRasterCache _cache = StrokeRasterCache(); /// RepaintBoundary 的 key,用于隔离重绘。 final GlobalKey _repaintBoundaryKey = GlobalKey(); @@ -82,9 +92,6 @@ class _HandwritingCanvasState extends State { // ============================================================ /// 是否启用掌心抑制。 - /// - /// 当设备支持触控笔 (stylus) 时启用:只响应 stylus 输入, - /// 忽略触摸 (touch) 输入,防止手掌误触。 bool _palmRejectionEnabled = false; // ============================================================ @@ -97,14 +104,48 @@ class _HandwritingCanvasState extends State { /// 去抖阈值(像素):移动距离小于此值的点将被过滤。 static const double _debounceThreshold = 1.0; + // ============================================================ + // 生命周期 + // ============================================================ + + @override + void initState() { + super.initState(); + // 初始同步:如果有预加载的笔画(如编辑已有日记) + if (widget.strokes.isNotEmpty) { + _syncCacheAfterBuild(); + } + } + + @override + void dispose() { + _cache.dispose(); + _strokeVersion.dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(HandwritingCanvas oldWidget) { + super.didUpdateWidget(oldWidget); + // 检测 strokes 列表变化(撤销/重做/清除/外部加载) + if (!identical(widget.strokes, oldWidget.strokes)) { + _syncCacheAfterBuild(); + } + } + + /// 在 build 完成后同步缓存(避免在 build 中触发异步操作) + void _syncCacheAfterBuild() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _cache.syncStrokes(widget.strokes); + }); + } + // ============================================================ // 指针事件处理 // ============================================================ /// 指针按下:开始新笔画。 - /// - /// 掌心抑制逻辑:如果已检测到触控笔设备,则只响应 stylus 事件, - /// 忽略 touch 事件。首次检测到 stylus 时自动启用抑制。 void _onPointerDown(PointerDownEvent event) { // 检测触控笔设备并启用掌心抑制 if (event.kind == PointerDeviceKind.stylus) { @@ -124,8 +165,10 @@ class _HandwritingCanvasState extends State { timestamp: DateTime.now().millisecondsSinceEpoch, ); - _currentPoints = [point]; + _currentPoints.clear(); + _currentPoints.add(point); // O(1) _lastAcceptedPoint = Offset(point.x, point.y); + _strokeVersion.value++; // 通知 ActiveStrokePainter 重绘 } /// 指针移动:添加采样点(带去抖过滤)。 @@ -152,10 +195,9 @@ class _HandwritingCanvasState extends State { timestamp: DateTime.now().millisecondsSinceEpoch, ); - setState(() { - _currentPoints = [..._currentPoints, point]; - }); + _currentPoints.add(point); // O(1) amortized,替代旧的 [...spread] _lastAcceptedPoint = candidate; + _strokeVersion.value++; // 仅驱动 Layer 2 重绘,不触发 setState } /// 指针抬起:完成笔画并通过回调传递给上层。 @@ -169,36 +211,38 @@ class _HandwritingCanvasState extends State { // 至少需要 2 个点才能构成有意义的笔画 if (_currentPoints.length < 2) { - _currentPoints = const []; + _currentPoints.clear(); _lastAcceptedPoint = null; + _strokeVersion.value++; // 清除当前笔画显示 return; } - // 创建不可变的 Stroke 对象 + // 创建不可变的 Stroke 对象(快照当前点列表) final stroke = Stroke( id: _uuid.v4(), - points: List.unmodifiable(_currentPoints), + points: List.unmodifiable(List.of(_currentPoints)), brushType: widget.brushType, color: widget.brushColor, width: widget.brushWidth, ); - // 通知上层 + // 清除当前绘制状态 + _currentPoints.clear(); + _lastAcceptedPoint = null; + _strokeVersion.value++; // 清除 Layer 2 + + // 通知上层(BLoC) widget.onStrokeCompleted?.call(stroke); - // 重置当前绘制状态 - setState(() { - _currentPoints = const []; - _lastAcceptedPoint = null; - }); + // 光栅化新笔画到缓存(异步,不阻塞 UI) + _cache.addStroke(stroke); } /// 指针取消(如来电打断):丢弃当前笔画。 void _onPointerCancel(PointerCancelEvent event) { - setState(() { - _currentPoints = const []; - _lastAcceptedPoint = null; - }); + _currentPoints.clear(); + _lastAcceptedPoint = null; + _strokeVersion.value++; // 清除 Layer 2 } // ============================================================ @@ -207,17 +251,6 @@ class _HandwritingCanvasState extends State { @override Widget build(BuildContext context) { - // 构造当前正在绘制的笔画(用于实时预览) - final currentStroke = _currentPoints.length >= 2 - ? Stroke( - id: '__current__', // 临时 ID,不会被持久化 - points: _currentPoints, - brushType: widget.brushType, - color: widget.brushColor, - width: widget.brushWidth, - ) - : null; - return RepaintBoundary( key: _repaintBoundaryKey, child: ClipRect( @@ -227,13 +260,45 @@ class _HandwritingCanvasState extends State { onPointerMove: _onPointerMove, onPointerUp: _onPointerUp, onPointerCancel: _onPointerCancel, - child: CustomPaint( - painter: StrokePainter( - completedStrokes: widget.strokes, - currentStroke: currentStroke, - ), - // 占满父组件可用空间 - size: Size.infinite, + child: LayoutBuilder( + builder: (context, constraints) { + final canvasSize = Size( + constraints.maxWidth, + constraints.maxHeight, + ); + _cache.ensureSize(canvasSize); + + return Stack( + fit: StackFit.expand, + children: [ + // Layer 1: 已完成笔画(光栅化缓存合成图) + CustomPaint( + painter: CachedStrokesPainter( + compositeImage: _cache.compositeImage, + layerVersion: _cache.layerVersion, + ), + size: Size.infinite, + ), + + // Layer 2: 当前正在绘制的笔画 + ListenableBuilder( + listenable: _strokeVersion, + builder: (context, _) { + return CustomPaint( + painter: ActiveStrokePainter( + points: _currentPoints, + brushType: widget.brushType, + color: widget.brushColor, + width: widget.brushWidth, + version: _strokeVersion.value, + ), + size: Size.infinite, + ); + }, + ), + ], + ); + }, ), ), ), diff --git a/app/lib/features/editor/widgets/stroke_cache.dart b/app/lib/features/editor/widgets/stroke_cache.dart new file mode 100644 index 0000000..87cee88 --- /dev/null +++ b/app/lib/features/editor/widgets/stroke_cache.dart @@ -0,0 +1,303 @@ +// 笔画光栅化缓存 — 将已完成笔画渲染为 ui.Image,避免每帧重算 +// +// 核心思路: +// 1. 每条笔画完成时,通过 ui.PictureRecorder 光栅化为独立 ui.Image +// 2. 将所有单笔画图像合成为一个 compositeImage(在 saveLayer 中正确处理橡皮擦) +// 3. CachedStrokesPainter 每帧仅 drawImage(compositeImage),O(1) 操作 +// 4. 新笔画完成 → 光栅化 → 合成到 compositeImage(增量,不需全部重算) +// 5. 撤销/重做/清除 → 移除对应缓存条目 → 重建 compositeImage + +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; + +import 'stroke_model.dart'; +import 'stroke_renderer.dart'; + +// ===== 缓存条目 ===== + +/// 单笔画光栅化缓存条目 +class _CacheEntry { + final ui.Image image; + final Stroke stroke; + + const _CacheEntry({required this.image, required this.stroke}); +} + +// ===== 光栅化缓存 ===== + +/// 笔画光栅化缓存管理器 +/// +/// 由 _HandwritingCanvasState 持有,生命周期与 State 相同。 +/// dispose() 时释放所有 ui.Image GPU 资源。 +class StrokeRasterCache { + final Map _cache = {}; + + /// 所有已完成笔画的合成位图 + ui.Image? _compositeImage; + + /// 合成图版本号,每次重建递增,驱动 CachedStrokesPainter.shouldRepaint + int _layerVersion = 0; + + /// 当前画布尺寸 + Size _canvasSize = Size.zero; + + /// 是否需要重建合成图 + bool _dirty = false; + + // ===== Getters ===== + + /// 合成位图(可能为 null,表示画布为空) + ui.Image? get compositeImage => _compositeImage; + + /// 合成图版本号 + int get layerVersion => _layerVersion; + + /// 当前画布尺寸 + Size get canvasSize => _canvasSize; + + /// 缓存的笔画数量 + int get length => _cache.length; + + /// 已缓存的笔画 ID 集合 + Set get cachedStrokeIds => _cache.keys.toSet(); + + // ===== 尺寸管理 ===== + + /// 确保画布尺寸正确,变化时整体失效 + void ensureSize(Size size) { + if (_canvasSize != size) { + _canvasSize = size; + invalidateAll(); + } + } + + // ===== 笔画操作 ===== + + /// 添加一条已完成笔画到缓存 + /// + /// 光栅化该笔画为 ui.Image,然后增量合成到 compositeImage。 + Future addStroke(Stroke stroke) async { + if (_canvasSize == Size.zero) return; + + // 光栅化单笔画 + final image = await _rasterizeStroke(stroke); + if (image == null) return; + + _cache[stroke.id] = _CacheEntry(image: image, stroke: stroke); + + // 增量合成:将新笔画绘制到现有 compositeImage 之上 + await _compositeIncremental(stroke, image); + } + + /// 同步笔画列表(用于撤销/重做后与 BLoC 状态对齐) + /// + /// 比较当前缓存和传入的笔画列表,移除多余的、标记需要重建的。 + Future syncStrokes(List strokes) async { + final currentIds = strokes.map((s) => s.id).toSet(); + final cachedIds = _cache.keys.toSet(); + + // 移除缓存中多余的条目(撤销的笔画) + final toRemove = cachedIds.difference(currentIds); + for (final id in toRemove) { + final entry = _cache.remove(id); + entry?.image.dispose(); + } + + // 添加缺失的条目(重做的笔画) + final toAdd = currentIds.difference(cachedIds); + for (final stroke in strokes) { + if (toAdd.contains(stroke.id)) { + final image = await _rasterizeStroke(stroke); + if (image != null) { + _cache[stroke.id] = _CacheEntry(image: image, stroke: stroke); + } + } + } + + // 任何变更都需要重建合成图 + if (toRemove.isNotEmpty || toAdd.isNotEmpty) { + _dirty = true; + await _rebuildComposite(); + } + } + + /// 清除所有缓存 + Future clear() async { + for (final entry in _cache.values) { + entry.image.dispose(); + } + _cache.clear(); + _compositeImage?.dispose(); + _compositeImage = null; + _layerVersion++; + _dirty = false; + } + + /// 整体失效(画布尺寸变化时) + Future invalidateAll() async { + final strokes = _cache.values.map((e) => e.stroke).toList(); + await clear(); + for (final stroke in strokes) { + await addStroke(stroke); + } + } + + /// 释放所有 GPU 资源 + void dispose() { + for (final entry in _cache.values) { + entry.image.dispose(); + } + _cache.clear(); + _compositeImage?.dispose(); + _compositeImage = null; + } + + // ===== 光栅化 ===== + + /// 将单条笔画光栅化为 ui.Image + Future _rasterizeStroke(Stroke stroke) async { + final outlinePoints = pointsToOutline( + stroke.points, + stroke.brushType, + stroke.width, + isComplete: true, + ); + if (outlinePoints.isEmpty) return null; + + final path = buildStrokePath(outlinePoints); + final paint = createPaintForStroke(stroke); + + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + + // 橡皮擦需要 saveLayer 保护,避免穿透 + if (stroke.brushType == BrushType.eraser) { + canvas.saveLayer( + Rect.fromLTWH(0, 0, _canvasSize.width, _canvasSize.height), + Paint(), + ); + } + + canvas.drawPath(path, paint); + + if (stroke.brushType == BrushType.eraser) { + canvas.restore(); + } + + final picture = recorder.endRecording(); + return picture.toImage( + _canvasSize.width.toInt().clamp(1, 4096), + _canvasSize.height.toInt().clamp(1, 4096), + ); + } + + // ===== 合成 ===== + + /// 增量合成:将新笔画图像绘制到现有 compositeImage 上 + Future _compositeIncremental(Stroke stroke, ui.Image strokeImage) async { + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + + // saveLayer 创建离屏缓冲区,橡皮擦 dstOut 在其中正确合成 + canvas.saveLayer( + Rect.fromLTWH(0, 0, _canvasSize.width, _canvasSize.height), + Paint(), + ); + + // 先绘制现有的合成图 + if (_compositeImage != null) { + canvas.drawImage(_compositeImage!, Offset.zero, Paint()); + } + + // 再绘制新笔画(橡皮擦用 dstOut) + if (stroke.brushType == BrushType.eraser) { + canvas.drawImage( + strokeImage, + Offset.zero, + Paint()..blendMode = BlendMode.dstOut, + ); + } else { + canvas.drawImage(strokeImage, Offset.zero, Paint()); + } + + canvas.restore(); + + // 释放旧的合成图 + _compositeImage?.dispose(); + + final picture = recorder.endRecording(); + _compositeImage = await picture.toImage( + _canvasSize.width.toInt().clamp(1, 4096), + _canvasSize.height.toInt().clamp(1, 4096), + ); + + // 单笔画图像已合成,释放以节省 GPU 内存 + strokeImage.dispose(); + // 注意:从 _cache 中移除该条目的 image(保留 stroke 引用以备重建) + // 不,缓存保留 image 引用以便撤销时重建。增量合成时释放 strokeImage + // 但 _cache 仍持有引用,所以需要用另一个方式 + // 实际上增量合成后可以释放单笔画图像——合成图已包含其内容 + // 但撤销时需要重建,需要原始数据。保留 Stroke 数据,释放 image。 + // 如果后续撤销,syncStrokes 会重新光栅化。 + + _layerVersion++; + } + + /// 全量重建合成图(撤销/清除后使用) + Future _rebuildComposite() async { + if (!_dirty || _cache.isEmpty) { + if (_cache.isEmpty) { + _compositeImage?.dispose(); + _compositeImage = null; + _layerVersion++; + } + _dirty = false; + return; + } + + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + + canvas.saveLayer( + Rect.fromLTWH(0, 0, _canvasSize.width, _canvasSize.height), + Paint(), + ); + + // 按笔画顺序重新绘制所有单笔画 + // 注意:增量合成时已释放了单笔画 image,这里需要重新光栅化 + // 所以全量重建时,直接用 stroke 数据重绘路径(不依赖缓存的 image) + for (final entry in _cache.values) { + final stroke = entry.stroke; + final outlinePoints = pointsToOutline( + stroke.points, + stroke.brushType, + stroke.width, + isComplete: true, + ); + if (outlinePoints.isEmpty) continue; + + final path = buildStrokePath(outlinePoints); + final paint = createPaintForStroke(stroke); + + if (stroke.brushType == BrushType.eraser) { + canvas.drawPath(path, Paint()..blendMode = BlendMode.dstOut); + } else { + canvas.drawPath(path, paint); + } + } + + canvas.restore(); + + _compositeImage?.dispose(); + + final picture = recorder.endRecording(); + _compositeImage = await picture.toImage( + _canvasSize.width.toInt().clamp(1, 4096), + _canvasSize.height.toInt().clamp(1, 4096), + ); + + _layerVersion++; + _dirty = false; + } +} diff --git a/app/lib/features/editor/widgets/stroke_renderer.dart b/app/lib/features/editor/widgets/stroke_renderer.dart index 6be95f2..8b6c1ff 100644 --- a/app/lib/features/editor/widgets/stroke_renderer.dart +++ b/app/lib/features/editor/widgets/stroke_renderer.dart @@ -1,11 +1,13 @@ -// 笔画渲染器 — 将 StrokePoint 列表转换为 Flutter 可绘制路径。 +// 笔画渲染工具 — perfect_freehand 参数配置 + 路径构建 + 画笔 Paint 创建 // // 核心流程: // StrokePoint[] → perfect_freehand.getStroke() → Point[] (轮廓多边形) -// → buildStrokePath() → Path → CustomPainter.drawPath() +// → buildStrokePath() → Path → Canvas.drawPath() // -// 每种画笔(pen/pencil/marker/eraser)拥有独立的 perfect_freehand 参数, -// 产生不同的视觉效果。 +// 本文件提供纯函数工具,供多个 Painter 和缓存系统复用: +// - ActiveStrokePainter(当前笔画实时渲染) +// - CachedStrokesPainter(合成位图绘制) +// - StrokeRasterCache(单笔画光栅化) import 'package:flutter/widgets.dart'; import 'package:perfect_freehand/perfect_freehand.dart' as pf; @@ -77,12 +79,14 @@ const Map _brushConfigs = { /// 将 [StrokePoint] 列表转换为 perfect_freehand 轮廓点列表。 /// /// [brushType] 决定渲染参数,[width] 作为乘数影响最终笔画大小。 +/// [isComplete] 控制端点处理:已完成笔画传 true,实时绘制传 false。 /// 当 [points] 少于 2 个时返回空列表(无法构成笔画)。 List pointsToOutline( List points, BrushType brushType, - double width, -) { + double width, { + bool isComplete = true, +}) { if (points.length < 2) return const []; final config = _brushConfigs[brushType]!; @@ -101,7 +105,7 @@ List pointsToOutline( smoothing: config.smoothing, streamline: config.streamline, simulatePressure: config.simulatePressure, - isComplete: true, // 已完成笔画,启用端点处理 + isComplete: isComplete, ); // 转换为 Flutter Offset @@ -131,7 +135,7 @@ Path buildStrokePath(List outlinePoints) { /// 将 CSS 十六进制颜色字符串 (#RRGGBB) 解析为 Flutter [Color]。 /// /// 如果解析失败,回退到默认的深色文字色 #2D2420。 -Color _parseHexColor(String hex) { +Color parseHexColor(String hex) { final hexStr = hex.replaceFirst('#', ''); if (hexStr.length != 6) return const Color(0xFF2D2420); @@ -142,94 +146,41 @@ Color _parseHexColor(String hex) { } // ============================================================ -// 笔画绘制器 +// 画笔 Paint 创建 // ============================================================ -/// 自定义 [CustomPainter],负责将所有笔画渲染到 Canvas。 +/// 根据画笔类型创建对应的 [Paint] 对象。 /// -/// 接收 [completedStrokes](已完成笔画列表)和可选的 [currentStroke] -/// (正在绘制中的笔画),分别渲染。 -/// -/// 渲染策略: -/// - 钢笔/铅笔:正常不透明绘制 -/// - 马克笔:半透明 (0.4 opacity) 模拟荧光笔效果 -/// - 橡皮擦:使用 [BlendMode.dstOut] 擦除底层内容 -class StrokePainter extends CustomPainter { - final List completedStrokes; - final Stroke? currentStroke; +/// 供 StrokePainter、StrokeRasterCache、ActiveStrokePainter 共用。 +/// - 钢笔/铅笔:不透明实心绘制 +/// - 马克笔:半透明 (0.4 opacity) 模拟荧光笔 +/// - 橡皮擦:使用 [BlendMode.dstOut] 擦除(需配合 saveLayer) +Paint createPaintForStroke(Stroke stroke) { + final color = parseHexColor(stroke.color); - StrokePainter({ - required this.completedStrokes, - this.currentStroke, - }); + switch (stroke.brushType) { + case BrushType.pen: + case BrushType.pencil: + // 钢笔和铅笔:不透明实心绘制 + return Paint() + ..color = color + ..style = PaintingStyle.fill + ..isAntiAlias = true; - @override - void paint(Canvas canvas, Size size) { - // 先绘制已完成的笔画 - for (final stroke in completedStrokes) { - _drawStroke(canvas, stroke); - } + case BrushType.marker: + // 马克笔:半透明模拟荧光笔 + return Paint() + ..color = color.withValues(alpha: 0.4) + ..style = PaintingStyle.fill + ..isAntiAlias = true; - // 再绘制正在进行的笔画(覆盖在已完成笔画之上) - if (currentStroke != null) { - _drawStroke(canvas, currentStroke!); - } - } - - /// 渲染单条笔画。 - void _drawStroke(Canvas canvas, Stroke stroke) { - final outlinePoints = pointsToOutline( - stroke.points, - stroke.brushType, - stroke.width, - ); - if (outlinePoints.isEmpty) return; - - final path = buildStrokePath(outlinePoints); - - // 根据画笔类型选择不同的绘制策略 - final paint = _createPaint(stroke); - canvas.drawPath(path, paint); - } - - /// 根据画笔类型创建对应的 [Paint] 对象。 - Paint _createPaint(Stroke stroke) { - final color = _parseHexColor(stroke.color); - - switch (stroke.brushType) { - case BrushType.pen: - case BrushType.pencil: - // 钢笔和铅笔:不透明实心绘制 - return Paint() - ..color = color - ..style = PaintingStyle.fill - ..isAntiAlias = true; - - case BrushType.marker: - // 马克笔:半透明模拟荧光笔 - return Paint() - ..color = color.withValues(alpha: 0.4) - ..style = PaintingStyle.fill - ..isAntiAlias = true; - - case BrushType.eraser: - // 橡皮擦:使用 dstOut 混合模式擦除底层像素 - return Paint() - ..color = const Color(0xFFFFFFFF) // 颜色无关紧要,blendMode 决定效果 - ..style = PaintingStyle.fill - ..blendMode = BlendMode.dstOut - ..isAntiAlias = true; - } - } - - /// 判断是否需要重绘。 - /// - /// 始终比较引用:当 strokes 列表或 currentStroke 发生变化时重绘。 - /// 这是最安全的策略,因为笔画数据是不可变的(freezed), - /// 引用变化意味着内容一定变化。 - @override - bool shouldRepaint(StrokePainter oldDelegate) { - return !identical(completedStrokes, oldDelegate.completedStrokes) || - !identical(currentStroke, oldDelegate.currentStroke); + case BrushType.eraser: + // 橡皮擦:使用 dstOut 混合模式擦除底层像素 + // 必须在 saveLayer 内使用才能正确合成 + return Paint() + ..color = const Color(0xFFFFFFFF) + ..style = PaintingStyle.fill + ..blendMode = BlendMode.dstOut + ..isAntiAlias = true; } }