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

@@ -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<EditorBloc>().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)

View File

@@ -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<int> _strokeVersion。
/// 每次 pointer move 递增 version触发此 Painter 重绘。
/// 仅渲染当前正在绘制的笔画,已完成笔画由 CachedStrokesPainter 处理。
class ActiveStrokePainter extends CustomPainter {
/// 当前笔画的采样点(直接引用可变缓冲区,不拷贝)
final List<StrokePoint> 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;
}
}

View File

@@ -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;
}
}

View File

@@ -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<HandwritingCanvas> {
// 状态
// ============================================================
/// 正在绘制中的笔画采样点。
List<StrokePoint> _currentPoints = const [];
/// 正在绘制中的笔画采样点可变缓冲区O(1) 添加)
final List<StrokePoint> _currentPoints = [];
/// 版本号,每次 pointer move 递增,驱动 ActiveStrokePainter 重绘。
final ValueNotifier<int> _strokeVersion = ValueNotifier(0);
/// 笔画光栅化缓存。
final StrokeRasterCache _cache = StrokeRasterCache();
/// RepaintBoundary 的 key用于隔离重绘。
final GlobalKey _repaintBoundaryKey = GlobalKey();
@@ -82,9 +92,6 @@ class _HandwritingCanvasState extends State<HandwritingCanvas> {
// ============================================================
/// 是否启用掌心抑制。
///
/// 当设备支持触控笔 (stylus) 时启用:只响应 stylus 输入,
/// 忽略触摸 (touch) 输入,防止手掌误触。
bool _palmRejectionEnabled = false;
// ============================================================
@@ -97,14 +104,48 @@ class _HandwritingCanvasState extends State<HandwritingCanvas> {
/// 去抖阈值(像素):移动距离小于此值的点将被过滤。
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<HandwritingCanvas> {
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<HandwritingCanvas> {
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<HandwritingCanvas> {
// 至少需要 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<HandwritingCanvas> {
@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<HandwritingCanvas> {
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,
);
},
),
],
);
},
),
),
),

View File

@@ -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<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然后增量合成到 compositeImage。
Future<void> 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<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 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<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
Future<ui.Image?> _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<void> _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<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(),
);
// 按笔画顺序重新绘制所有单笔画
// 注意:增量合成时已释放了单笔画 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;
}
}

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;
}
}