android-maps-compose icon indicating copy to clipboard operation
android-maps-compose copied to clipboard

OutOfMemory exception when showing/hiding GoogleMaps composable

Open dudeck opened this issue 1 year ago • 19 comments

Environment details

  1. Specify the API at the beginning of the title (for example, "Places: ...") Google Maps Compose Android

  2. OS type and version Mac OS Sonoma 14.5 Android API 29 emulator

  3. Library version and other environment information gms-maps-compose = "com.google.maps.android:maps-compose:5.0.3" gms-maps-compose-utils = "com.google.maps.android:maps-compose-utils:5.0.3"

Steps to reproduce

  1. Add yours Google Maps API Key.
  2. Create Android Emulator like Pixel 3, API 29 arm64 with GooglePlay.
  3. Build app from attached code.
  4. Click multiple times on Show/Hide Map button.
  5. After at least 15 tries application crashes with OutOfMemory Exception
  6. See how Memory consumption is rising after each time map is shown.

Code example

// Copyright 2021 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package com.example.GMC

import android.os.Bundle
import android.util.Log
import android.view.MotionEvent
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Button
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInteropFilter
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import com.google.android.gms.maps.model.CameraPosition
import com.google.android.gms.maps.model.LatLng
import com.google.android.gms.maps.model.Marker
import com.google.android.gms.maps.model.StrokeStyle
import com.google.android.gms.maps.model.StyleSpan
import com.google.maps.android.compose.CameraPositionState
import com.google.maps.android.compose.GoogleMap
import com.google.maps.android.compose.MapProperties
import com.google.maps.android.compose.MapType
import com.google.maps.android.compose.MapUiSettings
import com.google.maps.android.compose.MarkerInfoWindowContent
import com.google.maps.android.compose.rememberCameraPositionState
import com.google.maps.android.compose.rememberMarkerState

private const val TAG = "ScrollingMapActivity"

val singapore = LatLng(1.3588227, 103.8742114)
val singapore2 = LatLng(1.40, 103.77)
val singapore3 = LatLng(1.45, 103.77)
val singapore4 = LatLng(1.50, 103.77)
val singapore5 = LatLng(1.3418, 103.8461)
val singapore6 = LatLng(1.3430, 103.8844)
val singapore7 = LatLng(1.3430, 103.9116)
val singapore8 = LatLng(1.3300, 103.8624)
val singapore9 = LatLng(1.3200, 103.8541)
val singapore10 = LatLng(1.3200, 103.8765)

val defaultCameraPosition = CameraPosition.fromLatLngZoom(singapore, 11f)

val styleSpan = StyleSpan(
    StrokeStyle.gradientBuilder(
        Color.Red.toArgb(),
        Color.Green.toArgb(),
    ).build(),
)

class MapInColumnActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            // Observing and controlling the camera's state can be done with a CameraPositionState
            val cameraPositionState = rememberCameraPositionState {
                position = defaultCameraPosition
            }
            var columnScrollingEnabled by remember { mutableStateOf(true) }

            // Use a LaunchedEffect keyed on the camera moving state to enable column scrolling when the camera stops moving
            LaunchedEffect(cameraPositionState.isMoving) {
                if (!cameraPositionState.isMoving) {
                    columnScrollingEnabled = true
                    Log.d(TAG, "Map camera stopped moving - Enabling column scrolling...")
                }
            }

            MapInColumn(
                modifier = Modifier.fillMaxSize(),
                cameraPositionState,
                columnScrollingEnabled = columnScrollingEnabled,
                onMapTouched = {
                    columnScrollingEnabled = false
                    Log.d(
                        TAG,
                        "User touched map - Disabling column scrolling after user touched this Box..."
                    )
                },
                onMapLoaded = { }
            )
        }
    }
}

