Skip to content

fix(ios): hold navigation views weakly in the view registry so they deallocate on teardown - #752

Open
rafmsou wants to merge 1 commit into
googlemaps:mainfrom
rafmsou:fix/ios-weak-view-registry
Open

fix(ios): hold navigation views weakly in the view registry so they deallocate on teardown#752
rafmsou wants to merge 1 commit into
googlemaps:mainfrom
rafmsou:fix/ios-weak-view-registry

Conversation

@rafmsou

@rafmsou rafmsou commented Aug 13, 2026

Copy link
Copy Markdown

Summary

On iOS, every GoogleMapsMapView / GoogleMapsNavigationView is retained for the lifetime of the process, so repeatedly creating and disposing map/nav views grows memory without bound and eventually leads to jetsam/WatchdogTermination on memory-constrained devices.

GoogleMapsNavigationViewRegistry stored views in a strong dictionary:

private var views: [Int64: GoogleMapsNavigationView] = [:]

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, unregisterView() is never called, and the view (and its underlying GMSMapView) leaks forever. Flutter assigns a new viewId per platform-view creation, so nothing overwrites the stale entry either — one view leaks per view creation.

This is a self-referential retain: the only code that removes a view from the registry runs in deinit, which the registry's own strong reference prevents from ever running.

Fix

Hold views through a small WeakViewRef wrapper instead of strongly. A released view then deallocates, deinit runs, and the entry is pruned. unregisterView also removes entries whose weak reference has already been reclaimed, so stale keys can't accumulate. All read accessors compactMap over live views.

CarPlay handling is unchanged: carPlayView has an explicit unregisterCarPlayView() call path (from BaseCarSceneDelegate) and never depended on deinit, so it was not affected by this leak.

Reproduction / verification

  1. Show a screen containing a GoogleMapsMapView, dismiss it, repeat.
  2. Profile in Instruments (Activity Monitor) on a physical device.

Measured on an iPhone 11 / iOS 26.5: physical footprint climbed ~85 MB → ~595 MB over ~4.5 min of open/close cycles with no reclamation (~88 MB/min) before the change; after the change it reclaims between cycles.

Present at least through 0.9.4 and 0.10.0 (identical registry code).

`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.
@google-cla

google-cla Bot commented Aug 13, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@rafmsou
rafmsou marked this pull request as ready for review August 13, 2026 20:11
/// 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 {

@illuminati1911 illuminati1911 Aug 14, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is good, but let's make it generic for the whole library. Remove this and add generic version to Utilities.swift.

final class WeakRef<T: AnyObject> {
  weak var value: T?

  init(_ value: T) {
    self.value = value
  }
}

Also no need to explain all this. Just remove the whole comment.


class GoogleMapsNavigationViewRegistry {
private var views: [Int64: GoogleMapsNavigationView] = [:]
private var views: [Int64: WeakViewRef] = [:]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's change the name of the dictionary to viewRefs to match the content more accurately.

@illuminati1911 illuminati1911 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks a lot for the contribution. Few changes and I think should be good to go.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants