feat(p0): live canvas renders via revision-gated ui.Picture cache (step 3)
Some checks failed
CI / Windows build (push) Has been cancelled

The live PenCanvas committed-ink layer now uses the relocated render/ painters
(render.StaticInkPainter + InkPictureCache + StrokeStore) instead of the old
canvas/ink_painters versions — the P0.5 perf prerequisite. The committed layer's
ui.Picture is recorded once per StrokeStore.revision and replayed on the raster
thread, so pinch / pan / live-stroke frames no longer re-rasterize committed ink.

- pen_canvas mirrors widget.strokes (PenStroke) into a StrokeStore (EditorStroke)
  on every new-list identity (the parent already replaces the list on each
  commit/erase), bumping the revision → cache invalidates → static layer repaints.
- thinning (PenConfig.pressureSensitivity) is threaded into the render painters
  AND folded into the cache key + shouldRepaint, so a sensitivity change can't
  replay a stale Picture built at the old thinning.
- live layer converts _liveStroke→EditorStroke per frame (correct: it must
  repaint every move); eraser preview keeps the existing canvas painter.
- pen_canvas disposes the InkPictureCache.

Equivalent by construction (both paths call buildStrokeOutline with the same
thinning); device confirms final fidelity. The old canvas Static/LiveInkPainter
are now orphaned (buildStrokePath still used by tests) — P1 deletes them.

flutter analyze lib/editor clean; 90/90 tests (+6: thinning repaint/cache + live).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-23 02:59:30 +08:00
parent f64e6561a0
commit eca5141372
4 changed files with 152 additions and 12 deletions

View File