@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun MapInColumn(
    modifier: Modifier = Modifier,
    cameraPositionState: CameraPositionState,
    columnScrollingEnabled: Boolean,
    onMapTouched: () -> Unit,
    onMapLoaded: () -> Unit,
) {
    Surface(
        modifier = modifier,
        color = MaterialTheme.colors.background
    ) {
        var isMapLoaded by remember { mutableStateOf(false) }
        var showMap by remember { mutableStateOf(true) }

        Column(
            Modifier
                .fillMaxSize()
                .verticalScroll(
                    rememberScrollState(),
                    columnScrollingEnabled
                ),
            horizontalAlignment = Alignment.Start
        ) {
            Spacer(modifier = Modifier.padding(10.dp))
            Button(onClick = { showMap = !showMap }) {
                Text(text = "Show/Hide map")
            }
            for (i in 1..20) {
                Text(
                    text = "Item $i",
                    modifier = Modifier
                        .padding(start = 10.dp)
                        .testTag("Item $i")
                )
            }
            Spacer(modifier = Modifier.padding(10.dp))

            Box(
                Modifier
                    .fillMaxWidth()
                    .height(200.dp)
            ) {
                if (showMap) GoogleMapViewInColumn(
                    modifier = Modifier
                        .fillMaxSize()
                        .testTag("Map")
                        .pointerInteropFilter(
                            onTouchEvent = {
                                when (it.action) {
                                    MotionEvent.ACTION_DOWN -> {
                                        onMapTouched()
                                        false
                                    }

                                    else -> {
                                        Log.d(
                                            TAG,
                                            "MotionEvent ${it.action} - this never triggers."
                                        )
                                        true
                                    }
                                }
                            }
                        ),
                    cameraPositionState = cameraPositionState,
                    onMapLoaded = {
                        isMapLoaded = true
                        onMapLoaded()
                    },
                )
                if (!isMapLoaded) {
                    androidx.compose.animation.AnimatedVisibility(
                        modifier = Modifier
                            .fillMaxSize(),
                        visible = !isMapLoaded,
                        enter = EnterTransition.None,
                        exit = fadeOut()
                    ) {
                        CircularProgressIndicator(
                            modifier = Modifier
                                .background(MaterialTheme.colors.background)
                                .wrapContentSize()
                        )
                    }
                }
            }
            Spacer(modifier = Modifier.padding(10.dp))
            for (i in 21..40) {
                Text(
                    text = "Item $i",
                    modifier = Modifier
                        .padding(start = 10.dp)
                        .testTag("Item $i")
                )
            }
            Spacer(modifier = Modifier.padding(10.dp))
        }
    }
}

@Composable
private fun GoogleMapViewInColumn(
    modifier: Modifier,
    cameraPositionState: CameraPositionState,
    onMapLoaded: () -> Unit,
) {
    val singaporeState = rememberMarkerState(position = singapore)

    var uiSettings by remember { mutableStateOf(MapUiSettings(compassEnabled = false)) }
    var mapProperties by remember {
        mutableStateOf(MapProperties(mapType = MapType.NORMAL))
    }

    GoogleMap(
        modifier = modifier,
        cameraPositionState = cameraPositionState,
        properties = mapProperties,
        uiSettings = uiSettings,
        onMapLoaded = onMapLoaded
    ) {
        // Drawing on the map is accomplished with a child-based API
        val markerClick: (Marker) -> Boolean = {
            Log.d(TAG, "${it.title} was clicked")
            cameraPositionState.projection?.let { projection ->
                Log.d(TAG, "The current projection is: $projection")
            }
            false
        }
        MarkerInfoWindowContent(
            state = singaporeState,
            title = "Singapore",
            onClick = markerClick,
            draggable = true,
        ) {
            Text(it.title ?: "Title", color = Color.Red)
        }
    }
}

Stack trace

