// 笔画光栅化缓存 — 将已完成笔画渲染为 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; /// BBox 偏移量 — 光栅化时裁剪的起点,合成时用于定位 final Offset offset; const _CacheEntry({required this.image, required this.stroke, required this.offset}); } // ===== 光栅化缓存 ===== /// 笔画光栅化缓存管理器 /// /// 由 _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(仅 BBox 区域),然后增量合成到 compositeImage。 Future addStroke(Stroke stroke) async { if (_canvasSize == Size.zero) return; // 光栅化单笔画(BBox 裁剪) final result = await _rasterizeStroke(stroke); if (result == null) return; _cache[stroke.id] = _CacheEntry( image: result.image, stroke: stroke, offset: result.offset, ); // 增量合成:将新笔画绘制到现有 compositeImage 之上 await _compositeIncremental(stroke, result.image, result.offset); } /// 同步笔画列表(用于撤销/重做后与 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 result = await _rasterizeStroke(stroke); if (result != null) { _cache[stroke.id] = _CacheEntry( image: result.image, stroke: stroke, offset: result.offset, ); } } } // 任何变更都需要重建合成图 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 — 仅光栅化 BBox 区域(性能优化 8b-M02) /// /// 计算笔画的包围盒 (bounding box),仅对该区域光栅化, /// 大幅减少 GPU 内存占用(短笔画从全画布 4096×4096 降到实际尺寸)。 /// 返回 null 表示笔画无有效点。 Future<({ui.Image image, Offset offset})?> _rasterizeStroke(Stroke stroke) async { final outlinePoints = pointsToOutline( stroke.points, stroke.brushType, stroke.width, isComplete: true, ); if (outlinePoints.isEmpty) return null; // 计算笔画包围盒 double minX = double.infinity, minY = double.infinity; double maxX = double.negativeInfinity, maxY = double.negativeInfinity; for (final p in outlinePoints) { if (p.dx < minX) minX = p.dx; if (p.dy < minY) minY = p.dy; if (p.dx > maxX) maxX = p.dx; if (p.dy > maxY) maxY = p.dy; } // 添加边距(抗锯齿 + 笔触溢出) const padding = 4.0; final bboxLeft = (minX - padding).clamp(0.0, _canvasSize.width); final bboxTop = (minY - padding).clamp(0.0, _canvasSize.height); final bboxRight = (maxX + padding).clamp(0.0, _canvasSize.width); final bboxBottom = (maxY + padding).clamp(0.0, _canvasSize.height); final bboxWidth = (bboxRight - bboxLeft).clamp(1.0, 4096.0); final bboxHeight = (bboxBottom - bboxTop).clamp(1.0, 4096.0); final path = buildStrokePath(outlinePoints); final paint = createPaintForStroke(stroke); final recorder = ui.PictureRecorder(); final canvas = Canvas(recorder); // 平移坐标系,使 BBox 左上角对齐 (0, 0) canvas.translate(-bboxLeft, -bboxTop); // 橡皮擦需要 saveLayer 保护,避免穿透 if (stroke.brushType == BrushType.eraser) { canvas.saveLayer( Rect.fromLTWH(0, 0, bboxWidth, bboxHeight), Paint(), ); } canvas.drawPath(path, paint); if (stroke.brushType == BrushType.eraser) { canvas.restore(); } final picture = recorder.endRecording(); final image = await picture.toImage( bboxWidth.toInt().clamp(1, 4096), bboxHeight.toInt().clamp(1, 4096), ); return (image: image, offset: Offset(bboxLeft, bboxTop)); } // ===== 合成 ===== /// 增量合成:将新笔画图像绘制到现有 compositeImage 上 /// /// strokeImage 是 BBox 裁剪后的图像,offset 是其原始位置偏移。 Future _compositeIncremental(Stroke stroke, ui.Image strokeImage, Offset offset) 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),使用 BBox offset 定位 if (stroke.brushType == BrushType.eraser) { canvas.drawImage( strokeImage, offset, Paint()..blendMode = BlendMode.dstOut, ); } else { canvas.drawImage(strokeImage, offset, 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), ); // 注意:不在此处 dispose strokeImage! // _cache 和此方法持有同一 image 引用,提前 dispose 会导致 // syncStrokes/clear/dispose 时 use-after-dispose(审计 ID: 8b-R01)。 // 单笔画 image 由缓存统一管理生命周期(移除/清除/销毁时释放)。 _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(), ); // 按笔画顺序重新绘制所有单笔画 // 直接从 stroke 数据重绘路径,确保与增量合成结果一致 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; } }