Skip to content

Commit 5a7dc34

Browse files
kevin68Kévin Baumeyerclaude
authored
[Android] Map: Stop reloading a pin's icon on every cluster pass (#37774)
Fixes #37773 ### Description of Change `ReclusterPins` re-runs `AddPins` on every zoom change, and `AddPinAsync` reloaded `Pin.ImageSource` unconditionally each time — a Glide fetch plus a decode and rescale for every un-clustered pin, on every pass. The `MarkerOptions` it writes the icon to already outlive the markers built from them, so the work was redundant as soon as the source had not changed. `MapPinHandler` now records which image source produced the icon currently on its `MarkerOptions`, and `AddPinAsync` skips the load while it still matches. The key comes from `GetClusterIconCacheKey`, the helper the cluster icon cache already uses, rather than a reference comparison. That matters twice: - it compares by **value**, so a new but equivalent `ImageSource` instance is recognised instead of forcing a reload; - it returns `null` for a source that opted out of caching — a `UriImageSource` with `CachingEnabled` false, or a non-positive `CacheValidity` — and those always reload rather than being frozen for the handler's lifetime. The helper's own comment states that rule for the cluster cache; this reuses it rather than restating it. The record is invalidated by the `ImageSource` mapper, which is the one hook that always runs when the icon on the `MarkerOptions` goes stale — on every `SetVirtualView`, including a handler reconnected to a different pin, and on every `ImageSource` change. The source is captured before the await **and re-checked after it** before the icon and the key are written: `UpdatePinImageSourceAsync` guards its own write the same way, and without the symmetric guard the two writers race and a marker can be published with the older icon while the change notification that would correct it has already been consumed. `UpdatePinImageSourceAsync` writes the icon to the `MarkerOptions` as well as the live marker and updates the key, reaching the platform element through the non-throwing accessor since it resumes after an await. A cleared `ImageSource` now takes the previous icon off the reused `MarkerOptions` (`SetIcon(null)`, Android's "use the default marker") rather than leaving the pin rendering an image its source no longer names. Skipping the load also removes the await before `Map.AddMarker` on the common path, so an un-clustered pin with an unchanged icon is added synchronously instead of disappearing until its image resolves. ### Note on ordering Split out of #37769 so the bug fix and this caching behaviour stay separately reviewable. It applies independently, but #37769 is the one that fixes broken behaviour — merge that first. ### Verification Physical device (Samsung SM-X230, Android 15), clustering gallery using pins whose icon is a PNG written to `FileSystem.CacheDirectory`, handler instrumented at each boundary. Three zoom-in/zoom-out cycles over 25 clustered custom pins, merged with #37769: **19 image loads for 86 markers** instead of one load per marker, every de-clustered pin keeping its icon, no Glide asserts, no recycled-bitmap errors, no crashes. Re-measured after adding the mapper-based invalidation, to confirm it does not fire on the recluster path — `ReclusterPins` reuses the existing handler with an unchanged `VirtualView`, so the mapper does not re-run there. ### Issues Fixed Fixes #37773 --------- Co-authored-by: Kévin Baumeyer <kbaumeyer@divalto.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 5b04af5 commit 5a7dc34

4 files changed

Lines changed: 140 additions & 56 deletions

File tree

src/Controls/tests/Core.UnitTests/MapTests.cs

Lines changed: 69 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1442,37 +1442,37 @@ public void ClusterInfoConstructorThrowsOnNullArguments()
14421442
}
14431443

14441444
[Fact]
1445-
public void GetClusterIconCacheKeyForFileImageSourceIsStableAcrossInstances()
1445+
public void GetIconCacheKeyForFileImageSourceIsStableAcrossInstances()
14461446
{
14471447
var first = new FileImageSource { File = "icon.png" };
14481448
var second = new FileImageSource { File = "icon.png" };
14491449

1450-
var firstKey = MapHandler.GetClusterIconCacheKey(first);
1451-
var secondKey = MapHandler.GetClusterIconCacheKey(second);
1450+
var firstKey = MapHandler.GetIconCacheKey(first);
1451+
var secondKey = MapHandler.GetIconCacheKey(second);
14521452

14531453
Assert.NotNull(firstKey);
14541454
Assert.StartsWith("file:", firstKey, StringComparison.Ordinal);
14551455
Assert.Equal(firstKey, secondKey);
14561456
}
14571457

