Skip to content

fix: release GoogleMap listeners and dispose subcomposition when showing/hiding map - #1039

Open
dkhawk wants to merge 3 commits into
mainfrom
fix/581-oom-showing-hiding-map
Open

dkhawk wants to merge 3 commits into
mainfrom
fix/581-oom-showing-hiding-map

Conversation

@dkhawk

@dkhawk dkhawk commented Oct 13, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes memory retention and teardown ordering issues when GoogleMap is repeatedly shown/hidden (if (showMap) { GoogleMap(...) }) or deactivated/reused via ReusableContentHost / LazyColumn:

  1. Detach GoogleMap Listeners and InfoWindowAdapter on Clear:
    • MapApplier.attachClickListeners() registers 9 click/drag listeners and ComposeInfoWindowAdapter(mapView, ...) on GoogleMap (wrapped in Binder stubs that hold strong references to MapApplier and MapView), but MapApplier.onClear() never detached them. Added MapApplier.detachClickListeners() and invoked it from onClear().
    • MapPropertiesNode.onRemoved() and onCleared() now clear the 4 camera listeners (setOnCameraIdleListener(null), setOnCameraMoveCanceledListener(null), setOnCameraMoveStartedListener(null), setOnCameraMoveListener(null)) and setLocationSource(null) in addition to cameraPositionState.setMap(null).
    • TileOverlayNode.onRemoved() and onCleared() now clear tileOverlayState.tileOverlay = null.
  2. Synchronous Subcomposition Disposal & MapView Teardown:
    • Scoped subcompositionJob and subcomposition (Composition) to MapTagData on the MapView instance so onReset and onRelease dispose the map subcomposition synchronously before lifecycleObserver.moveToDestroyedState() (mapView.onDestroy()) is invoked, and so update cleanly re-launches the subcomposition when a deactivated MapView is reused.
    • Switched launchSubcomposition to Dispatchers.Main.immediate and guarded after mapView.awaitMap() (if (!isActive || mapView.tagDataOrNull !== tagData) return@launch) so rapid show/hide cycles where onRelease runs before getMapAsync completes do not throw a NullPointerException on mapView.tag or create an orphaned Composition.
    • Cleaned up removeOnAttachStateChangeListener, lifecycle observer registration, setViewTreeLifecycleOwner(null), setViewTreeSavedStateRegistryOwner(null), and removeAllViews() in onRelease.

Testing

  • Added unit tests in MapApplierCleanupTest verifying that MapApplier.onClear(), MapPropertiesNode, TileOverlayNode, and MarkerNode detach all listeners, InfoWindowAdapter, LocationSource, and state references.
  • Added instrumentation tests testShowingHidingMapRepeatedlyReleasesMapViews and testReusableContentHostResetAndReuse in GoogleMapViewTests.
  • Verified with heap dump (hprof) analysis across 30 show/hide cycles that 0 GoogleMap / MapApplier / ComposeInfoWindowAdapter instances remain in the heap.

Fixes #581

@github-actions

github-actions Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Coverage (unit tests)