java.lang.OutOfMemoryError: Failed to allocate a 118720 byte allocation with 106056 free bytes and 103KB until OOM, target footprint 50331648, growth limit 50331648
                                                                                                    	at m.fdu.d(:com.google.android.gms.policy_maps_core_dynamite@[email protected]:22)
                                                                                                    	at m.fea.c(:com.google.android.gms.policy_maps_core_dynamite@[email protected]:13)
                                                                                                    	at m.enc.k(:com.google.android.gms.policy_maps_core_dynamite@[email protected]:8)
                                                                                                    	at m.etl.a(:com.google.android.gms.policy_maps_core_dynamite@[email protected]:13)
                                                                                                    	at m.eum.d(:com.google.android.gms.policy_maps_core_dynamite@[email protected]:116)
                                                                                                    	at m.erb.run(:com.google.android.gms.policy_maps_core_dynamite@[email protected]:164)
                                                                                                    	at java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:462)
                                                                                                    	at m.cab.run(:com.google.android.gms.policy_maps_core_dynamite@[email protected]:12)
                                                                                                    	at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1167)
                                                                                                    	at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:641)
                                                                                                    	at m.car.run(:com.google.android.gms.policy_maps_core_dynamite@[email protected]:40)

Note: I checked in Memory Profiler that memory increases from around 350 MB to 450MB and then crashes. It is easy to reproduce, need some older/low end device (tested on emulators). It blocks us of releasing feature to the client. I used your sample code from GoogleMaps repo:https://github.com/googlemaps/android-maps-compose just modifying by adding Button to change visibility state of Google maps in column.

Could you fix it, please? Or at least give us some temporary quick fix solution? Thank you in advance.

dudeck avatar Jun 11 '24 13:06 dudeck

Can be triggered with this minimal example

class MinimumMapActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            var showMap by remember { mutableStateOf(true) }

            val cameraPositionState: CameraPositionState = rememberCameraPositionState() {
                position = defaultCameraPosition
            }

            val uiSettings by remember { mutableStateOf(MapUiSettings(compassEnabled = false)) }

            val mapProperties by remember {
                mutableStateOf(MapProperties(mapType = MapType.NORMAL))
            }

            LaunchedEffect(Unit) {
                while (true) {
                    delay(100.milliseconds.toJavaDuration())
                    showMap = !showMap
                }
            }

            if (showMap) {
                GoogleMap(
                    modifier = Modifier.fillMaxSize(),
                    cameraPositionState = cameraPositionState,
                    properties = mapProperties,
                    uiSettings = uiSettings,
                )
            }
        }
    }
}

Stack trace:

Process: com.example.memorybug, PID: 10996 java.lang.OutOfMemoryError: Failed to allocate a 475987 byte allocation with 245072 free bytes and 239KB until OOM, target footprint 50331648, growth limit 50331648 at dalvik.system.VMRuntime.newNonMovableArray(Native Method) at java.nio.DirectByteBuffer$MemoryRef.(DirectByteBuffer.java:70) at java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:258) at m.fab.Y(:com.google.android.gms.policy_maps_core_dynamite@[email protected]:8) at m.fab.B(:com.google.android.gms.policy_maps_core_dynamite@[email protected]:1) at m.fdf.a(:com.google.android.gms.policy_maps_core_dynamite@[email protected]:12) at m.fdj.a(:com.google.android.gms.policy_maps_core_dynamite@[email protected]:29) at m.ezb.x(:com.google.android.gms.policy_maps_core_dynamite@[email protected]:12) at m.fbq.a(:com.google.android.gms.policy_maps_core_dynamite@[email protected]:25) at m.ezm.e(:com.google.android.gms.policy_maps_core_dynamite@[email protected]:170) at m.ezw.run(:com.google.android.gms.policy_maps_core_dynamite@[email protected]:2346)

dkhawk avatar Jun 11 '24 15:06 dkhawk

I am unfortunately not able to reproduce this on the emulator, @dkhawk . Are you using a real device?

kikoso avatar Jun 18 '24 14:06 kikoso

This was on a pixel 6 (IIRC).