14581458
[Fact]
1459-
public void GetClusterIconCacheKeyForUriImageSourceDependsOnCachingEnabled()
1459+
public void GetIconCacheKeyForUriImageSourceDependsOnCachingEnabled()
14601460
{
14611461
var uri = new Uri("https://example.com/icon.png");
14621462

14631463
var cachingEnabled = new UriImageSource { Uri = uri, CachingEnabled = true };
14641464
var cachingDisabled = new UriImageSource { Uri = uri, CachingEnabled = false };
14651465

1466-
var enabledKey = MapHandler.GetClusterIconCacheKey(cachingEnabled);
1467-
var disabledKey = MapHandler.GetClusterIconCacheKey(cachingDisabled);
1466+
var enabledKey = MapHandler.GetIconCacheKey(cachingEnabled);
1467+
var disabledKey = MapHandler.GetIconCacheKey(cachingDisabled);
14681468

14691469
Assert.NotNull(enabledKey);
14701470
Assert.StartsWith("uri:", enabledKey, StringComparison.Ordinal);
14711471
Assert.Null(disabledKey);
14721472
}
14731473

14741474
[Fact]
1475-
public void GetClusterIconCacheKeyForUriImageSourceRequiresPositiveValidity()
1475+
public void GetIconCacheKeyForUriImageSourceRequiresPositiveValidity()
14761476
{
14771477
var source = new UriImageSource
14781478
{
@@ -1481,22 +1481,22 @@ public void GetClusterIconCacheKeyForUriImageSourceRequiresPositiveValidity()
14811481
CacheValidity = TimeSpan.Zero
14821482
};
14831483

1484-
Assert.Null(MapHandler.GetClusterIconCacheKey(source));
1484+
Assert.Null(MapHandler.GetIconCacheKey(source));
14851485
}
14861486

14871487
[Fact]
1488-
public void GetClusterIconCacheKeyForFontImageSourceContainsGlyph()
1488+
public void GetIconCacheKeyForFontImageSourceContainsGlyph()
14891489
{
14901490
var font = new FontImageSource { Glyph = "A", FontFamily = "F", Size = 24, Color = Colors.White };
14911491

1492-
var key = MapHandler.GetClusterIconCacheKey(font);
1492+
var key = MapHandler.GetIconCacheKey(font);
14931493

14941494
Assert.NotNull(key);
14951495
Assert.Contains("A", key, StringComparison.Ordinal);
14961496
}
14971497

14981498
[Fact]
1499-
public void GetClusterIconCacheKeyForFontImageSourceDistinguishesWeight()
1499+
public void GetIconCacheKeyForFontImageSourceDistinguishesWeight()
15001500
{
15011501
var regular = new FakeFontImageSource
15021502
{
@@ -1511,16 +1511,16 @@ public void GetClusterIconCacheKeyForFontImageSourceDistinguishesWeight()
15111511
Font = Font.OfSize("F", 24).WithWeight(FontWeight.Bold)
15121512
};
15131513

1514-
var regularKey = MapHandler.GetClusterIconCacheKey(regular);
1515-
var boldKey = MapHandler.GetClusterIconCacheKey(bold);
1514+
var regularKey = MapHandler.GetIconCacheKey(regular);
1515+
var boldKey = MapHandler.GetIconCacheKey(bold);
15161516

15171517
Assert.NotNull(regularKey);
15181518
Assert.NotNull(boldKey);
15191519
Assert.NotEqual(regularKey, boldKey);
15201520
}
15211521

15221522
[Fact]
1523-
public void GetClusterIconCacheKeyForFontImageSourceDistinguishesAutoScaling()
1523+
public void GetIconCacheKeyForFontImageSourceDistinguishesAutoScaling()
15241524
{
15251525
var scalingEnabled = new FakeFontImageSource
15261526
{
@@ -1535,27 +1535,27 @@ public void GetClusterIconCacheKeyForFontImageSourceDistinguishesAutoScaling()
15351535
Font = Font.OfSize("F", 24, enableScaling: false)
15361536
};
15371537

1538-
var enabledKey = MapHandler.GetClusterIconCacheKey(scalingEnabled);
1539-
var disabledKey = MapHandler.GetClusterIconCacheKey(scalingDisabled);
1538+
var enabledKey = MapHandler.GetIconCacheKey(scalingEnabled);
1539+
var disabledKey = MapHandler.GetIconCacheKey(scalingDisabled);
15401540

15411541
Assert.NotNull(enabledKey);
15421542
Assert.NotNull(disabledKey);
15431543
Assert.NotEqual(enabledKey, disabledKey);
15441544
}
15451545

