Repository navigation
perf(import-export): share unchanged paint strokes across history steps - #897
Merged
Merged
Conversation
Importing a state history parsed every paint stroke again for every history step, although each step that leaves the strokes alone receives the very same raw stroke list. A draft with two drawings of 7504 points each and 900 undo steps took about 500 MB more memory and 8 s to open on an iPhone 12 Pro, enough to get a larger draft killed by iOS. PaintLayer.fromMap now parses a raw stroke list once and hands every step copies of that parse, and PaintedModel.copy shares the points with the original instead of copying them. Points are only ever replaced as a whole, so sharing them is safe; erasedOffsets stays a copy because the partial eraser adds to it in place.
The global Expando kept every parse for as long as its raw map lived, so a map whose strokes were changed in place and imported again (or passed to Layer.fromMap again) returned the old strokes. The cache now lives only while ImportStateHistory.fromMap runs; other Layer.fromMap calls parse as before.
hm21
marked this pull request as ready for review
October 20, 2026 09:08
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Problem. Opening a state history that holds a large drawing and a long undo history took hundreds of MB and several seconds, growing with steps × points. In divine-mobile a tester's app was killed by iOS after they had worked with keyframes on two drawings: the editor sat at 2 GB right after reopening the draft, and any further change (adding music, tapping Done) pushed it over the limit. Deleting one of the two drawings was enough to post.
Cause.
ImportStateHistorymerges each step's layer diff over the step before it and callsLayer.fromMapon the merged map. A step that leaves a paint layer's strokes alone carries noitem/itemsof its own, so the merged map holds the very same raw stroke list as the step before, andPaintLayer.fromMapparsed that list again for every step. Every step got its ownOffsetobjects for every point. Since 14.14.1 (#895) the export writes unchanged strokes only once, so the file stays small (875 KB for two drawings over 1000 steps) while the import grows linearly: about 630 KB per step for 15,000 points. The live history paid a smaller version of the same cost:LayerCopyManagercopies every stroke into each step throughPaintedModel.copy, which copied the point list (8 bytes per point per step).Fix.
ImportStateHistory.fromMapruns,PaintLayer.fromMapremembers the strokes it parsed from a rawitemslist oritemmap, keyed by the identity of that raw value (PaintStrokeImportCache, internal). A later step with the same raw value gets copies of that parse instead of a new parse. The cached strokes themselves are never handed out, so editing a layer cannot change what later steps receive. The cache is dropped when the import returns, so a map changed in place and imported again (or passed toLayer.fromMapdirectly) is parsed again; an earlier revision used a globalExpando, which returned stale strokes in that case.PaintedModel.copysharesoffsetswith the original.offsetswas already documented as replace-only (assigning a new list is what drops the cachedbounds), and nothing in the package changes it in place.erasedOffsetsstays a copy, because the partial eraser adds to it in place (paint_canvas.dart).drawsSameAsnow takes itsidenticalfast path for shared point lists, so an export compares unchanged strokes in constant time instead of point by point.Not changed / trade-offs.
items) still parses them, as before.PaintedModel.offsetsin place, against its documentation, would now also change the copies held by other history steps. The CHANGELOG entry says so.Related Issue: Closes #none
Verification
PaintedModel.copyshares the points and keeps erased areas apart; an import shares the points across steps that keep the strokes, still gives each step stroke objects of its own, and reads the new points in a step that rewrites the strokes. The two sharing tests fail without this change. Two more check that a map changed in place is read again, byLayer.fromMapand by a second import.flutter test: 1008 passed on Flutter 3.47.2. On 3.44.0, with the CI'smaterial_ui/cupertino_uidowngrade,flutter analyze libis clean and the 47 tests of the affected files pass.flutter analyze lib testanddart formatare clean.flutter test, RSS growth):The live history (copies through
LayerCopyManager) went from 16–59 KB to 7–8 KB per step.All 903 steps were restored in both runs.
Type of Change