Files
nj/wiki/handwriting-engine.md
iven d1a07229e2 docs: 项目 Wiki 知识库 — 7 文件覆盖架构/手写/数据/前端/后端/技术债
新增 wiki/ 知识库 (遵循 HMS wiki-methodology.md 5 节结构):
- index.md (84 行) — 症状导航 13 条 + 模块索引 + 系统数据流
- architecture.md (120 行) — 基座剥离 7 耦合点 + Feature Flag + PIPL 合规
- handwriting-engine.md (124 行) — 双层 Canvas + O(1) 点缓冲 + 光栅化缓存
- data-layer.md (127 行) — Isar + SyncEngine 离线同步 + 踩坑记录
- frontend.md (118 行) — 16 模块地图 + BLoC 注入链 + 设计系统
- erp-diary.md (101 行) — 15 Entity / 10 Service / 8 Handler + API 端点

新增 docs/:
- tech-debt-board.md (110 行) — 10 条技术债 + 偿还优先级排名

其他更新:
- .gitignore: 添加 .understand-anything/ (待初始化)
- CLAUDE.md §9: 添加 wiki 参考文档链接
2026-06-01 15:08:21 +08:00

4.3 KiB
Raw Blame History

title, updated, status, tags
title updated status tags
手写引擎 2026-06-01 active
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) 点缓冲

// 旧方案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) 点缓冲