perf(app): 手写引擎性能优化 — 双层架构 + 光栅化缓存 + O(1) 点缓冲

性能优化:
- 新建 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
This commit is contained in:
iven
2026-06-01 13:18:36 +08:00
parent 8331db63ba
commit e07da7addb
6 changed files with 573 additions and 153 deletions

View File

@@ -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<BrushType, _BrushConfig> _brushConfigs = {
/// 将 [StrokePoint] 列表转换为 perfect_freehand 轮廓点列表。
///
/// [brushType] 决定渲染参数,[width] 作为乘数影响最终笔画大小。
/// [isComplete] 控制端点处理:已完成笔画传 true实时绘制传 false。
/// 当 [points] 少于 2 个时返回空列表(无法构成笔画)。
List<Offset> pointsToOutline(
List<StrokePoint> 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<Offset> pointsToOutline(
smoothing: config.smoothing,
streamline: config.streamline,
simulatePressure: config.simulatePressure,
isComplete: true, // 已完成笔画,启用端点处理
isComplete: isComplete,
);
// 转换为 Flutter Offset
@@ -131,7 +135,7 @@ Path buildStrokePath(List<Offset> 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<Stroke> 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;
}
}