Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReusableContentHost
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.assertIsDisplayed
Expand All @@ -39,6 +40,7 @@ import kotlinx.coroutines.runBlocking
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.lang.ref.WeakReference
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlin.math.abs
Expand Down Expand Up @@ -501,6 +503,103 @@ class GoogleMapViewTests {
.performClick()
}

@Test
fun testShowingHidingMapRepeatedlyReleasesMapViews() {
// Regression test for https://github.com/googlemaps/android-maps-compose/issues/581
val showMapState = mutableStateOf(true)
val mapViews = mutableListOf<WeakReference<MapView>>()
lateinit var markerState: MarkerState

composeTestRule.setContent {
markerState = rememberUpdatedMarkerState(position = startingPosition)
if (showMapState.value) {
GoogleMap(
modifier = Modifier.fillMaxSize(),
cameraPositionState = cameraPositionState,
mapViewFactory = { context, options ->
MapView(context, options).also {
mapViews.add(WeakReference(it))
}
},
) {
MarkerInfoWindowContent(
state = markerState,
title = "Test",
) {
Text("Test Info")
}
}
}
}

// Toggle visibility repeatedly (including rapid cycles before and after map initialization)
repeat(15) {
showMapState.value = true
composeTestRule.waitForIdle()
Thread.sleep(50)
showMapState.value = false
composeTestRule.waitForIdle()
assertThat(cameraPositionState.projection).isNull()
}

// Allow any pending main-thread callbacks (e.g., Compose's accessibility delegate
// periodic prune) to complete and trigger GC
Thread.sleep(250)
composeTestRule.waitForIdle()
Runtime.getRuntime().gc()
System.runFinalization()
Runtime.getRuntime().gc()

assertThat(mapViews.size).isAtLeast(15)
// Every released MapView should have cleared its tag and child views
for (ref in mapViews) {
val mapView = ref.get()
if (mapView != null) {
assertThat(mapView.tag).isNull()
assertThat(mapView.childCount).isEqualTo(0)
}
}
// 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.

}

@Test
fun testReusableContentHostResetAndReuse() {
val activeState = mutableStateOf(true)
lateinit var markerState: MarkerState

composeTestRule.setContent {
markerState = rememberUpdatedMarkerState(position = startingPosition)
ReusableContentHost(active = activeState.value) {
GoogleMap(
modifier = Modifier.fillMaxSize(),
cameraPositionState = cameraPositionState,
) {
Marker(
state = markerState,
title = "Reusable Marker",
)
}
}
}

composeTestRule.waitForIdle()
Thread.sleep(100)

// Deactivate and reactivate multiple times to exercise AndroidView.onReset -> update
repeat(3) {
activeState.value = false
composeTestRule.waitForIdle()
assertThat(cameraPositionState.projection).isNull()

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.

}
}

@Test
fun testRememberUpdatedMarkerStateBeUpdate() {
val testPoint0 = LatLng(0.0, 0.0)
Expand Down
3 changes: 3 additions & 0 deletions maps-compose/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ dependencies {
api(libs.maps.utils.core)

testImplementation(libs.test.junit)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.mockk)
testImplementation(libs.truth)

androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.test.espresso)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@

package com.google.maps.android.compose

import android.content.ComponentCallbacks
import android.content.ComponentCallbacks2
import android.content.Context
import android.content.res.Configuration
Expand Down Expand Up @@ -60,6 +59,7 @@ import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch

