From 37fc8c37a0f2db7f0fd56f20b69149239d9a23d1 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 13:16:50 -0800 Subject: [PATCH 1/3] Destroy the MapView when the map leaves the composition The MapView was destroyed only on a configuration change; a provider switch left it alive, and the SDK thread behind it kept the controller with every overlay. It is now released through MapViewBase's onDestroyMapView, once, whatever the reason the map went away. The lifecycle observer keeps forwarding onResume / onPause only. --- .../mapconductor/googlemaps/GoogleMapView.kt | 22 ++++--------------- 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/src/main/java/com/mapconductor/googlemaps/GoogleMapView.kt b/src/main/java/com/mapconductor/googlemaps/GoogleMapView.kt index d24b239..22f98e1 100644 --- a/src/main/java/com/mapconductor/googlemaps/GoogleMapView.kt +++ b/src/main/java/com/mapconductor/googlemaps/GoogleMapView.kt @@ -43,7 +43,6 @@ import com.mapconductor.googlemaps.polyline.GoogleMapPolylineOverlayRenderer import com.mapconductor.googlemaps.raster.GoogleMapRasterLayerController import com.mapconductor.googlemaps.raster.GoogleMapRasterLayerOverlayRenderer import okhttp3.OkHttpClient -import android.view.ViewGroup import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.suspendCancellableCoroutine @@ -142,7 +141,6 @@ fun GoogleMapView( // Specific Google Maps DisposableEffect logic val lifecycle = LocalLifecycleOwner.current.lifecycle // Get lifecycle here DisposableEffect(lifecycle) { - val stateId = state.id val observer = object : DefaultLifecycleObserver { override fun onResume(owner: LifecycleOwner) { @@ -152,22 +150,6 @@ fun GoogleMapView( override fun onPause(owner: LifecycleOwner) { holderRef.value?.mapView?.onPause() } - - override fun onDestroy(owner: LifecycleOwner) { - val activity = context.findActivity() - if (activity?.isChangingConfigurations == true) { - holderRef.value?.mapView?.let { - (it.parent as? ViewGroup)?.removeView(it) - it.onDestroy() - } - } else { - // Real teardown (not a configuration change): free the - // controller's overlay controllers (tile-server routes, - // marker managers) and cancel its coroutine scope. - GoogleMapViewControllerStore.get(stateId)?.destroy() - GoogleMapViewControllerStore.remove(stateId) - } - } } lifecycle.addObserver(observer) onDispose { @@ -175,6 +157,10 @@ fun GoogleMapView( } } }, + // The MapView keeps an SDK thread whose listeners reach the controller, + // so it has to be destroyed when the map leaves -- a provider switch as + // much as an Activity going away. + onDestroyMapView = { mapView -> mapView.onDestroy() }, sdkInitialize = { sdkInitialize?.invoke(context) ?: true }, From e011e24ac640902baa181b190dce582e5c043e27 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 14:12:26 -0800 Subject: [PATCH 2/3] Let go of tile overlays when the raster controller is destroyed The SDK keeps the TileProvider of every tile overlay in a process-wide table, even after the overlay is removed and the MapView destroyed (measured). Our provider reached the renderer, its holder and the MapView, so every Google map the user switched away from stayed in memory. The SDK now gets a provider that only forwards; it is cut from the real one when its overlay is removed, retired or the controller destroyed. Destroying the controller also removes every overlay it added and closes the local tile queues, before the MapView is destroyed. --- api/android-for-googlemaps.api | 4 ++ .../raster/GoogleMapRasterLayerController.kt | 2 +- .../GoogleMapRasterLayerOverlayRenderer.kt | 60 +++++++++++++++++-- 3 files changed, 60 insertions(+), 6 deletions(-) diff --git a/api/android-for-googlemaps.api b/api/android-for-googlemaps.api index 7e5265a..1d47c50 100644 --- a/api/android-for-googlemaps.api +++ b/api/android-for-googlemaps.api @@ -329,6 +329,10 @@ public final class com.mapconductor.googlemaps.raster.GoogleMapRasterLayerOverla } final class com.mapconductor.googlemaps.raster.GoogleMapRasterLayerOverlayRenderer$Companion { } +final class com.mapconductor.googlemaps.raster.GoogleMapRasterLayerOverlayRenderer$DetachableTileProvider implements com.google.android.gms.maps.model.TileProvider { + public com.google.android.gms.maps.model.Tile getTile(int, int, int); + public final void detach(); +} final class com.mapconductor.googlemaps.raster.GoogleMapRasterLayerOverlayRenderer$TileSpec { public final java.lang.String getTemplate(); public final int getTileSize(); diff --git a/src/main/java/com/mapconductor/googlemaps/raster/GoogleMapRasterLayerController.kt b/src/main/java/com/mapconductor/googlemaps/raster/GoogleMapRasterLayerController.kt index c004bd7..9f73fef 100644 --- a/src/main/java/com/mapconductor/googlemaps/raster/GoogleMapRasterLayerController.kt +++ b/src/main/java/com/mapconductor/googlemaps/raster/GoogleMapRasterLayerController.kt @@ -32,7 +32,7 @@ internal class GoogleMapRasterLayerController( internal fun onCameraIdle() { tileRenderer.refreshLocalTilesAfterZoom() } override fun destroy() { - tileRenderer.cancelLocalRequests() + tileRenderer.removeAllLayers() super.destroy() } } diff --git a/src/main/java/com/mapconductor/googlemaps/raster/GoogleMapRasterLayerOverlayRenderer.kt b/src/main/java/com/mapconductor/googlemaps/raster/GoogleMapRasterLayerOverlayRenderer.kt index f5dd859..c561856 100644 --- a/src/main/java/com/mapconductor/googlemaps/raster/GoogleMapRasterLayerOverlayRenderer.kt +++ b/src/main/java/com/mapconductor/googlemaps/raster/GoogleMapRasterLayerOverlayRenderer.kt @@ -45,6 +45,13 @@ class GoogleMapRasterLayerOverlayRenderer( /** 差し替え済みで、新しい方のタイルが揃うのを待って外す古いオーバーレイ。 */ private val retiring = mutableSetOf() + /** + * 各オーバーレイに渡したタイルプロバイダ。SDK はオーバーレイを外しても、地図を + * 破棄しても、渡されたプロバイダをプロセス共有の表に残す(実測)。そこから + * このレンダラ・ホルダー・MapView まで辿れてしまうので、外すときに中身を切り離す。 + */ + private val providers = mutableMapOf() + internal fun updateLocalZoom(zoom: Double) { localRequests.values.forEach { it.updateZoom(zoom) } } @@ -62,6 +69,27 @@ class GoogleMapRasterLayerOverlayRenderer( localRequests.clear() } + /** + * Takes every overlay this renderer added off the map. Called when the map + * goes away: the SDK keeps a tile overlay's provider in a process-wide + * table until the overlay is removed, and the provider reaches this + * renderer and through it the map view. Must run on the main thread, before + * the MapView is destroyed. + */ + internal fun removeAllLayers() { + cancelLocalRequests() + (tileActivity.keys + retiring).forEach { overlay -> runCatching { overlay.remove() } } + tileActivity.clear() + retiring.clear() + providers.values.forEach { it.detach() } + providers.clear() + } + + private fun forget(overlay: TileOverlay) { + tileActivity.remove(overlay) + providers.remove(overlay)?.detach() + } + private fun isMarkerTileRaster(state: RasterLayerState): Boolean = state.id.startsWith(MARKER_TILE_RASTER_ID_PREFIX) private fun resolveOverlayZIndex(state: RasterLayerState): Float = @@ -104,10 +132,13 @@ class GoogleMapRasterLayerOverlayRenderer( withContext(coroutine.coroutineContext) { data.forEach { entity -> localRequests.remove(entity.layer)?.close() - tileActivity.remove(entity.layer) + forget(entity.layer) entity.layer.remove() } - retiring.forEach { it.remove() } + retiring.forEach { + forget(it) + it.remove() + } retiring.clear() } } @@ -136,7 +167,7 @@ class GoogleMapRasterLayerOverlayRenderer( delay(RETIRE_POLL_MS) } if (retiring.remove(old)) { - tileActivity.remove(old) + forget(old) old.remove() } } @@ -256,14 +287,18 @@ class GoogleMapRasterLayerOverlayRenderer( } } } + val detachable = DetachableTileProvider(provider) val options = TileOverlayOptions() - .tileProvider(provider) + .tileProvider(detachable) .zIndex(resolveOverlayZIndex(state)) .transparency(opacityToTransparency(state.opacity)) .visible(state.visible) val overlay = holder.map.addTileOverlay(options) - if (overlay != null) tileActivity[overlay] = activity + if (overlay != null) { + tileActivity[overlay] = activity + providers[overlay] = detachable + } if (requests != null) { if (overlay != null) localRequests[overlay] = requests else requests.close() } @@ -304,6 +339,21 @@ class GoogleMapRasterLayerOverlayRenderer( private fun opacityToTransparency(opacity: Float): Float = (1.0f - opacity.coerceIn(0.0f, 1.0f)).coerceIn(0.0f, 1.0f) + /** SDK に渡すのはこれ。[detach] したあとは何も返さず、元のプロバイダも掴まない。 */ + private class DetachableTileProvider( + @Volatile private var delegate: TileProvider?, + ) : TileProvider { + override fun getTile( + x: Int, + y: Int, + zoom: Int, + ): Tile? = delegate?.getTile(x, y, zoom) ?: NO_TILE + + fun detach() { + delegate = null + } + } + private data class TileSpec( val template: String, val tileSize: Int, From 6c5d902e72b304fd76fe9524b183cc2280121ed0 Mon Sep 17 00:00:00 2001 From: Masashi Katsumata Date: Sat, 7 Nov 2026 16:20:26 -0800 Subject: [PATCH 3/3] Read the controller through the state base, which MapViewBase clears The state kept its own controller field, set on attach and never cleared, so a state the app keeps held the destroyed controller and every overlay of the old map. It now reads the base's attached controller, which MapViewBase detaches when the map is torn down. --- .../com/mapconductor/googlemaps/GoogleMapViewStateImpl.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/mapconductor/googlemaps/GoogleMapViewStateImpl.kt b/src/main/java/com/mapconductor/googlemaps/GoogleMapViewStateImpl.kt index 6183fe2..5df8c95 100644 --- a/src/main/java/com/mapconductor/googlemaps/GoogleMapViewStateImpl.kt +++ b/src/main/java/com/mapconductor/googlemaps/GoogleMapViewStateImpl.kt @@ -37,10 +37,13 @@ class GoogleMapViewState( this.controller?.setMapDesignType(value) } get() = _mapDesignType - private var controller: GoogleMapViewControllerInterface? = null + + // Read through the base so that letting go of the controller there (MapViewBase + // does it when the map is torn down) leaves nothing behind here. + private val controller: GoogleMapViewControllerInterface? + get() = attachedMapController as? GoogleMapViewControllerInterface internal fun setController(controller: GoogleMapViewControllerInterface) { - this.controller = controller // コントローラが覚えているデザインを state のものにしておく。既定の None のままだと、 // 変更リスナーを繋いだ時点で None が state へ返ってきて、復元したデザインを潰す。 // 地図は state の mapType で作られているので、地図の見た目は変わらない。