dkhawk avatar Jun 18 '24 14:06 dkhawk

maybe this will give you some ideas

Data

  • device: Pixel 5a
  • test from the comment above

Test

  1. set delay = 100ms --> OOM happens in a minute (image 1)
  2. set delay = 300ms --> memory increases a bit, but without exception (3 minutes of stable open-close, image 2)

изображение изображение

el-qq avatar Jul 05 '24 13:07 el-qq

maybe the problem is somewhere in these lines (deleted all but this one in fun GoogleMap)

    val context = LocalContext.current
    val mapView = remember { MapView(context, googleMapOptionsFactory()) }
    MapLifecycle(mapView)

el-qq avatar Jul 08 '24 06:07 el-qq

maybe this will give you some ideas

Data

  • device: Pixel 5a
  • test from the comment above

Test

  1. set delay = 100ms --> OOM happens in a minute (image 1)
  2. set delay = 300ms --> memory increases a bit, but without exception (3 minutes of stable open-close, image 2)

изображение изображение

In my case I was just tapping button Show/Hide 1-3s each time image

dudeck avatar Jul 09 '24 09:07 dudeck

Did you try again after the last release?

philip-segerfast avatar Jul 15 '24 17:07 philip-segerfast

yes is repeated in the example in this comment

el-qq avatar Jul 15 '24 17:07 el-qq

Using androidx.compose.runtime.ReusableContentHost() or friends would likely get rid of the OOM. This will leverage the ReusableComposition support from the recent release. SubcomposeLayout can be used for this as well, but is more complex.

bubenheimer avatar Jul 15 '24 19:07 bubenheimer

Using androidx.compose.runtime.ReusableContentHost() or friends would likely get rid of the OOM. This will leverage the ReusableComposition support from the recent release. SubcomposeLayout can be used for this as well, but is more complex.

I can confirm that wrapping my Composable into ReusableContentHost(active = true) fixed OOM. Thank you for this workaround!

dudeck avatar Jul 17 '24 12:07 dudeck

@dudeck To clarify, I had this in mind: ReusableContentHost(active = showMap) { GoogleMapViewInColumn(...) } instead of if (showMap) { GoogleMapViewInColumn(...) }

I would not expect the OOM to go away in the above code if you merely wrap it while keeping active = true at all times, but maybe it changes the GC behavior or something.

bubenheimer avatar Jul 17 '24 12:07 bubenheimer

@bubenheimer yes, you are right. I mean when I use like you said it works only if my composable with map is NOT INSIDE a LazyColumn (as LazyListScope item). Otherwise I'm receiving:

FATAL EXCEPTION: main      Process: com.example, PID: 13495                                                                                                    java.lang.IllegalArgumentException: measure is called on a deactivated node
at androidx.compose.ui.node.LayoutNodeLayoutDelegate$MeasurePassDelegate.remeasure-BRTryo0(LayoutNodeLayoutDelegate.kt:604)
at androidx.compose.ui.node.LayoutNodeLayoutDelegate$MeasurePassDelegate.measure-BRTryo0(LayoutNodeLayoutDelegate.kt:596)
at androidx.compose.foundation.layout.BoxMeasurePolicy.measure-3p2s80s(Box.kt:122)
at androidx.compose.ui.node.InnerNodeCoordinator.measure-BRTryo0(InnerNodeCoordinator.kt:126)
at androidx.compose.foundation.layout.SizeNode.measure-3p2s80s(Size.kt:838)
at androidx.compose.ui.node.LayoutModifierNodeCoordinator.measure-BRTryo0(LayoutModifierNodeCoordinator.kt:116)
at androidx.compose.foundation.layout.PaddingNode.measure-3p2s80s(Padding.kt:397)
at androidx.compose.ui.node.LayoutModifierNodeCoordinator.measure-BRTryo0(LayoutModifierNodeCoordinator.kt:116)
at androidx.compose.foundation.layout.FillNode.measure-3p2s80s(Size.kt:699)
at androidx.compose.ui.node.LayoutModifierNodeCoordinator.measure-BRTryo0(LayoutModifierNodeCoordinator.kt:116)
at androidx.compose.ui.node.LayoutNodeLayoutDelegate$performMeasureBlock$1.invoke(LayoutNodeLayoutDelegate.kt:252)
at androidx.compose.ui.node.LayoutNodeLayoutDelegate$performMeasureBlock$1.invoke(LayoutNodeLayoutDelegate.kt:251)
at androidx.compose.runtime.snapshots.Snapshot$Companion.observe(Snapshot.kt:2303)
at androidx.compose.runtime.snapshots.SnapshotStateObserver$ObservedScopeMap.observe(SnapshotStateObserver.kt:500)
at androidx.compose.runtime.snapshots.SnapshotStateObserver.observeReads(SnapshotStateObserver.kt:256)
at androidx.compose.ui.node.OwnerSnapshotObserver.observeReads$ui_release(OwnerSnapshotObserver.kt:133)
at androidx.compose.ui.node.OwnerSnapshotObserver.observeMeasureSnapshotReads$ui_release(OwnerSnapshotObserver.kt:113)
at androidx.compose.ui.node.LayoutNodeLayoutDelegate.performMeasure-BRTryo0(LayoutNodeLayoutDelegate.kt:1617)
at androidx.compose.ui.node.LayoutNodeLayoutDelegate.access$performMeasure-BRTryo0(LayoutNodeLayoutDelegate.kt:36)
at androidx.compose.ui.node.LayoutNodeLayoutDelegate$MeasurePassDelegate.remeasure-BRTryo0(LayoutNodeLayoutDelegate.kt:620)
at androidx.compose.ui.node.LayoutNode.remeasure-_Sx5XlM$ui_release(LayoutNode.kt:1145)
at androidx.compose.ui.node.LayoutNode.remeasure-_Sx5XlM$ui_release$default(LayoutNode.kt:1136)
at androidx.compose.ui.node.MeasureAndLayoutDelegate.doRemeasure-sdFAvZA(MeasureAndLayoutDelegate.kt:356)
at androidx.compose.ui.node.MeasureAndLayoutDelegate.remeasureAndRelayoutIfNeeded(MeasureAndLayoutDelegate.kt:514)
at androidx.compose.ui.node.MeasureAndLayoutDelegate.remeasureAndRelayoutIfNeeded$default(MeasureAndLayoutDelegate.kt:491)
at androidx.compose.ui.node.MeasureAndLayoutDelegate.measureAndLayout(MeasureAndLayoutDelegate.kt:377)
at androidx.compose.ui.platform.AndroidComposeView.measureAndLayout(AndroidComposeView.android.kt:971)
at androidx.compose.ui.node.Owner.measureAndLayout$default(Owner.kt:228)
at androidx.compose.ui.platform.AndroidComposeView.dispatchDraw(AndroidComposeView.android.kt:1224)
at android.view.View.draw(View.java:21424)
at android.view.View.updateDisplayListIfDirty(View.java:20298)
at android.view.ViewGroup.recreateChildDisplayList(ViewGroup.java:4372)
at android.view.ViewGroup.dispatchGetDisplayList(ViewGroup.java:4345)
at android.view.View.updateDisplayListIfDirty(View.java:20258)
at android.view.ViewGroup.recreateChildDisplayList(ViewGroup.java:4372)
at android.view.ViewGroup.dispatchGetDisplayList(ViewGroup.java:4345)
at android.view.View.updateDisplayListIfDirty(View.java:20258)
at android.view.ViewGroup.recreateChildDisplayList(ViewGroup.java:4372)
at android.view.ViewGroup.dispatchGetDisplayList(ViewGroup.java:4345)
at android.view.View.updateDisplayListIfDirty(View.java:20258)
at android.view.ViewGroup.recreateChildDisplayList(ViewGroup.java:4372)