15461546
[Fact]
1547-
public void GetClusterIconCacheKeyIsNullForStreamOrMissingSource()
1547+
public void GetIconCacheKeyIsNullForStreamOrMissingSource()
15481548
{
15491549
var stream = new StreamImageSource();
15501550

1551-
Assert.Null(MapHandler.GetClusterIconCacheKey(stream));
1552-
Assert.Null(MapHandler.GetClusterIconCacheKey(null));
1551+
Assert.Null(MapHandler.GetIconCacheKey(stream));
1552+
Assert.Null(MapHandler.GetIconCacheKey(null));
15531553
}
15541554

15551555
[Fact]
1556-
public async Task ClusterIconCacheCoalescesConcurrentLoads()
1556+
public async Task IconCacheCoalescesConcurrentLoads()
15571557
{
1558-
var cache = new ClusterIconCache<object>(2);
1558+
var cache = new IconCache<object>(2);
15591559
var release = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
15601560
var loadCount = 0;
15611561

@@ -1576,9 +1576,9 @@ Task<object> Load()
15761576
}
15771577

15781578
[Fact]
1579-
public async Task ClusterIconCacheEvictsLeastRecentlyUsedEntry()
1579+
public async Task IconCacheEvictsLeastRecentlyUsedEntry()
15801580
{
1581-
var cache = new ClusterIconCache<object>(2);
1581+
var cache = new IconCache<object>(2);
15821582
var first = new object();
15831583
var second = new object();
15841584
var third = new object();
@@ -1595,6 +1595,52 @@ public async Task ClusterIconCacheEvictsLeastRecentlyUsedEntry()
15951595
Assert.Same(third, cachedThird);
15961596
}
15971597

1598+
// The recluster path re-runs AddPins on every zoom step, so this is what stops a pin's icon
1599+
// being decoded and rescaled again on each pass.
1600+
[Fact]
1601+
public async Task IconCacheDoesNotReloadAKeyedSourceOnALaterPass()
1602+
{
1603+
var cache = new IconCache<object>(2);
1604+
var icon = new object();
1605+
var loadCount = 0;
1606+
1607+
Task<object> Load()
1608+
{
1609+
loadCount++;
1610+
return Task.FromResult(icon);
1611+
}
1612+
1613+
var first = await cache.GetOrCreateAsync("file:pin.png", Load, () => DateTime.MaxValue);
1614+
var second = await cache.GetOrCreateAsync("file:pin.png", Load, () => DateTime.MaxValue);
1615+
1616+
Assert.Equal(1, loadCount);
1617+
Assert.Same(icon, first);
1618+
Assert.Same(second, first);
1619+
}
1620+
1621+
// GetIconCacheKey returns null for a source that can't be keyed stably, or that opted out of
1622+
// caching (see GetIconCacheKeyForUriImageSourceDependsOnCachingEnabled). Those must never be
1623+
// frozen by the handler-level cache - they reload on every pass instead.
1624+
[Fact]
1625+
public async Task IconCacheAlwaysReloadsWhenTheKeyIsNull()
1626+
{
1627+
var cache = new IconCache<object>(2);
1628+
var loadCount = 0;
1629+
1630+
Task<object> Load()
1631+
{
1632+
loadCount++;
1633+
return Task.FromResult(new object());
1634+
}
1635+
1636+
var first = await cache.GetOrCreateAsync(null, Load, () => DateTime.MaxValue);
1637+
var second = await cache.GetOrCreateAsync(null, Load, () => DateTime.MaxValue);
1638+
1639+
Assert.Equal(2, loadCount);
1640+
Assert.NotSame(first, second);
1641+
Assert.Equal(0, cache.Count);
1642+
}
1643+
15981644
class FakeFontImageSource : IFontImageSource
15991645
{
16001646
public Color Color { get; set; }

src/Core/maps/src/Handlers/Map/MapHandler.Android.cs

Lines changed: 51 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,8 @@ public partial class MapHandler : ViewHandler<IMap, MapView>
4848
bool _isClusteringEnabled;
4949
List<MapCluster>? _clusters;
5050
Dictionary<string, MapCluster>? _clusterMarkers;
51-
const int MaxClusterIconCacheSize = 64;
52-
readonly ClusterIconCache<BitmapDescriptor> _clusterIconCache = new(MaxClusterIconCacheSize);
51+
const int MaxIconCacheSize = 64;
52+
readonly IconCache<BitmapDescriptor> _iconCache = new(MaxIconCacheSize);
5353
WeakReference<IMap>? _clusterImageOwner;
5454
int _clusterImageVersion = int.MinValue;
5555

@@ -135,7 +135,7 @@ protected override void DisconnectHandler(MapView platformView)
135135
_circles = null;
136136
_clusters?.Clear();
137137
_clusterMarkers?.Clear();
138-
_clusterIconCache.Clear();
138+
_iconCache.Clear();
139139
_clusterImageOwner = null;
140140
_clusterImageVersion = int.MinValue;
141141
_init = true;
@@ -414,7 +414,7 @@ void UpdateClusterImageVersion(IMap map)
414414

415415
_clusterImageOwner = new WeakReference<IMap>(map);
416416
_clusterImageVersion = version;
417-
_clusterIconCache.Clear();
417+
_iconCache.Clear();
418418
}
419419

