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

125 lines
4.3 KiB
Markdown
Raw Permalink 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.
---
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) 点缓冲 |