I need to place my component inside other list. This exception does not occurs only when I'm wrapping it with a box with set by modifier hardcoded height and when showMap == false it will deactivate google map composable node and leave empty place in a column with that hardcoded height. Unfortunately, it is not my case.

dudeck avatar Jul 24 '24 07:07 dudeck

LazyColumnn has ReusableComposition built in these days, so I guess an additional ReusableContentHost seems odd.

bubenheimer avatar Jul 24 '24 08:07 bubenheimer

Thanks, so is it possible to use ReusableComposition to have GoogleMaps composable inside LazyColumn that would get rid off OOM issue or maybe could you suggest other solution ? In addition, does anyone plan from @googlemaps team to fix this issue in next near releases?

dudeck avatar Jul 25 '24 07:07 dudeck

@dudeck not sure without experimenting or digging into impl. I assume LazyColumn would deactivate the reusable composition when an item falls off the radar. That should deactivate an item's Google map, and it would reactivate when the item gets recycled. If you structure your code around this pattern then I'd imagine it would let you avoid OOM. I've never looked at it deeply, though. YMMV

bubenheimer avatar Jul 25 '24 15:07 bubenheimer

FYI, I suffered from this bug quite a bit as well. My investigation is that the frequent OnDestroy events whenever the "back" or the "power" buttons are pressed significantly increase the OOM issue. Therefore I worked around the problem by reducing the Ondestroy through adding android:configChanges="orientation|screenLayout|screenSize|keyboardHidden" on the activity in the AndroidManifest.xml. Arguably this is NOT a good practice. It is merely a workaround but it solved my issues and while awaiting a future bugfix from Google at least I can proceed. Just wanted to share that here. Not a solution but a workaround worthwhile investigating.

KrisBogaert avatar Jan 21 '25 16:01 KrisBogaert

@KrisBogaert curious why you consider this "NOT a good practice". I agree that it is a workaround, but I assumed it was a common and favorable pattern for pure Compose apps.

bubenheimer avatar Jan 21 '25 17:01 bubenheimer

@bubenheimer Thanks for your reaction. Re-reading my comment I should have written is "not an optimal practise" according to the book because with fewer OnDestroys being handled de facto you keep your app longer in memory while otherwise you-re freeing memory that (using the savedinstancestate Bundle) is only used again with the next OnCreate.

With that in mind, one would expect that shortly after the OnDestroy, the Heap space that was used by GoogleMap objects are garbage collected. But for some reason (likely somewhere a reference remains), so nothing is GC-ed. Consequently with frequent OnCreate/OnDestroy events new GoogleMap object trees are created and at some point your process gets OOM. I can't prove that claim but at least to me it makes sense. Hence with fewer OnDestroys, the app keeps going.

KrisBogaert avatar Jan 23 '25 18:01 KrisBogaert

Re-reading my comment I should have written is "not an optimal practise" according to the book because with fewer OnDestroys being handled de facto you keep your app longer in memory while otherwise you-re freeing memory that (using the savedinstancestate Bundle) is only used again with the next OnCreate.

@KrisBogaert Still curious: what book? And can you quote what it says? I am not following the above rationale. Eager to learn something new.

With that in mind, one would expect that shortly after the OnDestroy, the Heap space that was used by GoogleMap objects are garbage collected. But for some reason (likely somewhere a reference remains), so nothing is GC-ed. Consequently with frequent OnCreate/OnDestroy events new GoogleMap object trees are created and at some point your process gets OOM. I can't prove that claim but at least to me it makes sense. Hence with fewer OnDestroys, the app keeps going.

It's great if the configChanges workaround helps the situation a bit. I'm actually surprised that it does, I would not typically expect power and back buttons to be related to configuration changes.

bubenheimer avatar Jan 23 '25 19:01 bubenheimer