420420
/// <summary>
@@ -907,11 +907,27 @@ async Task AddPinAsync(IMapPin pin, CancellationToken ct)
907907

908908
var markerOptions = iMapPinHandler.PlatformView;
909909

910-
// Load custom image if specified
911-
if (pin.ImageSource != null)
910+
// Captured before the await: if ImageSource changes while the load is running, the write
911+
// below has to be dropped rather than publish a marker with the older icon.
912+
var requestedSource = pin.ImageSource;
913+
914+
if (requestedSource is null)
915+
{
916+
// The MarkerOptions are reused, so a cleared ImageSource has to take the previous
917+
// icon off them - null is Android's "use the default marker".
918+
markerOptions.SetIcon(null);
919+
}
920+
else
912921
{
913-
var icon = await LoadPinIconAsync(pin.ImageSource, MauiContext, ct);
914-
if (icon != null)
922+
// Clustering re-runs AddPins on every zoom change, so without the cache the icon of
923+
// every un-clustered pin is decoded and rescaled again on each pass.
924+
var icon = await GetIconAsync(requestedSource, MauiContext, ct);
925+
926+
// Re-checked after the await: UpdatePinImageSourceAsync can have applied a newer
927+
// source in the meantime, and it guards its own write the same way. Without this
928+
// the two writers race and the marker can be published with the older icon while
929+
// the change notification that would fix it has already been consumed.
930+
if (icon != null && ReferenceEquals(pin.ImageSource, requestedSource))
915931
markerOptions.SetIcon(icon);
916932
}
917933

@@ -955,9 +971,35 @@ async Task UpdatePinImageSourceAsync(IMapPin pin, Marker marker, CancellationTok
955971
if (!ct.IsCancellationRequested && ReferenceEquals(pin.ImageSource, requestedSource) && (pin.MarkerId as string) == marker.Id)
956972
{
957973
marker.SetIcon(icon);
974+
975+
// The MarkerOptions are reused the next time this pin is added, so update them too -
976+
// otherwise the next cluster pass would restore the previous icon. Reached through
977+
// the non-throwing accessor: this resumes after an await, and the typed PlatformView
978+
// throws once the handler has been disconnected.
979+
if (pin.Handler is MapPinHandler mapPinHandler &&
980+
((IElementHandler)mapPinHandler).PlatformView is MarkerOptions handlerOptions)
981+
{
982+
handlerOptions.SetIcon(icon);
983+
}
958984
}
959985
}
960986

987+
// Shared by pin and cluster markers: one decoded descriptor backs every marker naming the same
988+
// image, so a recluster pass reuses it instead of decoding per marker. GetIconCacheKey returns
989+
// null for a source that can't be keyed stably, or that opted out of caching - those load fresh
990+
// every time, which is also why they keep this caller's ct: nobody else is waiting on them.
991+
// A keyable load is shared, so it must not be cancelled by whichever caller happened to start it.
992+
Task<BitmapDescriptor?> GetIconAsync(IImageSource imageSource, IMauiContext mauiContext, CancellationToken ct)
993+
{
994+
var cacheKey = GetIconCacheKey(imageSource);
995+
var loadToken = cacheKey is null ? ct : CancellationToken.None;
996+
997+
return _iconCache.GetOrCreateAsync(
998+
cacheKey,
999+
() => LoadPinIconAsync(imageSource, mauiContext, loadToken),
1000+
() => GetIconCacheExpiry(imageSource));
1001+
}
1002+
9611003
// Loads imageSource into a BitmapDescriptor
9621004
// Returns null on cancel/failure/no drawable.
9631005
static async Task<BitmapDescriptor?> LoadPinIconAsync(IImageSource imageSource, IMauiContext mauiContext, CancellationToken ct)
@@ -1009,14 +1051,7 @@ async Task AddClusterMarkerAsync(MapCluster cluster, CancellationToken ct)
10091051

