From f52b37b85eab22594e482ff620b9012d5354f499 Mon Sep 17 00:00:00 2001 From: Jop Middelkamp Date: Thu, 17 Sep 2026 23:55:58 +0700 Subject: [PATCH] Share marker icons between identical bitmaps --- .../google_maps_flutter_ios/CHANGELOG.md | 6 +++ .../ExtractIconFromDataTests.swift | 54 +++++++++++++++++++ .../google_maps_flutter_ios/FGMImageUtils.m | 47 ++++++++++++++++ .../google_maps_flutter_ios/pubspec.yaml | 2 +- .../CHANGELOG.md | 6 +++ .../ExtractIconFromDataTests.swift | 44 +++++++++++++++ .../ImageUtils.swift | 33 ++++++++++++ .../pubspec.yaml | 2 +- .../google_maps_flutter_ios_sdk9/CHANGELOG.md | 6 +++ .../ExtractIconFromDataTests.swift | 44 +++++++++++++++ .../ImageUtils.swift | 33 ++++++++++++ .../google_maps_flutter_ios_sdk9/pubspec.yaml | 2 +- .../ExtractIconFromDataTests.swift | 44 +++++++++++++++ .../google_maps_flutter_ios/ImageUtils.swift | 33 ++++++++++++ 14 files changed, 353 insertions(+), 3 deletions(-) diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/CHANGELOG.md b/packages/google_maps_flutter/google_maps_flutter_ios/CHANGELOG.md index 33bbdca6a77b..3cd2b6316b0b 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/CHANGELOG.md +++ b/packages/google_maps_flutter/google_maps_flutter_ios/CHANGELOG.md @@ -1,3 +1,9 @@ +## 2.18.7 + +* Fixes corrupted and missing marker icons on maps with many markers created from + `BitmapDescriptor.bytes`, by sharing a single `UIImage` between markers that use the same + bytes and scaling. + ## 2.18.6 * Updates pigeon dev_dependency to ^27.3.2 for analyzer 14 compatibility. diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/example/ios/RunnerTests/ExtractIconFromDataTests.swift b/packages/google_maps_flutter/google_maps_flutter_ios/example/ios/RunnerTests/ExtractIconFromDataTests.swift index da3978359e44..df5327aff712 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/example/ios/RunnerTests/ExtractIconFromDataTests.swift +++ b/packages/google_maps_flutter/google_maps_flutter_ios/example/ios/RunnerTests/ExtractIconFromDataTests.swift @@ -298,6 +298,60 @@ import Testing #expect(resultImage?.size.height == 1.0) } + @Test func extractIconFromDataBytesSharesImageForEqualBytesAndScaling() throws { + let pngData = try #require(createOnePixelImage().pngData()) + let screenScale: CGFloat = 3.0 + + func createIcon() -> UIImage? { + let bitmap = FGMPlatformBitmapBytesMap.make( + withByteData: FlutterStandardTypedData(bytes: pngData), + bitmapScaling: .auto, + imagePixelRatio: 1, + width: nil, + height: nil + ) + return FGMIconFromBitmap( + FGMPlatformBitmap.make(withBitmap: bitmap), + TestAssetProvider(), + screenScale + ) + } + + let firstImage = try #require(createIcon()) + let secondImage = try #require(createIcon()) + + // The Maps SDK allocates marker texture space per UIImage instance, so bitmaps that describe + // the same image must share a single instance. + #expect(firstImage === secondImage) + } + + @Test func extractIconFromDataBytesDoesNotShareImageForDifferentPixelRatio() throws { + let pngData = try #require(createOnePixelImage().pngData()) + let screenScale: CGFloat = 3.0 + + func createIcon(imagePixelRatio: Double) -> UIImage? { + let bitmap = FGMPlatformBitmapBytesMap.make( + withByteData: FlutterStandardTypedData(bytes: pngData), + bitmapScaling: .auto, + imagePixelRatio: imagePixelRatio, + width: nil, + height: nil + ) + return FGMIconFromBitmap( + FGMPlatformBitmap.make(withBitmap: bitmap), + TestAssetProvider(), + screenScale + ) + } + + let firstImage = try #require(createIcon(imagePixelRatio: 1)) + let secondImage = try #require(createIcon(imagePixelRatio: 10)) + + #expect(firstImage !== secondImage) + #expect(firstImage.scale == 1.0) + #expect(secondImage.scale == 10.0) + } + /// Tests for PinConfig (GMSPinImageOptions) - requires iOS 16.0+ and Google Maps SDK 9.0+. /// On earlier versions, FGMIconFromBitmap returns nil for PinConfig, which is expected behavior. @Test func extractIconFromPinConfigWithGlyphColor() { diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/ios/google_maps_flutter_ios/Sources/google_maps_flutter_ios/FGMImageUtils.m b/packages/google_maps_flutter/google_maps_flutter_ios/ios/google_maps_flutter_ios/Sources/google_maps_flutter_ios/FGMImageUtils.m index 03eb36eeb404..53fdd4e22ed5 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/ios/google_maps_flutter_ios/Sources/google_maps_flutter_ios/FGMImageUtils.m +++ b/packages/google_maps_flutter/google_maps_flutter_ios/ios/google_maps_flutter_ios/Sources/google_maps_flutter_ios/FGMImageUtils.m @@ -7,6 +7,8 @@ #import "FGMImageUtils.h" #import "FGMConversionUtils.h" +#import + @import Foundation; /// This method is deprecated within the context of `BitmapDescriptor.fromBytes` handling in the @@ -46,6 +48,21 @@ static UIImage *scaledImageWithWidthHeight(UIImage *image, NSNumber *width, NSNumber *height, CGFloat screenScale); +/// Returns the cache that shares one UIImage between all bitmaps that describe the same image. +/// +/// The Maps SDK allocates marker texture space per UIImage instance instead of per image content, +/// so creating a new UIImage for every marker exhausts the SDK's texture atlases ("Reached the max +/// number of texture atlases, can not allocate more.") and markers are drawn with the contents of +/// other markers. Sharing one instance between identical bitmaps avoids that, and is safe because +/// UIImage is immutable. +/// +/// NSCache is thread-safe, and releases its contents when the system is under memory pressure. +static NSCache *FGMBytesMapIconCache(void); + +/// Returns a key that covers every input of the icon created for [bitmap], so that bitmaps that +/// would produce different images never share one. +static NSString *FGMBytesMapIconCacheKey(FGMPlatformBitmapBytesMap *bitmap, CGFloat screenScale); + UIImage *FGMIconFromBitmap(FGMPlatformBitmap *platformBitmap, NSObject *assetProvider, CGFloat screenScale) { assert(screenScale > 0 && "Screen scale must be greater than 0"); @@ -106,6 +123,12 @@ FGMPlatformBitmapBytesMap *bitmapBytesMap = bitmap; FlutterStandardTypedData *bytes = bitmapBytesMap.byteData; + NSString *cacheKey = FGMBytesMapIconCacheKey(bitmapBytesMap, screenScale); + UIImage *cachedIcon = [FGMBytesMapIconCache() objectForKey:cacheKey]; + if (cachedIcon) { + return cachedIcon; + } + @try { image = [UIImage imageWithData:bytes.data scale:screenScale]; if (bitmapBytesMap.bitmapScaling == FGMPlatformMapBitmapScalingAuto) { @@ -128,6 +151,9 @@ reason:@"Unable to interpret bytes as a valid image." userInfo:nil]; } + if (image) { + [FGMBytesMapIconCache() setObject:image forKey:cacheKey]; + } } else if ([bitmap isKindOfClass:[FGMPlatformBitmapPinConfig class]]) { FGMPlatformBitmapPinConfig *pinConfig = bitmap; @@ -167,6 +193,27 @@ return image; } +static NSCache *FGMBytesMapIconCache(void) { + static NSCache *cache; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + cache = [[NSCache alloc] init]; + }); + return cache; +} + +static NSString *FGMBytesMapIconCacheKey(FGMPlatformBitmapBytesMap *bitmap, CGFloat screenScale) { + NSData *data = bitmap.byteData.data; + unsigned char digest[CC_SHA256_DIGEST_LENGTH]; + CC_SHA256(data.bytes, (CC_LONG)data.length, digest); + NSData *digestData = [NSData dataWithBytes:digest length:CC_SHA256_DIGEST_LENGTH]; + NSString *contentHash = [digestData base64EncodedStringWithOptions:0]; + long scaling = (long)bitmap.bitmapScaling; + return + [NSString stringWithFormat:@"%@|%ld|%f|%@|%@|%f", contentHash, scaling, + bitmap.imagePixelRatio, bitmap.width, bitmap.height, screenScale]; +} + UIImage *scaledImage(UIImage *image, double scale) { if (fabs(scale - 1) > 1e-3) { return [UIImage imageWithCGImage:[image CGImage] diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter_ios/pubspec.yaml index 3903dc23fc34..975434582e92 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter_ios/pubspec.yaml @@ -2,7 +2,7 @@ name: google_maps_flutter_ios description: iOS implementation of the google_maps_flutter plugin. repository: https://github.com/flutter/packages/tree/main/packages/google_maps_flutter/google_maps_flutter_ios issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+maps%22 -version: 2.18.6 +version: 2.18.7 environment: sdk: ^3.10.0 diff --git a/packages/google_maps_flutter/google_maps_flutter_ios_sdk10/CHANGELOG.md b/packages/google_maps_flutter/google_maps_flutter_ios_sdk10/CHANGELOG.md index dd1560e59d0a..92b86baafb53 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios_sdk10/CHANGELOG.md +++ b/packages/google_maps_flutter/google_maps_flutter_ios_sdk10/CHANGELOG.md @@ -1,3 +1,9 @@ +## 2.18.14 + +* Fixes corrupted and missing marker icons on maps with many markers created from + `BitmapDescriptor.bytes`, by sharing a single `UIImage` between markers that use the same + bytes and scaling. + ## 2.18.13 * Adopts new Pigeon async Swift support. diff --git a/packages/google_maps_flutter/google_maps_flutter_ios_sdk10/example/ios/RunnerTests/ExtractIconFromDataTests.swift b/packages/google_maps_flutter/google_maps_flutter_ios_sdk10/example/ios/RunnerTests/ExtractIconFromDataTests.swift index f13a6b714465..19a719199f3c 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios_sdk10/example/ios/RunnerTests/ExtractIconFromDataTests.swift +++ b/packages/google_maps_flutter/google_maps_flutter_ios_sdk10/example/ios/RunnerTests/ExtractIconFromDataTests.swift @@ -273,6 +273,50 @@ import Testing #expect(resultImage?.size.height == 1.0) } + @Test func extractIconFromDataBytesSharesImageForEqualBytesAndScaling() throws { + let pngData = try #require(createOnePixelImage().pngData()) + let screenScale: CGFloat = 3.0 + + func createIcon() -> UIImage? { + PlatformBitmapBytesMap( + byteData: FlutterStandardTypedData(bytes: pngData), + bitmapScaling: .auto, + imagePixelRatio: 1, + width: nil, + height: nil + ).createIcon(assetProvider: TestAssetProvider(), screenScale: screenScale) + } + + let firstImage = try #require(createIcon()) + let secondImage = try #require(createIcon()) + + // The Maps SDK allocates marker texture space per UIImage instance, so bitmaps that describe + // the same image must share a single instance. + #expect(firstImage === secondImage) + } + + @Test func extractIconFromDataBytesDoesNotShareImageForDifferentPixelRatio() throws { + let pngData = try #require(createOnePixelImage().pngData()) + let screenScale: CGFloat = 3.0 + + func createIcon(imagePixelRatio: Double) -> UIImage? { + PlatformBitmapBytesMap( + byteData: FlutterStandardTypedData(bytes: pngData), + bitmapScaling: .auto, + imagePixelRatio: imagePixelRatio, + width: nil, + height: nil + ).createIcon(assetProvider: TestAssetProvider(), screenScale: screenScale) + } + + let firstImage = try #require(createIcon(imagePixelRatio: 1)) + let secondImage = try #require(createIcon(imagePixelRatio: 10)) + + #expect(firstImage !== secondImage) + #expect(firstImage.scale == 1.0) + #expect(secondImage.scale == 10.0) + } + /// Tests for PinConfig (GMSPinImageOptions) - requires iOS 16.0+ and Google Maps SDK 9.0+. /// On earlier versions, createIcon returns nil for PinConfig, which is expected behavior. @Test func extractIconFromPinConfigWithGlyphColor() { diff --git a/packages/google_maps_flutter/google_maps_flutter_ios_sdk10/ios/google_maps_flutter_ios_sdk10/Sources/google_maps_flutter_ios_sdk10/ImageUtils.swift b/packages/google_maps_flutter/google_maps_flutter_ios_sdk10/ios/google_maps_flutter_ios_sdk10/Sources/google_maps_flutter_ios_sdk10/ImageUtils.swift index de6a4797ce3f..06f2cc89b1d1 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios_sdk10/ios/google_maps_flutter_ios_sdk10/Sources/google_maps_flutter_ios_sdk10/ImageUtils.swift +++ b/packages/google_maps_flutter/google_maps_flutter_ios_sdk10/ios/google_maps_flutter_ios_sdk10/Sources/google_maps_flutter_ios_sdk10/ImageUtils.swift @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import CryptoKit import Flutter import GoogleMaps import UIKit @@ -63,6 +64,10 @@ extension PlatformBitmap { } } case let bitmap as PlatformBitmapBytesMap: + let cacheKey = bitmap.iconCacheKey(screenScale: screenScale) + if let cachedIcon = bytesMapIconCache.object(forKey: cacheKey) { + return cachedIcon + } let bytes = bitmap.byteData image = UIImage(data: bytes.data, scale: screenScale) if let currentImage = image { @@ -81,6 +86,9 @@ extension PlatformBitmap { image = UIImage(data: bytes.data) } } + if let icon = image { + bytesMapIconCache.setObject(icon, forKey: cacheKey) + } case let bitmap as PlatformBitmapPinConfig: let options = GMSPinImageOptions() if let backgroundColor = bitmap.backgroundColor { @@ -119,6 +127,31 @@ extension PlatformBitmap { } } +/// Caches the icons created from `PlatformBitmapBytesMap` bitmaps, keyed by everything that +/// affects the resulting image. +/// +/// The Maps SDK allocates marker texture space per `UIImage` instance instead of per image content, +/// so creating a new `UIImage` for every marker exhausts the SDK's texture atlases ("Reached the +/// max number of texture atlases, can not allocate more."), after which markers are drawn with the +/// contents of other markers. Sharing one instance between identical bitmaps avoids that, and is +/// safe because `UIImage` is immutable. +/// +/// `NSCache` is thread-safe, and releases its contents when the system is under memory pressure. +private let bytesMapIconCache = NSCache() + +extension PlatformBitmapBytesMap { + /// Returns a key that covers every input of the icon created from this bitmap, so that bitmaps + /// that would produce different images never share one. + fileprivate func iconCacheKey(screenScale: CGFloat) -> NSString { + let contentHash = Data(SHA256.hash(data: byteData.data)).base64EncodedString() + let widthKey = width?.description ?? "nil" + let heightKey = height?.description ?? "nil" + return + "\(contentHash)|\(bitmapScaling.rawValue)|\(imagePixelRatio)|\(widthKey)|\(heightKey)|\(screenScale)" + as NSString + } +} + /// Creates a scaled version of the provided UIImage based on a specified scale factor. /// /// This method is deprecated within the context of `BitmapDescriptor.fromBytes` handling in the diff --git a/packages/google_maps_flutter/google_maps_flutter_ios_sdk10/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter_ios_sdk10/pubspec.yaml index f069ebac61bd..3db2dafaf39c 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios_sdk10/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter_ios_sdk10/pubspec.yaml @@ -2,7 +2,7 @@ name: google_maps_flutter_ios_sdk10 description: iOS implementation of the google_maps_flutter plugin using Google Maps SDK 10. repository: https://github.com/flutter/packages/tree/main/packages/google_maps_flutter/google_maps_flutter_ios_sdk10 issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+maps%22 -version: 2.18.13 +version: 2.18.14 environment: sdk: ^3.10.0 diff --git a/packages/google_maps_flutter/google_maps_flutter_ios_sdk9/CHANGELOG.md b/packages/google_maps_flutter/google_maps_flutter_ios_sdk9/CHANGELOG.md index da0ffcd8f6da..9112fce585e4 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios_sdk9/CHANGELOG.md +++ b/packages/google_maps_flutter/google_maps_flutter_ios_sdk9/CHANGELOG.md @@ -1,3 +1,9 @@ +## 2.18.15 + +* Fixes corrupted and missing marker icons on maps with many markers created from + `BitmapDescriptor.bytes`, by sharing a single `UIImage` between markers that use the same + bytes and scaling. + ## 2.18.14 * Adopts new Pigeon async Swift support. diff --git a/packages/google_maps_flutter/google_maps_flutter_ios_sdk9/example/ios/RunnerTests/ExtractIconFromDataTests.swift b/packages/google_maps_flutter/google_maps_flutter_ios_sdk9/example/ios/RunnerTests/ExtractIconFromDataTests.swift index 1f52c708e955..5857aaa1d055 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios_sdk9/example/ios/RunnerTests/ExtractIconFromDataTests.swift +++ b/packages/google_maps_flutter/google_maps_flutter_ios_sdk9/example/ios/RunnerTests/ExtractIconFromDataTests.swift @@ -273,6 +273,50 @@ import Testing #expect(resultImage?.size.height == 1.0) } + @Test func extractIconFromDataBytesSharesImageForEqualBytesAndScaling() throws { + let pngData = try #require(createOnePixelImage().pngData()) + let screenScale: CGFloat = 3.0 + + func createIcon() -> UIImage? { + PlatformBitmapBytesMap( + byteData: FlutterStandardTypedData(bytes: pngData), + bitmapScaling: .auto, + imagePixelRatio: 1, + width: nil, + height: nil + ).createIcon(assetProvider: TestAssetProvider(), screenScale: screenScale) + } + + let firstImage = try #require(createIcon()) + let secondImage = try #require(createIcon()) + + // The Maps SDK allocates marker texture space per UIImage instance, so bitmaps that describe + // the same image must share a single instance. + #expect(firstImage === secondImage) + } + + @Test func extractIconFromDataBytesDoesNotShareImageForDifferentPixelRatio() throws { + let pngData = try #require(createOnePixelImage().pngData()) + let screenScale: CGFloat = 3.0 + + func createIcon(imagePixelRatio: Double) -> UIImage? { + PlatformBitmapBytesMap( + byteData: FlutterStandardTypedData(bytes: pngData), + bitmapScaling: .auto, + imagePixelRatio: imagePixelRatio, + width: nil, + height: nil + ).createIcon(assetProvider: TestAssetProvider(), screenScale: screenScale) + } + + let firstImage = try #require(createIcon(imagePixelRatio: 1)) + let secondImage = try #require(createIcon(imagePixelRatio: 10)) + + #expect(firstImage !== secondImage) + #expect(firstImage.scale == 1.0) + #expect(secondImage.scale == 10.0) + } + /// Tests for PinConfig (GMSPinImageOptions) - requires iOS 16.0+ and Google Maps SDK 9.0+. /// On earlier versions, createIcon returns nil for PinConfig, which is expected behavior. @Test func extractIconFromPinConfigWithGlyphColor() { diff --git a/packages/google_maps_flutter/google_maps_flutter_ios_sdk9/ios/google_maps_flutter_ios_sdk9/Sources/google_maps_flutter_ios_sdk9/ImageUtils.swift b/packages/google_maps_flutter/google_maps_flutter_ios_sdk9/ios/google_maps_flutter_ios_sdk9/Sources/google_maps_flutter_ios_sdk9/ImageUtils.swift index de6a4797ce3f..06f2cc89b1d1 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios_sdk9/ios/google_maps_flutter_ios_sdk9/Sources/google_maps_flutter_ios_sdk9/ImageUtils.swift +++ b/packages/google_maps_flutter/google_maps_flutter_ios_sdk9/ios/google_maps_flutter_ios_sdk9/Sources/google_maps_flutter_ios_sdk9/ImageUtils.swift @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import CryptoKit import Flutter import GoogleMaps import UIKit @@ -63,6 +64,10 @@ extension PlatformBitmap { } } case let bitmap as PlatformBitmapBytesMap: + let cacheKey = bitmap.iconCacheKey(screenScale: screenScale) + if let cachedIcon = bytesMapIconCache.object(forKey: cacheKey) { + return cachedIcon + } let bytes = bitmap.byteData image = UIImage(data: bytes.data, scale: screenScale) if let currentImage = image { @@ -81,6 +86,9 @@ extension PlatformBitmap { image = UIImage(data: bytes.data) } } + if let icon = image { + bytesMapIconCache.setObject(icon, forKey: cacheKey) + } case let bitmap as PlatformBitmapPinConfig: let options = GMSPinImageOptions() if let backgroundColor = bitmap.backgroundColor { @@ -119,6 +127,31 @@ extension PlatformBitmap { } } +/// Caches the icons created from `PlatformBitmapBytesMap` bitmaps, keyed by everything that +/// affects the resulting image. +/// +/// The Maps SDK allocates marker texture space per `UIImage` instance instead of per image content, +/// so creating a new `UIImage` for every marker exhausts the SDK's texture atlases ("Reached the +/// max number of texture atlases, can not allocate more."), after which markers are drawn with the +/// contents of other markers. Sharing one instance between identical bitmaps avoids that, and is +/// safe because `UIImage` is immutable. +/// +/// `NSCache` is thread-safe, and releases its contents when the system is under memory pressure. +private let bytesMapIconCache = NSCache() + +extension PlatformBitmapBytesMap { + /// Returns a key that covers every input of the icon created from this bitmap, so that bitmaps + /// that would produce different images never share one. + fileprivate func iconCacheKey(screenScale: CGFloat) -> NSString { + let contentHash = Data(SHA256.hash(data: byteData.data)).base64EncodedString() + let widthKey = width?.description ?? "nil" + let heightKey = height?.description ?? "nil" + return + "\(contentHash)|\(bitmapScaling.rawValue)|\(imagePixelRatio)|\(widthKey)|\(heightKey)|\(screenScale)" + as NSString + } +} + /// Creates a scaled version of the provided UIImage based on a specified scale factor. /// /// This method is deprecated within the context of `BitmapDescriptor.fromBytes` handling in the diff --git a/packages/google_maps_flutter/google_maps_flutter_ios_sdk9/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter_ios_sdk9/pubspec.yaml index 4fa42bc6f481..fed6553448e7 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios_sdk9/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter_ios_sdk9/pubspec.yaml @@ -2,7 +2,7 @@ name: google_maps_flutter_ios_sdk9 description: iOS implementation of the google_maps_flutter plugin using Google Maps SDK 9. repository: https://github.com/flutter/packages/tree/main/packages/google_maps_flutter/google_maps_flutter_ios_sdk9 issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+maps%22 -version: 2.18.14 +version: 2.18.15 environment: sdk: ^3.10.0 diff --git a/packages/google_maps_flutter/google_maps_flutter_ios_shared_code/example/ios/RunnerTests/ExtractIconFromDataTests.swift b/packages/google_maps_flutter/google_maps_flutter_ios_shared_code/example/ios/RunnerTests/ExtractIconFromDataTests.swift index 06f0ab942a4d..1d70aa1188a3 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios_shared_code/example/ios/RunnerTests/ExtractIconFromDataTests.swift +++ b/packages/google_maps_flutter/google_maps_flutter_ios_shared_code/example/ios/RunnerTests/ExtractIconFromDataTests.swift @@ -273,6 +273,50 @@ import Testing #expect(resultImage?.size.height == 1.0) } + @Test func extractIconFromDataBytesSharesImageForEqualBytesAndScaling() throws { + let pngData = try #require(createOnePixelImage().pngData()) + let screenScale: CGFloat = 3.0 + + func createIcon() -> UIImage? { + PlatformBitmapBytesMap( + byteData: FlutterStandardTypedData(bytes: pngData), + bitmapScaling: .auto, + imagePixelRatio: 1, + width: nil, + height: nil + ).createIcon(assetProvider: TestAssetProvider(), screenScale: screenScale) + } + + let firstImage = try #require(createIcon()) + let secondImage = try #require(createIcon()) + + // The Maps SDK allocates marker texture space per UIImage instance, so bitmaps that describe + // the same image must share a single instance. + #expect(firstImage === secondImage) + } + + @Test func extractIconFromDataBytesDoesNotShareImageForDifferentPixelRatio() throws { + let pngData = try #require(createOnePixelImage().pngData()) + let screenScale: CGFloat = 3.0 + + func createIcon(imagePixelRatio: Double) -> UIImage? { + PlatformBitmapBytesMap( + byteData: FlutterStandardTypedData(bytes: pngData), + bitmapScaling: .auto, + imagePixelRatio: imagePixelRatio, + width: nil, + height: nil + ).createIcon(assetProvider: TestAssetProvider(), screenScale: screenScale) + } + + let firstImage = try #require(createIcon(imagePixelRatio: 1)) + let secondImage = try #require(createIcon(imagePixelRatio: 10)) + + #expect(firstImage !== secondImage) + #expect(firstImage.scale == 1.0) + #expect(secondImage.scale == 10.0) + } + /// Tests for PinConfig (GMSPinImageOptions) - requires iOS 16.0+ and Google Maps SDK 9.0+. /// On earlier versions, createIcon returns nil for PinConfig, which is expected behavior. @Test func extractIconFromPinConfigWithGlyphColor() { diff --git a/packages/google_maps_flutter/google_maps_flutter_ios_shared_code/ios/google_maps_flutter_ios/Sources/google_maps_flutter_ios/ImageUtils.swift b/packages/google_maps_flutter/google_maps_flutter_ios_shared_code/ios/google_maps_flutter_ios/Sources/google_maps_flutter_ios/ImageUtils.swift index de6a4797ce3f..06f2cc89b1d1 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios_shared_code/ios/google_maps_flutter_ios/Sources/google_maps_flutter_ios/ImageUtils.swift +++ b/packages/google_maps_flutter/google_maps_flutter_ios_shared_code/ios/google_maps_flutter_ios/Sources/google_maps_flutter_ios/ImageUtils.swift @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import CryptoKit import Flutter import GoogleMaps import UIKit @@ -63,6 +64,10 @@ extension PlatformBitmap { } } case let bitmap as PlatformBitmapBytesMap: + let cacheKey = bitmap.iconCacheKey(screenScale: screenScale) + if let cachedIcon = bytesMapIconCache.object(forKey: cacheKey) { + return cachedIcon + } let bytes = bitmap.byteData image = UIImage(data: bytes.data, scale: screenScale) if let currentImage = image { @@ -81,6 +86,9 @@ extension PlatformBitmap { image = UIImage(data: bytes.data) } } + if let icon = image { + bytesMapIconCache.setObject(icon, forKey: cacheKey) + } case let bitmap as PlatformBitmapPinConfig: let options = GMSPinImageOptions() if let backgroundColor = bitmap.backgroundColor { @@ -119,6 +127,31 @@ extension PlatformBitmap { } } +/// Caches the icons created from `PlatformBitmapBytesMap` bitmaps, keyed by everything that +/// affects the resulting image. +/// +/// The Maps SDK allocates marker texture space per `UIImage` instance instead of per image content, +/// so creating a new `UIImage` for every marker exhausts the SDK's texture atlases ("Reached the +/// max number of texture atlases, can not allocate more."), after which markers are drawn with the +/// contents of other markers. Sharing one instance between identical bitmaps avoids that, and is +/// safe because `UIImage` is immutable. +/// +/// `NSCache` is thread-safe, and releases its contents when the system is under memory pressure. +private let bytesMapIconCache = NSCache() + +extension PlatformBitmapBytesMap { + /// Returns a key that covers every input of the icon created from this bitmap, so that bitmaps + /// that would produce different images never share one. + fileprivate func iconCacheKey(screenScale: CGFloat) -> NSString { + let contentHash = Data(SHA256.hash(data: byteData.data)).base64EncodedString() + let widthKey = width?.description ?? "nil" + let heightKey = height?.description ?? "nil" + return + "\(contentHash)|\(bitmapScaling.rawValue)|\(imagePixelRatio)|\(widthKey)|\(heightKey)|\(screenScale)" + as NSString + } +} + /// Creates a scaled version of the provided UIImage based on a specified scale factor. /// /// This method is deprecated within the context of `BitmapDescriptor.fromBytes` handling in the