Files
nj/app/lib/features/editor/widgets/handwriting_canvas.dart
iven e07da7addb 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
2026-06-01 13:18:36 +08:00

308 lines
9.5 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.
/// 手写 Canvas 主组件 — 暖记手写引擎的用户交互层。
///
/// 双层渲染架构:
/// - Layer 1 (CachedStrokesPainter): 已完成笔画的合成位图,仅 drawImage
/// - Layer 2 (ActiveStrokePainter): 当前正在绘制的笔画,实时计算轮廓
///
/// 性能优化:
/// - 使用 [Listener] 而非 [GestureDetector] 处理指针事件(降低延迟)
/// - 可变 `_currentPoints` 缓冲区 + `ValueNotifier` 驱动 Layer 2 重绘
/// - Layer 1 仅在笔画完成/撤销/清除时更新(不随指针移动重绘)
/// - [RepaintBoundary] 隔离重绘范围
library;
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:uuid/uuid.dart';
import 'stroke_model.dart';
import 'stroke_cache.dart';
import 'cached_strokes_painter.dart';
import 'active_stroke_painter.dart';
// ============================================================
// 手写 Canvas 组件
// ============================================================
/// 手写画布,接收用户输入并实时渲染笔画。
///
/// 使用方式:
/// ```dart
/// HandwritingCanvas(
/// brushType: BrushType.pen,
/// brushColor: '#2D2420',
/// brushWidth: 3.0,
/// strokes: editorState.strokes,
/// onStrokeCompleted: (stroke) => context.read<EditorBloc>().add(
/// StrokeCompleted(stroke),
/// ),
/// )
/// ```
class HandwritingCanvas extends StatefulWidget {
const HandwritingCanvas({
super.key,
this.brushType = BrushType.pen,
this.brushColor = '#2D2420',
this.brushWidth = 3.0,
this.onStrokeCompleted,
this.strokes = const [],
});
/// 当前选中的画笔类型。
final BrushType brushType;
/// 当前画笔颜色CSS 十六进制格式,如 '#2D2420')。
final String brushColor;
/// 当前画笔宽度(基准 3.0)。
final double brushWidth;
/// 笔画完成时的回调,将新笔画传递给上层(通常是 BLoC
final ValueChanged<Stroke>? onStrokeCompleted;
/// 已有的笔画列表(用于重绘历史内容)。
final List<Stroke> strokes;
@override
State<HandwritingCanvas> createState() => _HandwritingCanvasState();
}
class _HandwritingCanvasState extends State<HandwritingCanvas> {
// ============================================================
// 状态
// ============================================================
/// 正在绘制中的笔画采样点可变缓冲区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();
/// UUID 生成器实例。
static const _uuid = Uuid();
// ============================================================
// 掌心抑制
// ============================================================
/// 是否启用掌心抑制。
bool _palmRejectionEnabled = false;
// ============================================================
// 去抖
// ============================================================
/// 上一个有效采样点的坐标(用于去抖计算)。
Offset? _lastAcceptedPoint;
/// 去抖阈值(像素):移动距离小于此值的点将被过滤。
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);
});
}
// ============================================================
// 指针事件处理
// ============================================================
/// 指针按下:开始新笔画。
void _onPointerDown(PointerDownEvent event) {
// 检测触控笔设备并启用掌心抑制
if (event.kind == PointerDeviceKind.stylus) {
_palmRejectionEnabled = true;
}
// 掌心抑制:如果已启用且当前非 stylus 输入,忽略
if (_palmRejectionEnabled && event.kind != PointerDeviceKind.stylus) {
return;
}
// 开始新笔画
final point = StrokePoint(
x: event.localPosition.dx,
y: event.localPosition.dy,
pressure: event.pressure,
timestamp: DateTime.now().millisecondsSinceEpoch,
);
_currentPoints.clear();
_currentPoints.add(point); // O(1)
_lastAcceptedPoint = Offset(point.x, point.y);
_strokeVersion.value++; // 通知 ActiveStrokePainter 重绘
}
/// 指针移动:添加采样点(带去抖过滤)。
void _onPointerMove(PointerMoveEvent event) {
if (_currentPoints.isEmpty) return;
// 掌心抑制
if (_palmRejectionEnabled && event.kind != PointerDeviceKind.stylus) {
return;
}
final candidate = Offset(event.localPosition.dx, event.localPosition.dy);
// 轻量去抖:移动距离 < 阈值时忽略(消除手指微抖)
if (_lastAcceptedPoint != null) {
final distance = (candidate - _lastAcceptedPoint!).distance;
if (distance < _debounceThreshold) return;
}
final point = StrokePoint(
x: candidate.dx,
y: candidate.dy,
pressure: event.pressure,
timestamp: DateTime.now().millisecondsSinceEpoch,
);
_currentPoints.add(point); // O(1) amortized替代旧的 [...spread]
_lastAcceptedPoint = candidate;
_strokeVersion.value++; // 仅驱动 Layer 2 重绘,不触发 setState
}
/// 指针抬起:完成笔画并通过回调传递给上层。
void _onPointerUp(PointerUpEvent event) {
if (_currentPoints.isEmpty) return;
// 掌心抑制
if (_palmRejectionEnabled && event.kind != PointerDeviceKind.stylus) {
return;
}
// 至少需要 2 个点才能构成有意义的笔画
if (_currentPoints.length < 2) {
_currentPoints.clear();
_lastAcceptedPoint = null;
_strokeVersion.value++; // 清除当前笔画显示
return;
}
// 创建不可变的 Stroke 对象(快照当前点列表)
final stroke = Stroke(
id: _uuid.v4(),
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);
// 光栅化新笔画到缓存(异步,不阻塞 UI
_cache.addStroke(stroke);
}
/// 指针取消(如来电打断):丢弃当前笔画。
void _onPointerCancel(PointerCancelEvent event) {
_currentPoints.clear();
_lastAcceptedPoint = null;
_strokeVersion.value++; // 清除 Layer 2
}
// ============================================================
// 构建
// ============================================================
@override
Widget build(BuildContext context) {
return RepaintBoundary(
key: _repaintBoundaryKey,
child: ClipRect(
child: Listener(
behavior: HitTestBehavior.opaque,
onPointerDown: _onPointerDown,
onPointerMove: _onPointerMove,
onPointerUp: _onPointerUp,
onPointerCancel: _onPointerCancel,
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,
);
},
),
],
);
},
),
),
),
);
}
}