10101052
BitmapDescriptor? icon = null;
10111053
if (image != null)
1012-
{
1013-
var cacheKey = GetClusterIconCacheKey(image);
1014-
var loadToken = cacheKey is null ? ct : CancellationToken.None;
1015-
icon = await _clusterIconCache.GetOrCreateAsync(
1016-
cacheKey,
1017-
() => LoadPinIconAsync(image, mauiContext, loadToken),
1018-
() => GetClusterIconCacheExpiry(image));
1019-
}
1054+
icon = await GetIconAsync(image, mauiContext, ct);
10201055

10211056
if (ct.IsCancellationRequested || !ReferenceEquals(Map, map))
10221057
return;

src/Core/maps/src/Handlers/Map/MapHandler.cs

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -104,13 +104,13 @@ public static void MapHideInfoWindow(IMapHandler handler, IMap map, object? arg)
104104
mapHandler.HideInfoWindow(pin);
105105
}
106106

107-
// Builds a stable cache key for a cluster icon so logically identical images (same file, URI,
108-
// or font glyph) share one decoded/rasterized bitmap even when the provider hands back a fresh
109-
// ImageSource instance on every recluster. Returns null for sources that can't be keyed stably
110-
// (e.g. streams), so those are simply loaded fresh instead of being cached forever.
107+
// Builds a stable cache key for a marker icon - cluster or pin - so logically identical images
108+
// (same file, URI, or font glyph) share one decoded/rasterized bitmap even when the caller hands
109+
// back a fresh ImageSource instance on every recluster. Returns null for sources that can't be
110+
// keyed stably (e.g. streams), so those are simply loaded fresh instead of being cached forever.
111111
// A URI source with CachingEnabled == false has explicitly opted out of caching, so it must not
112112
// be frozen by this handler-level cache either.
113-
internal static string? GetClusterIconCacheKey(IImageSource? source) =>
113+
internal static string? GetIconCacheKey(IImageSource? source) =>
114114
source switch
115115
{
116116
IFileImageSource file when !string.IsNullOrEmpty(file.File) => $"file:{file.File}",
@@ -124,7 +124,7 @@ public static void MapHideInfoWindow(IMapHandler handler, IMap map, object? arg)
124124
// URI sources carry an explicit CacheValidity; other stable sources never expire on their own
125125
// (the cache is bounded and cleared during handler cleanup). Clamped so a large
126126
// validity like TimeSpan.MaxValue ("cache forever") can't overflow DateTime arithmetic.
127-
internal static DateTime GetClusterIconCacheExpiry(IImageSource? source)
127+
internal static DateTime GetIconCacheExpiry(IImageSource? source)
128128
{
129129
if (source is not IUriImageSource uri)
130130
return DateTime.MaxValue;
@@ -134,7 +134,10 @@ internal static DateTime GetClusterIconCacheExpiry(IImageSource? source)
134134
}
135135
}
136136

137-
internal sealed class ClusterIconCache<T>
137+
// Bounded LRU of decoded marker icons, shared by cluster and pin markers so one decode serves
138+
// every marker naming the same image. Keys come from MapHandler.GetIconCacheKey; a null key
139+
// bypasses the cache entirely rather than being stored under a fabricated one.
140+
internal sealed class IconCache<T>
138141
where T : class
139142
{
140143
readonly Dictionary<string, (T Value, DateTime ExpiresAtUtc, long AccessTick)> _entries = new();
@@ -145,7 +148,7 @@ internal sealed class ClusterIconCache<T>
145148
long _accessCounter;
146149
int _generation;
147150

148-
internal ClusterIconCache(int capacity, Action<T>? disposeValue = null)
151+
internal IconCache(int capacity, Action<T>? disposeValue = null)
149152
{
150153
if (capacity <= 0)
151154
throw new ArgumentOutOfRangeException(nameof(capacity));

0 commit comments

Comments
 (0)