/**
Expand Down Expand Up @@ -168,7 +168,6 @@ public fun GoogleMap(

val parentComposition = rememberCompositionContext()
val currentContent by rememberUpdatedState(content)
var subcompositionJob by remember { mutableStateOf<Job?>(null) }
val parentCompositionScope = rememberCoroutineScope()

AndroidView(
Expand Down Expand Up @@ -216,48 +215,55 @@ public fun GoogleMap(

val lifecycleObserver = MapLifecycleEventObserver(mapView)

mapView.tag = MapTagData(
componentCallbacks,
lifecycleObserver,
isLiteMode = isLiteMode,
)

// Only register for [lifecycleOwner]'s lifecycle events while MapView is attached
val onAttachStateListener = object : View.OnAttachStateChangeListener {
private var lifecycle: Lifecycle? = null

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.

it.addObserver(lifecycleObserver)
}
}

override fun onViewDetachedFromWindow(v: View) {
cleanup()
}

fun cleanup() {
lifecycle?.removeObserver(lifecycleObserver)
lifecycle = null
lifecycleObserver.moveToBaseState()
}
}

mapView.tag = MapTagData(
componentCallbacks = componentCallbacks,
lifecycleObserver = lifecycleObserver,
onAttachStateListener = onAttachStateListener::cleanup,
onAttachStateChangeListener = onAttachStateListener,
isLiteMode = isLiteMode,
)

mapView.addOnAttachStateChangeListener(onAttachStateListener)
}
},
onReset = { /* View is detached. */ },
onReset = { mapView ->
mapView.onReset()
},
onRelease = { mapView ->
val (componentCallbacks, lifecycleObserver) = mapView.tagData
mapView.context.unregisterComponentCallbacks(componentCallbacks)
lifecycleObserver.moveToDestroyedState()
mapView.tag = null
mapView.onRelease()
},
update = { mapView ->
mapView.applyFocusability(focusable)
mapView.setViewTreeLifecycleOwner(lifecycleOwner)
mapView.setViewTreeSavedStateRegistryOwner(savedStateRegistryOwner)
if (subcompositionJob == null) {
subcompositionJob = parentCompositionScope.launchSubcomposition(
val tagData = mapView.tagDataOrNull
if (tagData != null && tagData.subcompositionJob == null) {
tagData.subcompositionJob = parentCompositionScope.launchSubcomposition(
mapUpdaterState,
parentComposition,
mapView,
tagData,
mapClickListeners,
currentContent,
)
Expand Down Expand Up @@ -287,30 +293,35 @@ private fun MapView.applyFocusability(focusable: Boolean) {
* Create and apply the [content] compositions to the map +
* dispose the [Composition] when the parent composable is disposed.
* */
private fun CoroutineScope.launchSubcomposition(
internal fun CoroutineScope.launchSubcomposition(
mapUpdaterState: MapUpdaterState,
parentComposition: CompositionContext,
mapView: MapView,
tagData: MapTagData,
mapClickListeners: MapClickListeners,
content:
@Composable @GoogleMapComposable
() -> Unit,
): Job {
// Use [CoroutineStart.UNDISPATCHED] to kick off GoogleMap loading immediately
return launch(
context = Dispatchers.Main,
context = Dispatchers.Main.immediate,
start = CoroutineStart.UNDISPATCHED,
) {
val map = mapView.awaitMap()
if (!isActive || mapView.tagDataOrNull !== tagData) {
return@launch
}
val composition = Composition(
applier = MapApplier(
map,
mapView,
mapClickListeners,
isLiteMode = mapView.tagData.isLiteMode,
isLiteMode = tagData.isLiteMode,
),
parent = parentComposition,
)
tagData.subcomposition = composition

try {
composition.setContent {
Expand All @@ -325,6 +336,9 @@ private fun CoroutineScope.launchSubcomposition(
}
awaitCancellation()
} finally {
if (tagData.subcomposition === composition) {
tagData.subcomposition = null
}
composition.dispose()
}
}
Expand Down Expand Up @@ -352,14 +366,45 @@ internal class MapUpdaterState(
}

/** Used to store things in the tag which must be retrievable across recompositions */
private data class MapTagData(
val componentCallbacks: ComponentCallbacks,
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.

val onAttachStateChangeListener: View.OnAttachStateChangeListener,
val isLiteMode: Boolean,
)
) {
var subcompositionJob: Job? = null
var subcomposition: Composition? = null

fun disposeSubcomposition() {
subcompositionJob?.cancel()
subcompositionJob = null
subcomposition?.dispose()
subcomposition = null
}
}

private val MapView.tagData: MapTagData
get() = tag as MapTagData
internal val MapView.tagDataOrNull: MapTagData?
get() = tag as? MapTagData

internal fun MapView.onReset() {
tagDataOrNull?.disposeSubcomposition()
}

internal fun MapView.onRelease() {
val tagData = tagDataOrNull
tagData?.disposeSubcomposition()
if (tagData != null) {
context.unregisterComponentCallbacks(tagData.componentCallbacks)
removeOnAttachStateChangeListener(tagData.onAttachStateChangeListener)
tagData.onAttachStateListener()
tagData.lifecycleObserver.moveToDestroyedState()
}
setViewTreeLifecycleOwner(null)
setViewTreeSavedStateRegistryOwner(null)
removeAllViews()
tag = null
}

public typealias GoogleMapFactory = @Composable () -> Unit

Expand Down Expand Up @@ -398,7 +443,7 @@ public fun googleMapFactory(
}
}

private class MapLifecycleEventObserver(private val mapView: MapView) : LifecycleEventObserver {
internal class MapLifecycleEventObserver(private val mapView: MapView) : LifecycleEventObserver {
private var currentLifecycleState: Lifecycle.State = Lifecycle.State.INITIALIZED

override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
map.clear()
decorations.forEach { it.onCleared() }
decorations.clear()
detachClickListeners()
}

override fun insertBottomUp(index: Int, instance: MapNode) {
Expand Down Expand Up @@ -221,6 +222,19 @@
),
)
}

internal fun detachClickListeners() {
map.setOnCircleClickListener(null)
map.setOnGroundOverlayClickListener(null)
map.setOnPolygonClickListener(null)
map.setOnPolylineClickListener(null)
map.setOnMarkerClickListener(null)
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
map.setOnInfoWindowClickListener(null)
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
map.setOnInfoWindowCloseListener(null)
map.setOnInfoWindowLongClickListener(null)
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
map.setOnMarkerDragListener(null)
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
map.setInfoWindowAdapter(null)
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,20 @@ internal class MapPropertiesNode(
}

override fun onRemoved() {
cameraPositionState.setMap(null)
clearMapReferences()
}

override fun onCleared() {
clearMapReferences()
}

private fun clearMapReferences() {
cameraPositionState.setMap(null)
map.setOnCameraIdleListener(null)
map.setOnCameraMoveCanceledListener(null)
map.setOnCameraMoveStartedListener(null)
map.setOnCameraMoveListener(null)
map.setLocationSource(null)
}
}

Expand Down
Loading
Loading