Repository navigation
Conversation
Coverage (unit tests)Compared against
Line and branch coverage from unit test reports. History is recorded on the |
Coverage (instrumentation tests)
Files
|
…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
e1c2594 to
3b093fd
Compare
|
|
||
| override fun onViewAttachedToWindow(mapView: View) { | ||
| lifecycle = mapView.findViewTreeLifecycleOwner()!!.lifecycle.also { | ||
| lifecycle = mapView.findViewTreeLifecycleOwner()?.lifecycle?.also { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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)?
| val onAttachStateListener: () -> Unit, | |
| val detachLifecycleObserver: () -> Unit, |
There was a problem hiding this comment.
Done in 82565df — renamed onAttachStateListener to detachLifecycleObserver in MapTagData and updated all call sites.
|
|
||
| activeState.value = true | ||
| composeTestRule.waitForIdle() | ||
| Thread.sleep(100) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
Summary
Fixes memory retention and teardown ordering issues when
GoogleMapis repeatedly shown/hidden (if (showMap) { GoogleMap(...) }) or deactivated/reused viaReusableContentHost/LazyColumn:GoogleMapListeners andInfoWindowAdapteron Clear:MapApplier.attachClickListeners()registers 9 click/drag listeners andComposeInfoWindowAdapter(mapView, ...)onGoogleMap(wrapped in Binder stubs that hold strong references toMapApplierandMapView), butMapApplier.onClear()never detached them. AddedMapApplier.detachClickListeners()and invoked it fromonClear().MapPropertiesNode.onRemoved()andonCleared()now clear the 4 camera listeners (setOnCameraIdleListener(null),setOnCameraMoveCanceledListener(null),setOnCameraMoveStartedListener(null),setOnCameraMoveListener(null)) andsetLocationSource(null)in addition tocameraPositionState.setMap(null).TileOverlayNode.onRemoved()andonCleared()now cleartileOverlayState.tileOverlay = null.MapViewTeardown:subcompositionJobandsubcomposition(Composition) toMapTagDataon theMapViewinstance soonResetandonReleasedispose the map subcomposition synchronously beforelifecycleObserver.moveToDestroyedState()(mapView.onDestroy()) is invoked, and soupdatecleanly re-launches the subcomposition when a deactivatedMapViewis reused.launchSubcompositiontoDispatchers.Main.immediateand guarded aftermapView.awaitMap()(if (!isActive || mapView.tagDataOrNull !== tagData) return@launch) so rapid show/hide cycles whereonReleaseruns beforegetMapAsynccompletes do not throw aNullPointerExceptiononmapView.tagor create an orphanedComposition.removeOnAttachStateChangeListener, lifecycle observer registration,setViewTreeLifecycleOwner(null),setViewTreeSavedStateRegistryOwner(null), andremoveAllViews()inonRelease.Testing
MapApplierCleanupTestverifying thatMapApplier.onClear(),MapPropertiesNode,TileOverlayNode, andMarkerNodedetach all listeners,InfoWindowAdapter,LocationSource, and state references.testShowingHidingMapRepeatedlyReleasesMapViewsandtestReusableContentHostResetAndReuseinGoogleMapViewTests.hprof) analysis across 30 show/hide cycles that 0GoogleMap/MapApplier/ComposeInfoWindowAdapterinstances remain in the heap.Fixes #581