Compared against cdf8b7f on main (#1044).

Module Line % Change Branch % Change
maps-compose 12.41% 🔺 +12.41 2.25% 🔺 +2.25
maps-compose-utils 2.17% no change 0.94% no change
maps-compose-widgets 0.00% no change 0.00% no change
TOTAL 9.64% 🔺 +9.19 1.94% 🔺 +1.77

Line and branch coverage from unit test reports. History is recorded on the coverage-history branch after each merge to main.

@dkhawk
dkhawk marked this pull request as ready for review October 13, 2026 23:58
@dkhawk
dkhawk requested a review from kikoso October 13, 2026 23:58
@googlemaps-bot

googlemaps-bot commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Coverage (instrumentation tests)

Overall Project 44.77% -0.06% 🍏
Files changed 91.54% 🍏

Module Coverage
maps-app 63.91% -0.16% 🍏
Files
Module File Coverage
maps-app MapUpdater.kt 81.62% -0.44% 🍏
GoogleMap.kt 80.47% -0.55% 🍏
MapApplier.kt 41.21% 🍏
TileOverlay.kt 0% -2.52% ❌

…ing/hiding map

Ensure MapApplier, MapPropertiesNode, and TileOverlayNode detach all GoogleMap listeners, InfoWindowAdapter, LocationSource, and state references when cleared or removed. Dispose the map subcomposition synchronously in AndroidView's onReset and onRelease callbacks before destroying the MapView, clean up view tree owners and attach-state listeners, and guard against rapid show/hide cycles where MapView is released before awaitMap() completes.

Fixes #581
@dkhawk
dkhawk force-pushed the fix/581-oom-showing-hiding-map branch from e1c2594 to 3b093fd Compare October 14, 2026 17:25
@dkhawk
dkhawk requested a review from LoyalAbbas October 14, 2026 19:50

@kikoso kikoso left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Minor comments @dkhawk , but looks good!


override fun onViewAttachedToWindow(mapView: View) {
lifecycle = mapView.findViewTreeLifecycleOwner()!!.lifecycle.also {
lifecycle = mapView.findViewTreeLifecycleOwner()?.lifecycle?.also {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Just to understand it: in which case do we get here without a lifecycle owner? Before, the !! failed loudly, and now the map would silently never receive lifecycle events (so it never renders). If it's only the released view case I am fine with it, but should we at least log a warning, or do I miss anything?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good catch! Since factory and update pin lifecycleOwner onto mapView via setViewTreeLifecycleOwner(lifecycleOwner) and onRelease() removes the OnAttachStateChangeListener before clearing the view-tree owner, findViewTreeLifecycleOwner() should never be null when onViewAttachedToWindow fires.

In 82565df, replaced the silent ?. with an explicit defensive check (MapAttachStateChangeListener) that logs an error (Log.e) and throws a descriptive IllegalStateException (error(...)) if findViewTreeLifecycleOwner() is ever null, along with a unit test covering this case.

internal class MapTagData(
val componentCallbacks: ComponentCallbacks2,
val lifecycleObserver: MapLifecycleEventObserver,
val onAttachStateListener: () -> Unit,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

nit: this sits right next to onAttachStateChangeListener and it is really the cleanup, so the names are easy to mix up. What about something like this (plus the two call sites)?

Suggested change
val onAttachStateListener: () -> Unit,
val detachLifecycleObserver: () -> Unit,

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done in 82565df — renamed onAttachStateListener to detachLifecycleObserver in MapTagData and updated all call sites.


activeState.value = true
composeTestRule.waitForIdle()
Thread.sleep(100)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Could we assert that the map comes back after each reactivation? Right now the test passes as long as nothing crashes. Something like composeTestRule.waitUntil(5_000) { cameraPositionState.projection != null } instead of the sleep would also verify that update re-launches the subcomposition.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done in 82565df — replaced both Thread.sleep(100) calls in testReusableContentHostResetAndReuse with composeTestRule.waitUntil(timeoutMillis = 5_000) { cameraPositionState.projection != null } to verify that initial composition and each reactivation re-launch the subcomposition.

// Verify MapView instances do not accumulate across show/hide cycles
// (Compose's accessibility delegate or the JIT register map may briefly hold 1-3 recent refs)
val liveCount = mapViews.count { it.get() != null }
assertThat(liveCount).isAtMost(3)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I wonder if this can be flaky on CI, since it depends on the GC actually collecting the views. Did it pass consistently over several runs on the emulator?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Great point! While it passed consistently across multiple local emulator runs (and 30-cycle HPROF heap dumps confirmed 0 GoogleMap / MapApplier instances retained), relying on Runtime.getRuntime().gc() in an instrumentation test can be non-deterministic on CI emulators.

In 82565df, updated testShowingHidingMapRepeatedlyReleasesMapViews to assert the deterministic post-onRelease() state on every created MapView (mapView.tag == null, mapView.childCount == 0, mapView.findViewTreeLifecycleOwner() == null, mapView.findViewTreeSavedStateRegistryOwner() == null, and cameraPositionState.projection == null) without depending on GC timing.

This branch has not been deployed

No deployments
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.

OutOfMemory exception when showing/hiding GoogleMaps composable

4 participants