--- title: 手写引擎 updated: 2026-06-01 status: active tags: [handwriting, performance, canvas, perfect-freehand] --- # 手写引擎 > 从 [[index]] 导航。关联: [[data-layer]] [[frontend]] 暖记的**核心价值** — 保留用户真实笔迹。目标:<16ms 延迟(p99),在 Samsung Tab A9 / iPad 10th 上流畅运行。 ## 1. 设计决策 ### Q: 为什么用 Listener 不用 GestureDetector? GestureDetector 内部有手势竞技场(gesture arena),额外延迟约 5-8ms。Listener 直接接收 PointerDownEvent/PointerMoveEvent,跳过竞技场,延迟最低。 ### Q: 为什么双层 Canvas 架构? 单层 Canvas 每帧需要重绘全部 N 条笔画,复杂度 O(N×P)。双层架构将已完成笔画光栅化为 ui.Image 位图,每帧只绘制当前活跃笔画,复杂度降至 O(P_current)。 ### Q: 为什么选 perfect_freehand? 支持压力感知、速度相关的变宽笔画,开箱即用的高质量笔迹渲染。4 种画笔(钢笔/铅笔/马克笔/橡皮擦)通过不同参数配置实现。 ### Q: 笔画存储策略? HandwritingStroke 作为独立 SeaORM Entity,大字段(points JSON)隔离,日记列表查询时延迟加载。前端 Isar 中笔画序列化为 handwriting_ref 元素,固定 ID `${journalId}_strokes`。 ## 2. 关键文件 + 数据流 ### 核心文件 | 文件 | 行数 | 职责 | |------|------|------| | `features/editor/widgets/handwriting_canvas.dart` | 307 | Listener + 双层 Stack + 掌心抑制 | | `features/editor/widgets/stroke_cache.dart` | 303 | StrokeRasterCache 光栅化 + 合成 | | `features/editor/widgets/active_stroke_painter.dart` | 35 | 实时绘制当前笔画 | | `features/editor/widgets/cached_strokes_painter.dart` | 35 | drawImage 绘制缓存位图 | | `features/editor/widgets/stroke_renderer.dart` | ~120 | 4 种画笔渲染(pen/pencil/marker/eraser) | | `features/editor/widgets/stroke_model.dart` | 112 | Stroke/StrokePoint 数据模型 | ### 数据流 ``` PointerDown/Move/Up Events │ (Listener, not GestureDetector) ▼ StrokePoint (x, y, pressure, timestamp) │ accumulate ▼ Stroke (id, points[], brushType, color, width) │ ValueNotifier → 触发重绘 ▼ ActiveStrokePainter ──── 实时绘制当前笔画 │ onStrokeCompleted ▼ StrokeRasterCache.addStroke() │ toImage() 光栅化 ▼ compositeImage (ui.Image) ──── 所有完成笔画合成位图 │ ▼ CachedStrokesPainter ──── drawImage 绘制缓存 ``` ## 3. 代码逻辑 ### O(1) 点缓冲 ```dart // 旧方案:O(N²) — 每次 rebuild 都 spread 整个列表 final points = [..._allPoints, newPoint]; // 新方案:O(1) — 可变缓冲区 + ValueNotifier _currentPoints.add(newPoint); _pointsNotifier.value = _currentPoints; ``` ### 橡皮擦实现 ``` saveLayer() // 保存当前画布状态 drawPath(eraserPath, BlendMode.dstOut) // 用 dstOut 混合模式"擦除" restore() // 恢复,不穿透背景 ``` ### 模式切换 ``` 旧方案:if (isDrawingMode) Canvas() else ElementLayer() — 销毁重建 新方案:IgnorePointer(ignoring: !isDrawingMode) — 不销毁,只控制交互 ``` ### 不变量 ⚡ **shouldRepaint 守卫** — CachedStrokesPainter 仅在 compositeImage 引用变化时重绘 ⚡ **_currentPoints 可变缓冲** — 不创建新 List,直接 add + ValueNotifier 通知 ⚡ **掌心抑制** — 通过 PointerDeviceKind 过滤,仅处理 touch 事件 ## 4. 活跃问题 + 陷阱 | 问题 | 级别 | 状态 | 说明 | |------|------|------|------| | toImage() 同步阻塞 | HIGH | 待修 | 光栅化在主线程,大笔画可能卡 UI | | 画布尺寸变化缓存失效 | MEDIUM | 待修 | 屏幕旋转时需平滑过渡 | | 橡皮擦视觉反馈 | LOW | 待优化 | 擦除区域无实时预览 | ### 历史教训 - 初版用 GestureDetector,延迟约 20ms → 改用 Listener 降至 <16ms - 初版 if/else 切换模式会销毁 Canvas → IgnorePointer 保持 Widget 树稳定 - 单层 Canvas 100+ 笔画时明显卡顿 → 双层 + 光栅化缓存解决 ## 5. 变更记录 | 日期 | 变更 | |------|------| | 2026-06-01 | 初始创建 — 双层架构、性能优化记录、活跃问题 | | 2026-06-01 | 性能优化提交 (e07da7a):双层 Canvas + 光栅化缓存 + O(1) 点缓冲 |