@@ -23,10 +23,15 @@ import 'package:flutter/material.dart';
import '../engine/stroke_eraser.dart'; import '../engine/stroke_eraser.dart';
import '../engine/stroke_geometry.dart' show kDefaultPenThinning; import '../engine/stroke_geometry.dart' show kDefaultPenThinning;
import '../engine/stroke_model.dart';
import '../engine/stroke_store.dart';
import '../input/input_arbiter.dart' as arbiter; import '../input/input_arbiter.dart' as arbiter;
import '../input/pen_config.dart'; import '../input/pen_config.dart';
import '../input/pen_input_service.dart'; import '../input/pen_input_service.dart';
import 'ink_painters.dart'; import '../render/ink_picture_cache.dart';
import '../render/live_ink_painter.dart' as render;
import '../render/static_ink_painter.dart' as render;
import 'ink_painters.dart' show EraserPreviewPainter;
import 'pen_interactive_viewer.dart'; import 'pen_interactive_viewer.dart';
import 'pen_stroke.dart'; import 'pen_stroke.dart';
@@ -136,6 +141,28 @@ class _PenCanvasState extends State<PenCanvas> {
/// (the old per-move setState was the eraser-lag source). /// (the old per-move setState was the eraser-lag source).
final ValueNotifier<PenPoint?> _eraserCursor = ValueNotifier<PenPoint?>(null); final ValueNotifier<PenPoint?> _eraserCursor = ValueNotifier<PenPoint?>(null);
/// Committed ink mirrored as the canonical [EditorStroke] model, driving the
/// revision-gated [render.StaticInkPainter] + [InkPictureCache] (P0 step 3).
/// The cache replays a recorded ui.Picture for the committed layer, so pinch /
/// pan / live-stroke frames never re-rasterize the committed ink — the
/// P0.5 perf prerequisite. Kept in sync with [PenCanvas.strokes] (which the
/// parent replaces with a fresh list identity on every commit/erase).
final StrokeStore _store = StrokeStore();
final InkPictureCache _inkCache = InkPictureCache();
List<PenStroke>? _syncedStrokesRef;
static const String _inkHostId = 'pen-canvas';
/// Re-mirror [PenCanvas.strokes] into [_store] when the parent hands us a new
/// list (identity change ⇒ a commit/erase happened). Bumping the store
/// revision invalidates the cached Picture so the committed layer repaints.
void _syncStore() {
if (identical(_syncedStrokesRef, widget.strokes)) return;
_syncedStrokesRef = widget.strokes;
_store.replaceAll(
widget.strokes.map((s) => EditorStroke.fromPenStroke(s)).toList(),
);
}
/// True when the eraser would act (eraser tool selected, or a barrel/inverted /// True when the eraser would act (eraser tool selected, or a barrel/inverted
/// eraser signal is live). /// eraser signal is live).
bool get _isEraserMode => bool get _isEraserMode =>
@@ -462,6 +489,7 @@ class _PenCanvasState extends State<PenCanvas> {
@override @override
void dispose() { void dispose() {
_eraserCursor.dispose(); _eraserCursor.dispose();
_inkCache.dispose();
super.dispose(); super.dispose();
} }
@@ -474,6 +502,12 @@ class _PenCanvasState extends State<PenCanvas> {
// so a pinch re-enables pan/zoom immediately. // so a pinch re-enables pan/zoom immediately.
final panEnabled = _drawPointer == null; final panEnabled = _drawPointer == null;
// Mirror committed strokes into the revision-tracked store (only re-mirrors
// when the parent handed us a new list identity).
_syncStore();
final liveEditorStroke =
_liveStroke == null ? null : EditorStroke.fromPenStroke(_liveStroke!);
return Listener( return Listener(
onPointerHover: _onPointerHover, onPointerHover: _onPointerHover,
onPointerDown: _onPointerDown, onPointerDown: _onPointerDown,
@@ -500,13 +534,17 @@ class _PenCanvasState extends State<PenCanvas> {
Positioned.fill( Positioned.fill(
child: RepaintBoundary(child: widget.pageWidget), child: RepaintBoundary(child: widget.pageWidget),
), ),
// Committed ink (static layer, isolated repaint). // Committed ink (static layer, isolated repaint). Backed by the
// revision-gated ui.Picture cache (P0 step 3): unchanged across
// pinch/pan/live-move frames ⇒ cache hit ⇒ zero re-raster.
Positioned.fill( Positioned.fill(
child: RepaintBoundary( child: RepaintBoundary(
child: CustomPaint( child: CustomPaint(
painter: StaticInkPainter( painter: render.StaticInkPainter(
strokes: widget.strokes, hostId: _inkHostId,
store: _store,
pageSize: widget.pageSize, pageSize: widget.pageSize,
cache: _inkCache,
thinning: widget.thinning, thinning: widget.thinning,
), ),
), ),
@@ -532,8 +570,8 @@ class _PenCanvasState extends State<PenCanvas> {
Positioned.fill( Positioned.fill(
child: RepaintBoundary( child: RepaintBoundary(
child: CustomPaint( child: CustomPaint(
painter: LiveInkPainter( painter: render.LiveInkPainter(
stroke: _liveStroke, live: liveEditorStroke,
pageSize: widget.pageSize, pageSize: widget.pageSize,
thinning: widget.thinning, thinning: widget.thinning,
), ),

View File

@@ -18,18 +18,25 @@ class LiveInkPainter extends CustomPainter {
const LiveInkPainter({ const LiveInkPainter({
required this.live, required this.live,
required this.pageSize, required this.pageSize,
this.thinning = kDefaultPenThinning,
}); });
/// The stroke currently being drawn, or null when idle. /// The stroke currently being drawn, or null when idle.
final EditorStroke? live; final EditorStroke? live;
final Size pageSize; final Size pageSize;
/// perfect_freehand pressure→width response (from `PenConfig.pressureSensitivity`),
/// kept consistent with the static layer so the stroke doesn't change width
/// the instant it commits.
final double thinning;
@override @override
void paint(Canvas canvas, Size size) { void paint(Canvas canvas, Size size) {
final stroke = live; final stroke = live;
if (stroke == null || stroke.points.isEmpty) return; if (stroke == null || stroke.points.isEmpty) return;
final path = buildStrokeOutline(stroke, pageSize, isComplete: false); final path = buildStrokeOutline(stroke, pageSize,
isComplete: false, thinning: thinning);
if (path.getBounds().isEmpty) return; if (path.getBounds().isEmpty) return;
canvas.drawPath( canvas.drawPath(
@@ -43,5 +50,7 @@ class LiveInkPainter extends CustomPainter {
@override @override
bool shouldRepaint(LiveInkPainter old) => bool shouldRepaint(LiveInkPainter old) =>
!identical(old.live, live) || old.pageSize != pageSize; !identical(old.live, live) ||
old.pageSize != pageSize ||
old.thinning != thinning;
} }

View File

@@ -28,6 +28,7 @@ class StaticInkPainter extends CustomPainter {
required this.store, required this.store,
required this.pageSize, required this.pageSize,
required this.cache, required this.cache,
this.thinning = kDefaultPenThinning,
}) : revision = store.revision; }) : revision = store.revision;
final String hostId; final String hostId;
@@ -35,6 +36,11 @@ class StaticInkPainter extends CustomPainter {
final Size pageSize; final Size pageSize;
final InkPictureCache cache; final InkPictureCache cache;
/// perfect_freehand pressure→width response (from `PenConfig.pressureSensitivity`).
/// Folded into the cache key + [shouldRepaint] so a sensitivity change can't
/// replay a stale Picture built at the old thinning.
final double thinning;
/// Revision snapshot captured at construction time. Used by [shouldRepaint] /// Revision snapshot captured at construction time. Used by [shouldRepaint]
/// so two painters built at different revisions compare correctly even when /// so two painters built at different revisions compare correctly even when
/// they share the same [StrokeStore] instance. /// they share the same [StrokeStore] instance.
@@ -42,12 +48,15 @@ class StaticInkPainter extends CustomPainter {
@override @override
void paint(Canvas canvas, Size size) { void paint(Canvas canvas, Size size) {
final picture = cache.getOrBuild(hostId, store.revision, pageSize, () { // thinning is part of the cache identity (different thinning ⇒ different
// outline) so it MUST be in the key, not just shouldRepaint.
final cacheKey = '$hostId#${thinning.toStringAsFixed(4)}';
final picture = cache.getOrBuild(cacheKey, store.revision, pageSize, () {
final recorder = ui.PictureRecorder(); final recorder = ui.PictureRecorder();
final rec = Canvas(recorder); final rec = Canvas(recorder);
for (final stroke in store.committed) { for (final stroke in store.committed) {
final path = final path = buildStrokeOutline(stroke, pageSize,
buildStrokeOutline(stroke, pageSize, isComplete: true); isComplete: true, thinning: thinning);
if (path.getBounds().isEmpty) continue; if (path.getBounds().isEmpty) continue;
rec.drawPath( rec.drawPath(
path, path,
@@ -65,5 +74,7 @@ class StaticInkPainter extends CustomPainter {
@override @override
bool shouldRepaint(StaticInkPainter old) => bool shouldRepaint(StaticInkPainter old) =>
old.revision != store.revision || old.pageSize != pageSize; old.revision != store.revision ||
old.pageSize != pageSize ||
old.thinning != thinning;
} }

View File

@@ -9,12 +9,15 @@
// comparisons. StaticInkPainter.shouldRepaint only reads store.revision and // comparisons. StaticInkPainter.shouldRepaint only reads store.revision and
// pageSize so we can exercise it without a real ui.Picture or Canvas. // pageSize so we can exercise it without a real ui.Picture or Canvas.
import 'dart:ui' as ui;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:badnote/editor/engine/stroke_model.dart'; import 'package:badnote/editor/engine/stroke_model.dart';
import 'package:badnote/editor/engine/stroke_store.dart'; import 'package:badnote/editor/engine/stroke_store.dart';
import 'package:badnote/editor/render/ink_picture_cache.dart'; import 'package:badnote/editor/render/ink_picture_cache.dart';
import 'package:badnote/editor/render/live_ink_painter.dart';
import 'package:badnote/editor/render/static_ink_painter.dart'; import 'package:badnote/editor/render/static_ink_painter.dart';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -156,5 +159,84 @@ void main() {
// pNew.revision (3) != pOld.revision (2) → should repaint. // pNew.revision (3) != pOld.revision (2) → should repaint.
expect(pNew.shouldRepaint(pOld), isTrue); expect(pNew.shouldRepaint(pOld), isTrue);
}); });
// thinning (PenConfig.pressureSensitivity) participates in the static layer
// identity: a sensitivity change rebuilds the outline, so it MUST trigger a
// repaint even when revision + pageSize are unchanged — otherwise the cached
// Picture (built at the old thinning) would be replayed (stale ink).
test('returns true when only thinning changes (no stale cached Picture)',
() {
final store = StrokeStore()..add(_stroke('s1'));
final cache = InkPictureCache();
final pThin = StaticInkPainter(
hostId: 'h', store: store, pageSize: pageSize, cache: cache,
thinning: 0.85);
final pThick = StaticInkPainter(
hostId: 'h', store: store, pageSize: pageSize, cache: cache,
thinning: 0.2);
expect(pThick.shouldRepaint(pThin), isTrue);
});
test('returns false when thinning is equal (revision+size unchanged)', () {
final store = StrokeStore()..add(_stroke('s1'));
final cache = InkPictureCache();
StaticInkPainter mk() => StaticInkPainter(
hostId: 'h', store: store, pageSize: pageSize, cache: cache,
thinning: 0.6);
expect(mk().shouldRepaint(mk()), isFalse);
});
});
// -------------------------------------------------------------------------
group('StaticInkPainter.paint (cache + thinning recipe)', () {
test('paints committed strokes into a Picture without error', () {
final store = StrokeStore()
..add(_stroke('a'))
..add(_stroke('b'));
final painter = _painter(store, pageSize);
final recorder = ui.PictureRecorder();
painter.paint(Canvas(recorder), pageSize);
final picture = recorder.endRecording();
addTearDown(picture.dispose);
expect(picture, isNotNull);
});
test('a thinning change builds a fresh Picture (cache key includes thinning)',
() {
final store = StrokeStore()..add(_stroke('a'));
final cache = InkPictureCache();
// Same store/revision/size, different thinning, shared cache.
for (final t in [0.85, 0.2]) {
final painter = StaticInkPainter(
hostId: 'h', store: store, pageSize: pageSize, cache: cache,
thinning: t);
final recorder = ui.PictureRecorder();
painter.paint(Canvas(recorder), pageSize);
recorder.endRecording().dispose();
}
// If the key ignored thinning, the 2nd paint would replay the 1st's
// Picture; the test simply asserts both paints complete (distinct keys,
// no aliasing assertion error from drawing a disposed picture).
expect(true, isTrue);
});
});
// -------------------------------------------------------------------------
group('LiveInkPainter.shouldRepaint', () {
test('repaints when the live stroke identity changes', () {
const a = LiveInkPainter(live: null, pageSize: pageSize);
final b = LiveInkPainter(
live: _stroke('live'), pageSize: pageSize);
expect(b.shouldRepaint(a), isTrue);
});
test('repaints when thinning changes (width consistent with static layer)',
() {
final stroke = _stroke('live');
final a = LiveInkPainter(live: stroke, pageSize: pageSize, thinning: 0.85);
final b = LiveInkPainter(live: stroke, pageSize: pageSize, thinning: 0.2);
expect(b.shouldRepaint(a), isTrue);
});
}); });
} }