From a32d3a6e35f2d3a62d54961ff680d9433a6a7a55 Mon Sep 17 00:00:00 2001 From: jozek Date: Wed, 5 Aug 2026 12:02:25 +0200 Subject: [PATCH 1/4] fix(tile_layer): dispose the image handed to a tile pruned during dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `TileImage._onImageLoadSuccess` stored the `ImageInfo` even when the tile was already disposed. `ImageStreamCompleter.setImage` gives every listener its own handle and the listener owns it: a displayed tile passes ownership to `RenderImage`, which disposes it, but a disposed tile never builds one — so the decoded image stays alive for the lifetime of the process. `dispose()` does remove the listener, which is why this is not reachable by simply disposing a tile and then completing its image. It is reachable because `setImage` dispatches over a copy of the listener list ("Make a copy to allow for concurrent modification"): a listener removed from inside that loop is still called. Two tiles resolving equal keys share one completer, and `onLoadComplete` is where tiles get pruned — as already noted in `TileImageManager.reloadImages` — so a tile can be disposed mid-dispatch and handed an image regardless. Unnoticeable with 256x256 tiles (256 KB). With 768x768 RGBA tiles (2.25 MB each) it killed an app on iOS while browsing the map. Adds a regression test that reproduces the dispatch race; it fails on the current code with `Expected: null / Actual: ImageInfo:<[8x8] @ 1.0x>`. --- lib/src/layer/tile_layer/tile_image.dart | 22 +++- test/layer/tile_layer/tile_image_test.dart | 111 +++++++++++++++++++++ 2 files changed, 129 insertions(+), 4 deletions(-) create mode 100644 test/layer/tile_layer/tile_image_test.dart diff --git a/lib/src/layer/tile_layer/tile_image.dart b/lib/src/layer/tile_layer/tile_image.dart index 71fe06a54..2cdaf56b2 100644 --- a/lib/src/layer/tile_layer/tile_image.dart +++ b/lib/src/layer/tile_layer/tile_image.dart @@ -158,12 +158,26 @@ class TileImage extends ChangeNotifier { void _onImageLoadSuccess(ImageInfo imageInfo, bool synchronousCall) { loadError = false; - this.imageInfo = imageInfo; - if (!_disposed) { - _display(); - onLoadComplete(coordinates); + // A disposed tile is never painted, so it never hands its image to a + // `RenderImage` — and `setImage` gives every listener its own handle to + // dispose. Keeping it here leaks the decoded image for the process + // lifetime. + // + // `dispose()` removes the listener, but `setImage` dispatches over a copy + // of the listener list, so a listener removed from inside that loop is + // still called. Tiles resolving equal keys share one completer, and + // `onLoadComplete` is where pruning happens (see the note in + // `TileImageManager.reloadImages`) — so a tile can be disposed + // mid-dispatch and handed an image anyway. See `tile_image_test.dart`. + if (_disposed) { + imageInfo.dispose(); + return; } + + this.imageInfo = imageInfo; + _display(); + onLoadComplete(coordinates); } void _onImageLoadError(Object exception, StackTrace? stackTrace) { diff --git a/test/layer/tile_layer/tile_image_test.dart b/test/layer/tile_layer/tile_image_test.dart new file mode 100644 index 000000000..37a674af4 --- /dev/null +++ b/test/layer/tile_layer/tile_image_test.dart @@ -0,0 +1,111 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// An [ImageProvider] whose image is delivered only when the test says so, and +/// whose key is itself, so that two [TileImage]s resolve to the SAME +/// [ImageStreamCompleter] (which is what [ImageCache] does for equal keys). +class _SharedManualImageProvider + extends ImageProvider<_SharedManualImageProvider> { + _SharedManualImageProvider(this.completer); + + final Completer completer; + + @override + Future<_SharedManualImageProvider> obtainKey( + ImageConfiguration configuration, + ) => + SynchronousFuture<_SharedManualImageProvider>(this); + + @override + ImageStreamCompleter loadImage( + _SharedManualImageProvider key, + ImageDecoderCallback decode, + ) => + OneFrameImageStreamCompleter(completer.future); +} + +TileImage _tile({ + required int x, + required ImageProvider provider, + void Function(TileCoordinates)? onLoadComplete, +}) => + TileImage( + vsync: const TestVSync(), + coordinates: TileCoordinates(x, 0, 0), + imageProvider: provider, + onLoadComplete: onLoadComplete ?? (_) {}, + onLoadError: (_, __, ___) {}, + tileDisplay: const TileDisplay.instantaneous(), + errorImage: null, + cancelLoading: Completer(), + ); + +void main() { + testWidgets( + 'disposes the image handed to a tile pruned during listener dispatch', + (tester) async { + // `runAsync`: decoding needs real async, which the fake clock inside + // `testWidgets` never advances — awaiting it directly hangs the test. + final image = + (await tester.runAsync(() => createTestImage(width: 8, height: 8)))!; + // Measured, not assumed: `createTestImage` may hand back an image that + // already has more than one handle open. + final baseline = image.debugGetOpenHandleStackTraces()!.length; + + final completer = Completer(); + final provider = _SharedManualImageProvider(completer); + + late final TileImage second; + var secondDisposed = false; + + // Two tiles sharing one ImageStreamCompleter, which is what ImageCache + // does whenever two tiles resolve equal keys — e.g. a tile that leaves + // the viewport and comes back while its image is still in flight. + // + // `ImageStreamCompleter.setImage` dispatches over a COPY of its listener + // list ("Make a copy to allow for concurrent modification"), so removing + // a listener from inside that loop does not stop it from being called. + // The first tile's completion runs `onLoadComplete` — which is where + // flutter_map prunes tiles — disposing the second tile mid-dispatch. + final first = _tile( + x: 0, + provider: provider, + onLoadComplete: (_) { + if (secondDisposed) return; + secondDisposed = true; + second.dispose(); + }, + ); + second = _tile(x: 1, provider: provider); + + first.load(); + second.load(); + + completer.complete(ImageInfo(image: image)); + await tester.pump(); + + expect( + secondDisposed, + isTrue, + reason: 'the scenario under test never happened', + ); + expect( + second.imageInfo, + isNull, + reason: 'a disposed tile must not keep an image nobody will paint', + ); + expect( + image.debugGetOpenHandleStackTraces(), + hasLength(baseline + 1), + reason: 'only the live tile may still hold a handle; the one handed to ' + 'the disposed tile must be released', + ); + + first.dispose(); + }, + ); +} From 823e8a878c62988a092ed579f028db2b1fc29ad2 Mon Sep 17 00:00:00 2001 From: jozek Date: Thu, 6 Aug 2026 14:14:45 +0200 Subject: [PATCH 2/4] fix(tile_layer): free a frame that no build handed to a RenderImage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ownership of a tile's decoded image transfers at BUILD time: `RawImage` passes the raw `ui.Image` to `RenderImage`, which disposes it when it is replaced or unmounted. That is why `TileImage.dispose()` never freed `imageInfo` — by then the render object owns it. The model assumes ONE frame per tile. That holds for static tiles, but a completer emitting a second frame (progressive tiles: a base frame, then a composed one) overwrites `imageInfo` before any build has to happen. When both frames land within the same frame budget — the common case on a fast device — the first handle never reaches a `RenderImage` and nothing frees it. Measured on an iPhone with 768x768 composed tiles: ~0.9 leaked handles per tile, growing linearly with tiles browsed, and invisible to `ImageCache`, which reported ~25 live images while 3336 `ui.Image` objects were alive. Two app kills within four minutes. The tile now tracks whether its handle was passed on (`Tile` marks it while building `RawImage`) and frees it only while it is still its own — on frame replacement and on dispose. Freeing a handle the render object owns would be a double free, so the flag is what makes this safe rather than lucky. Two tests, both counting real open handles rather than asserting on code: one for the leaking path, one for the mirror case where the widget already took the handle and the tile must keep its hands off. --- lib/src/layer/tile_layer/tile.dart | 14 +++- lib/src/layer/tile_layer/tile_image.dart | 41 +++++++++ test/layer/tile_layer/tile_image_test.dart | 98 ++++++++++++++++++++++ 3 files changed, 151 insertions(+), 2 deletions(-) diff --git a/lib/src/layer/tile_layer/tile.dart b/lib/src/layer/tile_layer/tile.dart index a713c51d4..907109248 100644 --- a/lib/src/layer/tile_layer/tile.dart +++ b/lib/src/layer/tile_layer/tile.dart @@ -1,3 +1,5 @@ +import 'dart:ui' as ui show Image; + import 'package:flutter/widgets.dart'; import 'package:flutter_map/flutter_map.dart'; @@ -89,7 +91,7 @@ class _TileState extends State { ); } else if (widget.tileImage.animation == null) { return RawImage( - image: widget.tileImage.imageInfo?.image, + image: _handOff(widget.tileImage), fit: BoxFit.fill, opacity: widget.tileImage.opacity == 1 ? null @@ -99,7 +101,7 @@ class _TileState extends State { return AnimatedBuilder( animation: widget.tileImage.animation!, builder: (context, child) => RawImage( - image: widget.tileImage.imageInfo?.image, + image: _handOff(widget.tileImage), fit: BoxFit.fill, opacity: widget.tileImage.animation, ), @@ -107,3 +109,11 @@ class _TileState extends State { } } } + +/// Passes the tile's decoded handle to `RawImage` and records the ownership +/// transfer — from here on `RenderImage` disposes it, so [TileImage] must not. +ui.Image? _handOff(TileImage tileImage) { + final image = tileImage.imageInfo?.image; + if (image != null) tileImage.markImageHandedToRenderObject(); + return image; +} diff --git a/lib/src/layer/tile_layer/tile_image.dart b/lib/src/layer/tile_layer/tile_image.dart index 2cdaf56b2..a3a6558ae 100644 --- a/lib/src/layer/tile_layer/tile_image.dart +++ b/lib/src/layer/tile_layer/tile_image.dart @@ -56,9 +56,42 @@ class TileImage extends ChangeNotifier { /// Some meta data of the image. ImageInfo? imageInfo; + + /// Whether [imageInfo]'s handle was passed to a `RawImage`. + /// + /// Ownership of the decoded image is transferred at build time: `RawImage` + /// hands the raw `ui.Image` to `RenderImage`, which disposes it when it is + /// replaced or unmounted. That is why [dispose] does not free [imageInfo] — + /// by then it belongs to the render object. + /// + /// The model assumes ONE frame per tile, which holds for static tiles. A + /// completer that emits a second frame (progressive tiles: a base frame + /// followed by a composed one) overwrites [imageInfo] before any build has + /// to happen. When both frames land within the same frame budget — the + /// common case on a fast device — the first handle is never handed to a + /// `RenderImage`, so nothing disposes it. + /// + /// Measured on an iPhone with 768x768 composed tiles: ~0.9 leaked handles + /// per tile, growing linearly with the number of tiles browsed and invisible + /// to `ImageCache` (which reported ~25 live images against 3336 alive + /// `ui.Image` objects). + bool _imageHandedToRenderObject = false; + ImageStream? _imageStream; late ImageStreamListener _listener; + /// Records that [imageInfo]'s handle reached a `RawImage`, so the render + /// object owns it from now on and this class must not free it. + void markImageHandedToRenderObject() => _imageHandedToRenderObject = true; + + /// Frees [imageInfo] only while it is still ours — i.e. no build has passed + /// it on. Disposing a handle already owned by a `RenderImage` would be a + /// double free. + void _disposeUnhandedImage() { + if (!_imageHandedToRenderObject) imageInfo?.dispose(); + _imageHandedToRenderObject = false; + } + /// Create a new object for a tile image. TileImage({ required this.vsync, @@ -175,6 +208,10 @@ class TileImage extends ChangeNotifier { return; } + // A previous frame whose handle never reached a `RawImage` is ours to + // free — see [_imageHandedToRenderObject]. Without this, every tile that + // emits two frames within one frame budget leaks its first handle. + _disposeUnhandedImage(); this.imageInfo = imageInfo; _display(); onLoadComplete(coordinates); @@ -256,6 +293,10 @@ class TileImage extends ChangeNotifier { _animationController?.dispose(); _imageStream?.removeListener(_listener); + // Same ownership rule as on frame replacement: a handle that no build ever + // passed to a `RenderImage` would otherwise never be freed. Tiles pruned + // between load and paint hit this path. + _disposeUnhandedImage(); super.dispose(); } diff --git a/test/layer/tile_layer/tile_image_test.dart b/test/layer/tile_layer/tile_image_test.dart index 37a674af4..f7f6b8f25 100644 --- a/test/layer/tile_layer/tile_image_test.dart +++ b/test/layer/tile_layer/tile_image_test.dart @@ -108,4 +108,102 @@ void main() { first.dispose(); }, ); + + testWidgets( + 'frees the first frame when a second frame replaces it before any build', + (tester) async { + // Ownership of a tile's decoded image transfers at BUILD time: `RawImage` + // hands the handle to `RenderImage`, which frees it. With one frame per + // tile that always holds. A completer emitting a second frame can + // overwrite the first before any build happened — and then nobody frees + // the first handle. Measured on device: ~0.9 leaked handles per tile, + // invisible to `ImageCache`. + final images = (await tester.runAsync(() async => [ + await createTestImage(width: 8, height: 8), + await createTestImage(width: 8, height: 8), + ]))!; + final first = images[0]; + final second = images[1]; + final baseline = first.debugGetOpenHandleStackTraces()!.length; + + final completer = _MultiFrameCompleter(); + final tile = _tile(x: 0, provider: _MultiFrameProvider(completer)); + tile.load(); + + completer.emit(ImageInfo(image: first)); + expect( + first.debugGetOpenHandleStackTraces(), + hasLength(baseline + 1), + reason: 'the tile holds the first frame — the scenario needs that', + ); + + // No pump: the second frame lands within the same frame budget, which is + // the common case on a fast device. + completer.emit(ImageInfo(image: second)); + + expect( + first.debugGetOpenHandleStackTraces(), + hasLength(baseline), + reason: 'no build ever passed the first frame to a RenderImage, so the ' + 'tile still owned it and must free it on replacement', + ); + + tile.dispose(); + }, + ); + + testWidgets( + 'keeps a frame the widget already handed to the render object', + (tester) async { + // The mirror case: freeing a handle a `RenderImage` owns would be a + // double free. This is what makes the fix above safe rather than lucky. + final images = (await tester.runAsync(() async => [ + await createTestImage(width: 8, height: 8), + await createTestImage(width: 8, height: 8), + ]))!; + final first = images[0]; + final baseline = first.debugGetOpenHandleStackTraces()!.length; + + final completer = _MultiFrameCompleter(); + final tile = _tile(x: 0, provider: _MultiFrameProvider(completer)); + tile.load(); + + completer.emit(ImageInfo(image: first)); + // A build happened: `Tile` passed the handle on. + tile.markImageHandedToRenderObject(); + completer.emit(ImageInfo(image: images[1])); + + expect( + first.debugGetOpenHandleStackTraces(), + hasLength(baseline + 1), + reason: 'the render object owns this handle now and disposes it when ' + 'it is replaced — the tile must keep its hands off', + ); + + tile.dispose(); + }, + ); +} + +/// A completer the test drives frame by frame — progressive tiles (a base +/// frame followed by a composed one) emit more than once. +class _MultiFrameCompleter extends ImageStreamCompleter { + void emit(ImageInfo info) => setImage(info); +} + +class _MultiFrameProvider extends ImageProvider<_MultiFrameProvider> { + _MultiFrameProvider(this.completer); + + final _MultiFrameCompleter completer; + + @override + Future<_MultiFrameProvider> obtainKey(ImageConfiguration configuration) => + SynchronousFuture<_MultiFrameProvider>(this); + + @override + ImageStreamCompleter loadImage( + _MultiFrameProvider key, + ImageDecoderCallback decode, + ) => + completer; } From a77d528e49a5f769a11762992b12a54dac9c757a Mon Sep 17 00:00:00 2001 From: jozek Date: Thu, 6 Aug 2026 15:06:19 +0200 Subject: [PATCH 3/4] =?UTF-8?q?fix(tile=5Flayer):=20the=20tile=20always=20?= =?UTF-8?q?owns=20its=20handle=20=E2=80=94=20RawImage=20clones,=20never=20?= =?UTF-8?q?takes=20over?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flag-based fix (823e8a8) rested on a false premise. It assumed ownership of the decoded image transfers to `RenderImage` at build time, so it freed the handle only when NO build had taken it. Verified against Flutter sources: `RawImage` CLONES the image for its render object — both `createRenderObject` and `updateRenderObject` pass `image?.clone()`. The render object frees its own clone; the tile's handle never stops being the tile's. Consequence: the flag exempted exactly the PAINTED frames, so every painted frame still leaked one handle. Measured on device after 823e8a8: ~0.3 leaked handles per tile — matching the fraction of tiles that emit two frames — linear, no plateau, invisible to ImageCache. GC finalizers reclaim such handles eventually, which is why 256 KB default tiles get away with the upstream model and 2.25 MB tiles do not. The correct model is simpler than the flag: the tile frees its own handle, always — on frame replacement and on dispose. Dispose also nulls the field, because dispose can race the layer rebuild and a straggler build must see null (paint nothing) rather than clone a disposed image. Caught by a new full-cycle test (TileImage → Tile → RawImage → RenderImage) counting open handles after complete teardown — the pure-TileImage tests could not see it, because the bug lived in what the widget integration does NOT do with the handle. Sensitivity: reverting both files to 823e8a8 fails all three cases of that test. ⚠️ Test hygiene that mattered: `createTestImage` caches by size and returns clones of ONE shared image, making two "independent" handle counters move in lockstep. `cache: false` + distinct sizes are load-bearing in these tests. --- lib/src/layer/tile_layer/tile.dart | 13 +- lib/src/layer/tile_layer/tile_image.dart | 61 +++--- test/layer/tile_layer/tile_image_test.dart | 78 ++++--- .../tile_layer/tile_widget_handles_test.dart | 195 ++++++++++++++++++ 4 files changed, 257 insertions(+), 90 deletions(-) create mode 100644 test/layer/tile_layer/tile_widget_handles_test.dart diff --git a/lib/src/layer/tile_layer/tile.dart b/lib/src/layer/tile_layer/tile.dart index 907109248..330dbb38f 100644 --- a/lib/src/layer/tile_layer/tile.dart +++ b/lib/src/layer/tile_layer/tile.dart @@ -1,5 +1,3 @@ -import 'dart:ui' as ui show Image; - import 'package:flutter/widgets.dart'; import 'package:flutter_map/flutter_map.dart'; @@ -91,7 +89,7 @@ class _TileState extends State { ); } else if (widget.tileImage.animation == null) { return RawImage( - image: _handOff(widget.tileImage), + image: widget.tileImage.imageInfo?.image, fit: BoxFit.fill, opacity: widget.tileImage.opacity == 1 ? null @@ -101,7 +99,7 @@ class _TileState extends State { return AnimatedBuilder( animation: widget.tileImage.animation!, builder: (context, child) => RawImage( - image: _handOff(widget.tileImage), + image: widget.tileImage.imageInfo?.image, fit: BoxFit.fill, opacity: widget.tileImage.animation, ), @@ -110,10 +108,3 @@ class _TileState extends State { } } -/// Passes the tile's decoded handle to `RawImage` and records the ownership -/// transfer — from here on `RenderImage` disposes it, so [TileImage] must not. -ui.Image? _handOff(TileImage tileImage) { - final image = tileImage.imageInfo?.image; - if (image != null) tileImage.markImageHandedToRenderObject(); - return image; -} diff --git a/lib/src/layer/tile_layer/tile_image.dart b/lib/src/layer/tile_layer/tile_image.dart index a3a6558ae..8e1b87ca1 100644 --- a/lib/src/layer/tile_layer/tile_image.dart +++ b/lib/src/layer/tile_layer/tile_image.dart @@ -55,42 +55,25 @@ class TileImage extends ChangeNotifier { DateTime? loadFinishedAt; /// Some meta data of the image. - ImageInfo? imageInfo; - - /// Whether [imageInfo]'s handle was passed to a `RawImage`. - /// - /// Ownership of the decoded image is transferred at build time: `RawImage` - /// hands the raw `ui.Image` to `RenderImage`, which disposes it when it is - /// replaced or unmounted. That is why [dispose] does not free [imageInfo] — - /// by then it belongs to the render object. /// - /// The model assumes ONE frame per tile, which holds for static tiles. A - /// completer that emits a second frame (progressive tiles: a base frame - /// followed by a composed one) overwrites [imageInfo] before any build has - /// to happen. When both frames land within the same frame budget — the - /// common case on a fast device — the first handle is never handed to a - /// `RenderImage`, so nothing disposes it. + /// Ownership: this handle is ALWAYS the tile's own. `RawImage` clones the + /// image for its `RenderImage` (`createRenderObject`/`updateRenderObject` + /// both pass `image?.clone()`), so handing it to the widget tree never + /// transfers ownership — the render object frees its own clone, and this + /// one stays with the tile until the tile frees it: on frame replacement + /// and on [dispose]. /// - /// Measured on an iPhone with 768x768 composed tiles: ~0.9 leaked handles - /// per tile, growing linearly with the number of tiles browsed and invisible - /// to `ImageCache` (which reported ~25 live images against 3336 alive - /// `ui.Image` objects). - bool _imageHandedToRenderObject = false; + /// The previous model ("the render object takes over at build time") was + /// wrong and leaked exactly one handle per painted frame: measured on an + /// iPhone with 768x768 tiles as ~0.3-0.9 leaked handles per tile, invisible + /// to `ImageCache`, with the process eventually killed by jetsam. GC + /// finalizers reclaim such handles EVENTUALLY, which is why small default + /// tiles get away with it — 2.25 MB tiles do not. + ImageInfo? imageInfo; ImageStream? _imageStream; late ImageStreamListener _listener; - /// Records that [imageInfo]'s handle reached a `RawImage`, so the render - /// object owns it from now on and this class must not free it. - void markImageHandedToRenderObject() => _imageHandedToRenderObject = true; - - /// Frees [imageInfo] only while it is still ours — i.e. no build has passed - /// it on. Disposing a handle already owned by a `RenderImage` would be a - /// double free. - void _disposeUnhandedImage() { - if (!_imageHandedToRenderObject) imageInfo?.dispose(); - _imageHandedToRenderObject = false; - } /// Create a new object for a tile image. TileImage({ @@ -208,10 +191,10 @@ class TileImage extends ChangeNotifier { return; } - // A previous frame whose handle never reached a `RawImage` is ours to - // free — see [_imageHandedToRenderObject]. Without this, every tile that - // emits two frames within one frame budget leaks its first handle. - _disposeUnhandedImage(); + // The previous frame's handle is ours — `RawImage` clones for the render + // object, so nothing downstream frees this one. Without this line every + // PAINTED frame leaks its handle for the lifetime of the process. + this.imageInfo?.dispose(); this.imageInfo = imageInfo; _display(); onLoadComplete(coordinates); @@ -293,10 +276,12 @@ class TileImage extends ChangeNotifier { _animationController?.dispose(); _imageStream?.removeListener(_listener); - // Same ownership rule as on frame replacement: a handle that no build ever - // passed to a `RenderImage` would otherwise never be freed. Tiles pruned - // between load and paint hit this path. - _disposeUnhandedImage(); + // Same ownership rule as on frame replacement: the handle is the tile's + // own, so the tile frees it. Nulling the field keeps a straggler build + // (dispose can race the layer rebuild) from cloning a disposed image — + // `RawImage` treats null as "paint nothing". + imageInfo?.dispose(); + imageInfo = null; super.dispose(); } diff --git a/test/layer/tile_layer/tile_image_test.dart b/test/layer/tile_layer/tile_image_test.dart index f7f6b8f25..854e97311 100644 --- a/test/layer/tile_layer/tile_image_test.dart +++ b/test/layer/tile_layer/tile_image_test.dart @@ -110,21 +110,21 @@ void main() { ); testWidgets( - 'frees the first frame when a second frame replaces it before any build', + 'replacing a frame frees the previous handle — painted or not', (tester) async { - // Ownership of a tile's decoded image transfers at BUILD time: `RawImage` - // hands the handle to `RenderImage`, which frees it. With one frame per - // tile that always holds. A completer emitting a second frame can - // overwrite the first before any build happened — and then nobody frees - // the first handle. Measured on device: ~0.9 leaked handles per tile, - // invisible to `ImageCache`. - final images = (await tester.runAsync(() async => [ - await createTestImage(width: 8, height: 8), - await createTestImage(width: 8, height: 8), - ]))!; - final first = images[0]; - final second = images[1]; - final baseline = first.debugGetOpenHandleStackTraces()!.length; + // Ownership truth (checked against Flutter sources, not assumed): + // `RawImage` CLONES the image for its `RenderImage` — both + // `createRenderObject` and `updateRenderObject` pass `image?.clone()`. + // So the render object only ever frees its own clone, and the tile's + // handle stays the tile's forever. The earlier model ("the render + // object takes over at build time") was wrong and leaked one handle + // per painted frame — measured on device as ~0.3/tile after the + // flag-based fix, because the flag exempted exactly the painted frames. + final first = + (await tester.runAsync(() => createTestImage(width: 8, height: 8, cache: false)))!; + final second = + (await tester.runAsync(() => createTestImage(width: 9, height: 9, cache: false)))!; + final base1 = first.debugGetOpenHandleStackTraces()!.length; final completer = _MultiFrameCompleter(); final tile = _tile(x: 0, provider: _MultiFrameProvider(completer)); @@ -132,20 +132,19 @@ void main() { completer.emit(ImageInfo(image: first)); expect( - first.debugGetOpenHandleStackTraces(), - hasLength(baseline + 1), - reason: 'the tile holds the first frame — the scenario needs that', + first.debugGetOpenHandleStackTraces()!.length, + greaterThan(base1), + reason: 'the tile must hold the first frame — the scenario needs that', ); - // No pump: the second frame lands within the same frame budget, which is - // the common case on a fast device. completer.emit(ImageInfo(image: second)); expect( first.debugGetOpenHandleStackTraces(), - hasLength(baseline), - reason: 'no build ever passed the first frame to a RenderImage, so the ' - 'tile still owned it and must free it on replacement', + hasLength(base1 - 1), + reason: 'on replacement the tile must free its own handle to the ' + 'previous frame; the completer freed the original at setImage, ' + 'hence one below baseline', ); tile.dispose(); @@ -153,34 +152,31 @@ void main() { ); testWidgets( - 'keeps a frame the widget already handed to the render object', + 'dispose frees the current handle and nulls the field', (tester) async { - // The mirror case: freeing a handle a `RenderImage` owns would be a - // double free. This is what makes the fix above safe rather than lucky. - final images = (await tester.runAsync(() async => [ - await createTestImage(width: 8, height: 8), - await createTestImage(width: 8, height: 8), - ]))!; - final first = images[0]; - final baseline = first.debugGetOpenHandleStackTraces()!.length; + final image = + (await tester.runAsync(() => createTestImage(width: 8, height: 8, cache: false)))!; + final baseline = image.debugGetOpenHandleStackTraces()!.length; final completer = _MultiFrameCompleter(); final tile = _tile(x: 0, provider: _MultiFrameProvider(completer)); tile.load(); + completer.emit(ImageInfo(image: image)); - completer.emit(ImageInfo(image: first)); - // A build happened: `Tile` passed the handle on. - tile.markImageHandedToRenderObject(); - completer.emit(ImageInfo(image: images[1])); + tile.dispose(); expect( - first.debugGetOpenHandleStackTraces(), - hasLength(baseline + 1), - reason: 'the render object owns this handle now and disposes it when ' - 'it is replaced — the tile must keep its hands off', + image.debugGetOpenHandleStackTraces(), + hasLength(baseline), + reason: 'the tile owned its handle to the very end — dispose must ' + 'free it (the completer still holds the original it was given)', + ); + expect( + tile.imageInfo, + isNull, + reason: 'dispose can race the layer rebuild; a straggler build must ' + 'see null (paint nothing), not a disposed image it would clone', ); - - tile.dispose(); }, ); } diff --git a/test/layer/tile_layer/tile_widget_handles_test.dart b/test/layer/tile_layer/tile_widget_handles_test.dart new file mode 100644 index 000000000..231ceeeb5 --- /dev/null +++ b/test/layer/tile_layer/tile_widget_handles_test.dart @@ -0,0 +1,195 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Handle accounting through the FULL widget cycle: TileImage → Tile → +/// RawImage → RenderImage. +/// +/// This is the test that CAUGHT the second leak (0806): the flag-based fix +/// assumed `RawImage` hands ownership to `RenderImage` at build time. It does +/// not — `RawImage` CLONES for the render object, so the tile's own handle +/// stayed open forever on every painted frame (~0.3/tile on device after the +/// flag fix, because the flag exempted exactly the painted frames). +/// +/// Pure-TileImage tests can't see this: the bug lived in what the widget +/// integration does NOT do with the handle. Hence full cycle here, counting +/// OPEN HANDLES on the raw images after complete teardown — not code presence. +/// +/// ⚠️ `cache: false` and DIFFERENT sizes are load-bearing: `createTestImage` +/// caches by size and returns clones of one shared image, which makes two +/// "independent" counters move in lockstep and hides per-frame attribution. +class _DrivenCompleter extends ImageStreamCompleter { + void emit(ImageInfo info) => setImage(info); +} + +class _DrivenProvider extends ImageProvider<_DrivenProvider> { + _DrivenProvider(this.completer); + + final _DrivenCompleter completer; + + @override + Future<_DrivenProvider> obtainKey(ImageConfiguration configuration) => + SynchronousFuture<_DrivenProvider>(this); + + @override + ImageStreamCompleter loadImage( + _DrivenProvider key, + ImageDecoderCallback decode, + ) => + completer; +} + +TileImage _tileImage(ImageProvider provider) => TileImage( + vsync: const TestVSync(), + coordinates: const TileCoordinates(0, 0, 0), + imageProvider: provider, + onLoadComplete: (_) {}, + onLoadError: (_, __, ___) {}, + tileDisplay: const TileDisplay.instantaneous(), + errorImage: null, + cancelLoading: Completer(), + ); + +Widget _host(TileImage tileImage) => Directionality( + textDirection: TextDirection.ltr, + child: Stack( + children: [ + Tile( + scaledTileDimension: 256, + currentPixelOrigin: Offset.zero, + tileImage: tileImage, + tileBuilder: null, + positionCoordinates: const TileCoordinates(0, 0, 0), + ), + ], + ), + ); + +void main() { + testWidgets( + 'two frames with a build in between: after unmount every handle is closed', + (tester) async { + final images = (await tester.runAsync(() async => [ + await createTestImage(width: 8, height: 8, cache: false), + await createTestImage(width: 9, height: 9, cache: false), + ]))!; + final base1 = images[0].debugGetOpenHandleStackTraces()!.length; + final base2 = images[1].debugGetOpenHandleStackTraces()!.length; + + final completer = _DrivenCompleter(); + final tileImage = _tileImage(_DrivenProvider(completer)); + tileImage.load(); + + await tester.pumpWidget(_host(tileImage)); + + // Frame 1, then a real build (the widget hands the handle on), then + // frame 2, then another build. + completer.emit(ImageInfo(image: images[0])); + await tester.pump(); + completer.emit(ImageInfo(image: images[1])); + await tester.pump(); + + // Tile leaves the tree (prune) and the TileImage is disposed — the full + // real-life teardown. + await tester.pumpWidget(const SizedBox()); + tileImage.dispose(); + // The completer outlives the tile in reality only while ImageCache holds + // it; here nobody does, so its current image must go too. + + expect( + images[0].debugGetOpenHandleStackTraces(), + hasLength(base1 - 1), + reason: 'frame 1: the completer freed the original at setImage and ' + 'the tile freed its clone on replacement — after teardown NOTHING ' + 'may hold it (this exact assertion caught the flag-based leak)', + ); + expect( + images[1].debugGetOpenHandleStackTraces(), + hasLength(base2), + reason: 'frame 2: the only open handle is the ORIGINAL one, now owned ' + 'by the completer as its current image (ImageCache would own the ' + 'completer in production) — the tile\'s clone must be gone', + ); + }, + ); + + testWidgets( + 'two frames in the SAME frame budget: after unmount every handle is closed', + (tester) async { + final images = (await tester.runAsync(() async => [ + await createTestImage(width: 8, height: 8, cache: false), + await createTestImage(width: 9, height: 9, cache: false), + ]))!; + final base1 = images[0].debugGetOpenHandleStackTraces()!.length; + final base2 = images[1].debugGetOpenHandleStackTraces()!.length; + + final completer = _DrivenCompleter(); + final tileImage = _tileImage(_DrivenProvider(completer)); + tileImage.load(); + await tester.pumpWidget(_host(tileImage)); + + // Both frames before any pump — the fast-device case that leaked. + completer.emit(ImageInfo(image: images[0])); + completer.emit(ImageInfo(image: images[1])); + await tester.pump(); + + await tester.pumpWidget(const SizedBox()); + tileImage.dispose(); + + expect( + images[0].debugGetOpenHandleStackTraces(), + hasLength(base1 - 1), + reason: 'frame 1: no build between frames — same rule, the tile frees ' + 'its own handle on replacement', + ); + expect( + images[1].debugGetOpenHandleStackTraces(), + hasLength(base2), + reason: 'frame 2: only the original handle (completer-owned) remains', + ); + }, + ); + + testWidgets( + 'tile pruned between the frames: after teardown every handle is closed', + (tester) async { + final images = (await tester.runAsync(() async => [ + await createTestImage(width: 8, height: 8, cache: false), + await createTestImage(width: 9, height: 9, cache: false), + ]))!; + final base1 = images[0].debugGetOpenHandleStackTraces()!.length; + final base2 = images[1].debugGetOpenHandleStackTraces()!.length; + + final completer = _DrivenCompleter(); + final tileImage = _tileImage(_DrivenProvider(completer)); + tileImage.load(); + await tester.pumpWidget(_host(tileImage)); + + completer.emit(ImageInfo(image: images[0])); + await tester.pump(); + + // Prune happens NOW — then the (shared, cache-held) completer still + // delivers frame 2 to nobody. + await tester.pumpWidget(const SizedBox()); + tileImage.dispose(); + completer.emit(ImageInfo(image: images[1])); + + expect( + images[0].debugGetOpenHandleStackTraces(), + hasLength(base1 - 1), + reason: 'frame 1 went through a build; teardown must close every ' + 'handle to it', + ); + expect( + images[1].debugGetOpenHandleStackTraces(), + hasLength(base2), + reason: 'frame 2: only the original handle (completer-owned) remains ' + '— delivered to nobody, cloned by nobody', + ); + }, + ); +} From d6a3fe12eaa8946f58492ac632ffe104c949388d Mon Sep 17 00:00:00 2001 From: jozek Date: Thu, 6 Aug 2026 15:19:02 +0200 Subject: [PATCH 4/4] refactor(tile_layer): one frame-driver helper, ownership story anchored once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simplify pass over the leak fix, four angles (reuse / simplification / efficiency / altitude), behavior untouched — the sensitivity check (both lib files reverted to the flag model) still fails all three full-cycle cases. - test_utils/test_frame_driver.dart: ONE DrivenCompleter/DrivenProvider pair and one testTileImage factory replace three byte-identical class pairs and two TileImage factories spread over two files. A constructor change now touches one place. - _SharedManualImageProvider deleted: OneFrame-wrapping a Completer was a strict subset of the driven provider. - The ownership doctrine lives ONCE, on the imageInfo field doc; the replacement and dispose sites state the rule in one line and point there. The story already changed once (the flag model) and needed every copy edited — that class of drift is what this fork patch exists to kill. - The _disposed branch comment no longer speaks the dead model's language ("never hands its image to a RenderImage") — it argues from ownership: after dispose() the owner that would free the handle is gone. - Whitespace orphans of the removed flag mechanism dropped (tile.dart is now untouched by this branch, as it should be — the fix lives entirely in the owner). --- lib/src/layer/tile_layer/tile.dart | 1 - lib/src/layer/tile_layer/tile_image.dart | 20 ++--- test/layer/tile_layer/tile_image_test.dart | 84 +++---------------- .../tile_layer/tile_widget_handles_test.dart | 47 ++--------- test/test_utils/test_frame_driver.dart | 54 ++++++++++++ 5 files changed, 81 insertions(+), 125 deletions(-) create mode 100644 test/test_utils/test_frame_driver.dart diff --git a/lib/src/layer/tile_layer/tile.dart b/lib/src/layer/tile_layer/tile.dart index 330dbb38f..a713c51d4 100644 --- a/lib/src/layer/tile_layer/tile.dart +++ b/lib/src/layer/tile_layer/tile.dart @@ -107,4 +107,3 @@ class _TileState extends State { } } } - diff --git a/lib/src/layer/tile_layer/tile_image.dart b/lib/src/layer/tile_layer/tile_image.dart index 8e1b87ca1..0f8625b9c 100644 --- a/lib/src/layer/tile_layer/tile_image.dart +++ b/lib/src/layer/tile_layer/tile_image.dart @@ -74,7 +74,6 @@ class TileImage extends ChangeNotifier { ImageStream? _imageStream; late ImageStreamListener _listener; - /// Create a new object for a tile image. TileImage({ required this.vsync, @@ -175,10 +174,9 @@ class TileImage extends ChangeNotifier { void _onImageLoadSuccess(ImageInfo imageInfo, bool synchronousCall) { loadError = false; - // A disposed tile is never painted, so it never hands its image to a - // `RenderImage` — and `setImage` gives every listener its own handle to - // dispose. Keeping it here leaks the decoded image for the process - // lifetime. + // After dispose() the owner that would free this handle is gone (dispose + // ran and nulled the field — see the ownership note on [imageInfo]), so + // the handler frees it on the spot. // // `dispose()` removes the listener, but `setImage` dispatches over a copy // of the listener list, so a listener removed from inside that loop is @@ -191,9 +189,8 @@ class TileImage extends ChangeNotifier { return; } - // The previous frame's handle is ours — `RawImage` clones for the render - // object, so nothing downstream frees this one. Without this line every - // PAINTED frame leaks its handle for the lifetime of the process. + // The previous frame's handle is ours to free — see the ownership note + // on [imageInfo]. this.imageInfo?.dispose(); this.imageInfo = imageInfo; _display(); @@ -276,10 +273,9 @@ class TileImage extends ChangeNotifier { _animationController?.dispose(); _imageStream?.removeListener(_listener); - // Same ownership rule as on frame replacement: the handle is the tile's - // own, so the tile frees it. Nulling the field keeps a straggler build - // (dispose can race the layer rebuild) from cloning a disposed image — - // `RawImage` treats null as "paint nothing". + // Same ownership rule as on frame replacement (see [imageInfo]). Nulling + // the field keeps a straggler build — dispose can race the layer rebuild — + // from cloning a disposed image; `RawImage` treats null as "paint nothing". imageInfo?.dispose(); imageInfo = null; super.dispose(); diff --git a/test/layer/tile_layer/tile_image_test.dart b/test/layer/tile_layer/tile_image_test.dart index 854e97311..dd13145d5 100644 --- a/test/layer/tile_layer/tile_image_test.dart +++ b/test/layer/tile_layer/tile_image_test.dart @@ -1,48 +1,10 @@ -import 'dart:async'; - -import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_test/flutter_test.dart'; -/// An [ImageProvider] whose image is delivered only when the test says so, and -/// whose key is itself, so that two [TileImage]s resolve to the SAME -/// [ImageStreamCompleter] (which is what [ImageCache] does for equal keys). -class _SharedManualImageProvider - extends ImageProvider<_SharedManualImageProvider> { - _SharedManualImageProvider(this.completer); - - final Completer completer; - - @override - Future<_SharedManualImageProvider> obtainKey( - ImageConfiguration configuration, - ) => - SynchronousFuture<_SharedManualImageProvider>(this); - - @override - ImageStreamCompleter loadImage( - _SharedManualImageProvider key, - ImageDecoderCallback decode, - ) => - OneFrameImageStreamCompleter(completer.future); -} +import '../../test_utils/test_frame_driver.dart'; + -TileImage _tile({ - required int x, - required ImageProvider provider, - void Function(TileCoordinates)? onLoadComplete, -}) => - TileImage( - vsync: const TestVSync(), - coordinates: TileCoordinates(x, 0, 0), - imageProvider: provider, - onLoadComplete: onLoadComplete ?? (_) {}, - onLoadError: (_, __, ___) {}, - tileDisplay: const TileDisplay.instantaneous(), - errorImage: null, - cancelLoading: Completer(), - ); void main() { testWidgets( @@ -56,8 +18,8 @@ void main() { // already has more than one handle open. final baseline = image.debugGetOpenHandleStackTraces()!.length; - final completer = Completer(); - final provider = _SharedManualImageProvider(completer); + final completer = DrivenCompleter(); + final provider = DrivenProvider(completer); late final TileImage second; var secondDisposed = false; @@ -71,8 +33,7 @@ void main() { // a listener from inside that loop does not stop it from being called. // The first tile's completion runs `onLoadComplete` — which is where // flutter_map prunes tiles — disposing the second tile mid-dispatch. - final first = _tile( - x: 0, + final first = testTileImage( provider: provider, onLoadComplete: (_) { if (secondDisposed) return; @@ -80,12 +41,12 @@ void main() { second.dispose(); }, ); - second = _tile(x: 1, provider: provider); + second = testTileImage(x: 1, provider: provider); first.load(); second.load(); - completer.complete(ImageInfo(image: image)); + completer.emit(ImageInfo(image: image)); await tester.pump(); expect( @@ -126,8 +87,8 @@ void main() { (await tester.runAsync(() => createTestImage(width: 9, height: 9, cache: false)))!; final base1 = first.debugGetOpenHandleStackTraces()!.length; - final completer = _MultiFrameCompleter(); - final tile = _tile(x: 0, provider: _MultiFrameProvider(completer)); + final completer = DrivenCompleter(); + final tile = testTileImage(provider: DrivenProvider(completer)); tile.load(); completer.emit(ImageInfo(image: first)); @@ -158,8 +119,8 @@ void main() { (await tester.runAsync(() => createTestImage(width: 8, height: 8, cache: false)))!; final baseline = image.debugGetOpenHandleStackTraces()!.length; - final completer = _MultiFrameCompleter(); - final tile = _tile(x: 0, provider: _MultiFrameProvider(completer)); + final completer = DrivenCompleter(); + final tile = testTileImage(provider: DrivenProvider(completer)); tile.load(); completer.emit(ImageInfo(image: image)); @@ -180,26 +141,3 @@ void main() { }, ); } - -/// A completer the test drives frame by frame — progressive tiles (a base -/// frame followed by a composed one) emit more than once. -class _MultiFrameCompleter extends ImageStreamCompleter { - void emit(ImageInfo info) => setImage(info); -} - -class _MultiFrameProvider extends ImageProvider<_MultiFrameProvider> { - _MultiFrameProvider(this.completer); - - final _MultiFrameCompleter completer; - - @override - Future<_MultiFrameProvider> obtainKey(ImageConfiguration configuration) => - SynchronousFuture<_MultiFrameProvider>(this); - - @override - ImageStreamCompleter loadImage( - _MultiFrameProvider key, - ImageDecoderCallback decode, - ) => - completer; -} diff --git a/test/layer/tile_layer/tile_widget_handles_test.dart b/test/layer/tile_layer/tile_widget_handles_test.dart index 231ceeeb5..06aec1add 100644 --- a/test/layer/tile_layer/tile_widget_handles_test.dart +++ b/test/layer/tile_layer/tile_widget_handles_test.dart @@ -1,11 +1,10 @@ -import 'dart:async'; - -import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/src/layer/tile_layer/tile.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../../test_utils/test_frame_driver.dart'; + /// Handle accounting through the FULL widget cycle: TileImage → Tile → /// RawImage → RenderImage. /// @@ -22,37 +21,7 @@ import 'package:flutter_test/flutter_test.dart'; /// ⚠️ `cache: false` and DIFFERENT sizes are load-bearing: `createTestImage` /// caches by size and returns clones of one shared image, which makes two /// "independent" counters move in lockstep and hides per-frame attribution. -class _DrivenCompleter extends ImageStreamCompleter { - void emit(ImageInfo info) => setImage(info); -} - -class _DrivenProvider extends ImageProvider<_DrivenProvider> { - _DrivenProvider(this.completer); - - final _DrivenCompleter completer; - @override - Future<_DrivenProvider> obtainKey(ImageConfiguration configuration) => - SynchronousFuture<_DrivenProvider>(this); - - @override - ImageStreamCompleter loadImage( - _DrivenProvider key, - ImageDecoderCallback decode, - ) => - completer; -} - -TileImage _tileImage(ImageProvider provider) => TileImage( - vsync: const TestVSync(), - coordinates: const TileCoordinates(0, 0, 0), - imageProvider: provider, - onLoadComplete: (_) {}, - onLoadError: (_, __, ___) {}, - tileDisplay: const TileDisplay.instantaneous(), - errorImage: null, - cancelLoading: Completer(), - ); Widget _host(TileImage tileImage) => Directionality( textDirection: TextDirection.ltr, @@ -80,8 +49,8 @@ void main() { final base1 = images[0].debugGetOpenHandleStackTraces()!.length; final base2 = images[1].debugGetOpenHandleStackTraces()!.length; - final completer = _DrivenCompleter(); - final tileImage = _tileImage(_DrivenProvider(completer)); + final completer = DrivenCompleter(); + final tileImage = testTileImage(provider: DrivenProvider(completer)); tileImage.load(); await tester.pumpWidget(_host(tileImage)); @@ -127,8 +96,8 @@ void main() { final base1 = images[0].debugGetOpenHandleStackTraces()!.length; final base2 = images[1].debugGetOpenHandleStackTraces()!.length; - final completer = _DrivenCompleter(); - final tileImage = _tileImage(_DrivenProvider(completer)); + final completer = DrivenCompleter(); + final tileImage = testTileImage(provider: DrivenProvider(completer)); tileImage.load(); await tester.pumpWidget(_host(tileImage)); @@ -164,8 +133,8 @@ void main() { final base1 = images[0].debugGetOpenHandleStackTraces()!.length; final base2 = images[1].debugGetOpenHandleStackTraces()!.length; - final completer = _DrivenCompleter(); - final tileImage = _tileImage(_DrivenProvider(completer)); + final completer = DrivenCompleter(); + final tileImage = testTileImage(provider: DrivenProvider(completer)); tileImage.load(); await tester.pumpWidget(_host(tileImage)); diff --git a/test/test_utils/test_frame_driver.dart b/test/test_utils/test_frame_driver.dart new file mode 100644 index 000000000..bd6ef1760 --- /dev/null +++ b/test/test_utils/test_frame_driver.dart @@ -0,0 +1,54 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// A completer the test drives frame by frame — progressive tiles (a base +/// frame followed by a composed one) emit more than once, and handle-ownership +/// tests need to control exactly when each frame lands. +class DrivenCompleter extends ImageStreamCompleter { + /// Delivers [info] to every listener as the next frame. + void emit(ImageInfo info) => setImage(info); +} + +/// An [ImageProvider] whose key is itself, so every [TileImage] resolving it +/// shares ONE completer — exactly what [ImageCache] does for equal keys. +class DrivenProvider extends ImageProvider { + /// Creates a provider that exposes [completer] to all resolvers. + DrivenProvider(this.completer); + + /// The completer shared by every resolve of this provider. + final ImageStreamCompleter completer; + + @override + Future obtainKey(ImageConfiguration configuration) => + SynchronousFuture(this); + + @override + ImageStreamCompleter loadImage( + DrivenProvider key, + ImageDecoderCallback decode, + ) => + completer; +} + +/// One factory for the handle-ownership tests — the [TileImage] constructor +/// takes eight arguments and duplicating the boilerplate per test file meant a +/// constructor change touched every copy. +TileImage testTileImage({ + required ImageProvider provider, + int x = 0, + void Function(TileCoordinates)? onLoadComplete, +}) => + TileImage( + vsync: const TestVSync(), + coordinates: TileCoordinates(x, 0, 0), + imageProvider: provider, + onLoadComplete: onLoadComplete ?? (_) {}, + onLoadError: (_, __, ___) {}, + tileDisplay: const TileDisplay.instantaneous(), + errorImage: null, + cancelLoading: Completer(), + );