Files
nj/app/lib/features/editor/widgets/stroke_cache.dart
iven 32a91551c4
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
perf(app): Phase 2 前端性能优化 5 项 — 8b-D01/D02/D03/M02/N01
- 8b-D01: Isar 添加 authorId+dateEpoch 复合索引和 dateEpoch 单独索引
- 8b-D02: getJournals 分页改为 DB 层 .offset().limit() 替代 Dart 层 sublist
- 8b-D03: home_bloc monthCount 改用日期范围独立查询(不受分页限制)
- 8b-M02: 笔画光栅化改为 BBox 裁剪 — 短笔画不再创建全画布尺寸图像
  - _CacheEntry 增加 offset 字段记录 BBox 偏移
  - _rasterizeStroke 计算包围盒 + 4px padding
  - _compositeIncremental 使用 offset 定位
- 8b-N01: SyncEngine enqueue 合并同一资源的操作
  - create+update → create(最新数据)
  - update+update → update(最新数据)
  - update+delete → delete
  - create+delete → 取消(不发送)
- 注意: Isar .g.dart 需运行 build_runner 重新生成
2026-06-03 16:05:11 +08:00

339 lines
9.9 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 笔画光栅化缓存 — 将已完成笔画渲染为 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<String, _CacheEntry> _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<String> get cachedStrokeIds => _cache.keys.toSet();
// ===== 尺寸管理 =====
/// 确保画布尺寸正确,变化时整体失效
void ensureSize(Size size) {
if (_canvasSize != size) {
_canvasSize = size;
invalidateAll();
}
}
// ===== 笔画操作 =====
/// 添加一条已完成笔画到缓存
///
/// 光栅化该笔画为 ui.Image仅 BBox 区域),然后增量合成到 compositeImage。
Future<void> 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<void> syncStrokes(List<Stroke> 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<void> clear() async {
for (final entry in _cache.values) {
entry.image.dispose();
}
_cache.clear();
_compositeImage?.dispose();
_compositeImage = null;
_layerVersion++;
_dirty = false;
}
/// 整体失效(画布尺寸变化时)
Future<void> 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<void> _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<void> _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;
}
}