From f7b74672334d0e777eadad54badf2aed856c9e8e Mon Sep 17 00:00:00 2001 From: Rafael Sousa Date: Thu, 13 Aug 2026 17:00:39 -0300 Subject: [PATCH 1/2] fix(ios): hold navigation views weakly in the view registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GoogleMapsNavigationViewRegistry` stored views in a strong dictionary but a view removes itself from the registry only in its `deinit` (`unregisterView()`). The strong entry keeps the view's reference count above zero after Flutter tears down the platform view, so `deinit` never runs, the view is never unregistered, and its underlying `GMSMapView` is retained for the lifetime of the process. Flutter assigns a new view id per platform-view creation, so nothing overwrites the stale entry either — one view leaks per view creation. Hold views via a weak wrapper so a released view deallocates, which triggers `deinit` and prunes the (now-empty) entry. `unregisterView` also drops entries whose weak reference has already been reclaimed so stale keys can't accumulate. CarPlay handling is unchanged (it has an explicit unregister path and never depended on `deinit`). Measured on a physical device: physical footprint climbed ~85 MB -> ~595 MB over ~4.5 min of map open/close cycles before the change, and reclaims between cycles after it. --- .../GoogleMapsNavigationViewRegistry.swift | 43 ++++++++++++++----- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/ios/google_navigation_flutter/Sources/google_navigation_flutter/GoogleMapsNavigationViewRegistry.swift b/ios/google_navigation_flutter/Sources/google_navigation_flutter/GoogleMapsNavigationViewRegistry.swift index 153615fd..11f133f5 100644 --- a/ios/google_navigation_flutter/Sources/google_navigation_flutter/GoogleMapsNavigationViewRegistry.swift +++ b/ios/google_navigation_flutter/Sources/google_navigation_flutter/GoogleMapsNavigationViewRegistry.swift @@ -15,8 +15,24 @@ import Dispatch import Foundation +/// Weak wrapper so the registry does not own the views it tracks. +/// +/// A `GoogleMapsNavigationView` removes itself from the registry only in its +/// `deinit` (`unregisterView()`). If the registry held views strongly, that +/// strong entry would keep the view's reference count above zero after Flutter +/// tears down the platform view, so `deinit` would never run, the view would +/// never be unregistered, and its underlying `GMSMapView` would be retained for +/// the lifetime of the process. Because Flutter assigns a new view id for each +/// platform view, nothing overwrites the stale entry either, so one view leaks +/// per view creation. Holding views weakly lets a released view deallocate, +/// which triggers `deinit` and prunes the (now-empty) entry. +private class WeakViewRef { + weak var view: GoogleMapsNavigationView? + init(_ view: GoogleMapsNavigationView) { self.view = view } +} + class GoogleMapsNavigationViewRegistry { - private var views: [Int64: GoogleMapsNavigationView] = [:] + private var views: [Int64: WeakViewRef] = [:] private var carPlayView: GoogleMapsNavigationView? { didSet { onHasCarPlayViewChanged?(carPlayView != nil) @@ -34,41 +50,46 @@ class GoogleMapsNavigationViewRegistry { func registerView(viewId: Int64, view: GoogleMapsNavigationView) { queue.sync(flags: .barrier) { [weak self] in - self?.views[viewId] = view + self?.views[viewId] = WeakViewRef(view) } } func unregisterView(viewId: Int64, viewInstanceIdToUnregister: ObjectIdentifier) { queue.async(flags: .barrier) { [weak self] in - if let registeredView = self?.views[viewId], - ObjectIdentifier(registeredView) == viewInstanceIdToUnregister - { - self?.views.removeValue(forKey: viewId) + guard let self else { return } + // Remove the entry when it matches the unregistering instance, or when the + // weakly-held view has already been reclaimed, so stale keys never linger. + if let registeredView = self.views[viewId]?.view { + if ObjectIdentifier(registeredView) == viewInstanceIdToUnregister { + self.views.removeValue(forKey: viewId) + } + } else { + self.views.removeValue(forKey: viewId) } } } func getView(viewId: Int64) -> GoogleMapsNavigationView? { queue.sync { - views[viewId] + views[viewId]?.view } } func getAllRegisteredViewIds() -> [Int64] { queue.sync { - Array(views.keys) + views.compactMap { $0.value.view != nil ? $0.key : nil } } } func getAllRegisteredViews() -> [GoogleMapsNavigationView] { queue.sync { - Array(views.values) + views.values.compactMap { $0.view } } } func getAllRegisteredNavigationViewIds() -> [Int64] { // Filter the views dictionary to include only those views that are navigation views - views.filter { $0.value.isNavigationView() }.map(\.key) + views.compactMap { $0.value.view?.isNavigationView() == true ? $0.key : nil } } func registerCarPlayView(view: GoogleMapsNavigationView) { @@ -95,7 +116,7 @@ class GoogleMapsNavigationViewRegistry { func sendPromptVisibilityChangedToAllViews(promptVisible: Bool) { queue.sync { - for view in views.values { + for view in views.values.compactMap({ $0.view }) { view.sendPromptVisibilityChangedEvent(promptVisible: promptVisible) } // Also send to CarPlay view if it exists From ce5821099f489ffd52296da7d133d2a37ac17843 Mon Sep 17 00:00:00 2001 From: Rafael Sousa Date: Fri, 14 Aug 2026 12:18:05 -0300 Subject: [PATCH 2/2] fix(ios): use generic WeakRef and rename registry dict to viewRefs Address review: move the weak wrapper to a generic WeakRef in Utilities.swift instead of a file-local class, drop the explanatory comment, and rename the registry dictionary to viewRefs to match its contents. --- .../GoogleMapsNavigationViewRegistry.swift | 38 +++++-------------- .../google_navigation_flutter/Utilities.swift | 8 ++++ 2 files changed, 18 insertions(+), 28 deletions(-) diff --git a/ios/google_navigation_flutter/Sources/google_navigation_flutter/GoogleMapsNavigationViewRegistry.swift b/ios/google_navigation_flutter/Sources/google_navigation_flutter/GoogleMapsNavigationViewRegistry.swift index 11f133f5..b3ef15ae 100644 --- a/ios/google_navigation_flutter/Sources/google_navigation_flutter/GoogleMapsNavigationViewRegistry.swift +++ b/ios/google_navigation_flutter/Sources/google_navigation_flutter/GoogleMapsNavigationViewRegistry.swift @@ -15,24 +15,8 @@ import Dispatch import Foundation -/// Weak wrapper so the registry does not own the views it tracks. -/// -/// A `GoogleMapsNavigationView` removes itself from the registry only in its -/// `deinit` (`unregisterView()`). If the registry held views strongly, that -/// strong entry would keep the view's reference count above zero after Flutter -/// tears down the platform view, so `deinit` would never run, the view would -/// never be unregistered, and its underlying `GMSMapView` would be retained for -/// the lifetime of the process. Because Flutter assigns a new view id for each -/// platform view, nothing overwrites the stale entry either, so one view leaks -/// per view creation. Holding views weakly lets a released view deallocate, -/// which triggers `deinit` and prunes the (now-empty) entry. -private class WeakViewRef { - weak var view: GoogleMapsNavigationView? - init(_ view: GoogleMapsNavigationView) { self.view = view } -} - class GoogleMapsNavigationViewRegistry { - private var views: [Int64: WeakViewRef] = [:] + private var viewRefs: [Int64: WeakRef] = [:] private var carPlayView: GoogleMapsNavigationView? { didSet { onHasCarPlayViewChanged?(carPlayView != nil) @@ -50,46 +34,44 @@ class GoogleMapsNavigationViewRegistry { func registerView(viewId: Int64, view: GoogleMapsNavigationView) { queue.sync(flags: .barrier) { [weak self] in - self?.views[viewId] = WeakViewRef(view) + self?.viewRefs[viewId] = WeakRef(view) } } func unregisterView(viewId: Int64, viewInstanceIdToUnregister: ObjectIdentifier) { queue.async(flags: .barrier) { [weak self] in guard let self else { return } - // Remove the entry when it matches the unregistering instance, or when the - // weakly-held view has already been reclaimed, so stale keys never linger. - if let registeredView = self.views[viewId]?.view { + if let registeredView = self.viewRefs[viewId]?.value { if ObjectIdentifier(registeredView) == viewInstanceIdToUnregister { - self.views.removeValue(forKey: viewId) + self.viewRefs.removeValue(forKey: viewId) } } else { - self.views.removeValue(forKey: viewId) + self.viewRefs.removeValue(forKey: viewId) } } } func getView(viewId: Int64) -> GoogleMapsNavigationView? { queue.sync { - views[viewId]?.view + viewRefs[viewId]?.value } } func getAllRegisteredViewIds() -> [Int64] { queue.sync { - views.compactMap { $0.value.view != nil ? $0.key : nil } + viewRefs.compactMap { id, ref in ref.value != nil ? id : nil } } } func getAllRegisteredViews() -> [GoogleMapsNavigationView] { queue.sync { - views.values.compactMap { $0.view } + viewRefs.values.compactMap { $0.value } } } func getAllRegisteredNavigationViewIds() -> [Int64] { // Filter the views dictionary to include only those views that are navigation views - views.compactMap { $0.value.view?.isNavigationView() == true ? $0.key : nil } + viewRefs.compactMap { id, ref in ref.value?.isNavigationView() == true ? id : nil } } func registerCarPlayView(view: GoogleMapsNavigationView) { @@ -116,7 +98,7 @@ class GoogleMapsNavigationViewRegistry { func sendPromptVisibilityChangedToAllViews(promptVisible: Bool) { queue.sync { - for view in views.values.compactMap({ $0.view }) { + for view in viewRefs.values.compactMap({ $0.value }) { view.sendPromptVisibilityChangedEvent(promptVisible: promptVisible) } // Also send to CarPlay view if it exists diff --git a/ios/google_navigation_flutter/Sources/google_navigation_flutter/Utilities.swift b/ios/google_navigation_flutter/Sources/google_navigation_flutter/Utilities.swift index d0ec4128..2e7bcd1b 100644 --- a/ios/google_navigation_flutter/Sources/google_navigation_flutter/Utilities.swift +++ b/ios/google_navigation_flutter/Sources/google_navigation_flutter/Utilities.swift @@ -14,6 +14,14 @@ import UIKit +final class WeakRef { + weak var value: T? + + init(_ value: T) { + self.value = value + } +} + extension String { static var empty: String { "" } }