Skip to content

Commit c5301b7

Browse files
pengdevgithub-actions[bot]
authored andcommitted
Offload display refresh rate retrieval off main thread to prevent ANR (#5326)
GitOrigin-RevId: fe056dab2772fc942ff9e1db23cd5a4e81e70c0a
1 parent 1d0a4b9 commit c5301b7

File tree

8 files changed

+403
-10
lines changed

8 files changed

+403
-10
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ Mapbox welcomes participation and contributions from everyone.
44

55
# main
66

7+
## Bug fixes 🐞
8+
* Fix potential ANR (Application Not Responding) issue when retrieving display refresh rate during map initialization by offloading the system call to a background thread with proper timeout and fallback handling.
9+
710

811
# 11.14.0
912
## Bug fixes 🐞

maps-sdk/src/main/java/com/mapbox/maps/MapController.kt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ import com.mapbox.maps.renderer.OnFpsChangedListener
4040
import com.mapbox.maps.renderer.RenderThreadStatsRecorder
4141
import com.mapbox.maps.renderer.RendererSetupErrorListener
4242
import com.mapbox.maps.renderer.widget.Widget
43+
import kotlinx.coroutines.CoroutineName
44+
import kotlinx.coroutines.CoroutineScope
45+
import kotlinx.coroutines.Dispatchers
46+
import kotlinx.coroutines.SupervisorJob
47+
import kotlinx.coroutines.cancel
48+
import kotlinx.coroutines.plus
4349
import java.util.concurrent.CopyOnWriteArraySet
4450

4551
@RestrictTo(RestrictTo.Scope.LIBRARY)
@@ -56,6 +62,15 @@ internal class MapController : MapPluginProviderDelegate, MapControllable {
5662
private val cameraChangedCoalescedCallback: CameraChangedCoalescedCallback
5763
private val cancelableSubscriberSet = CopyOnWriteArraySet<Cancelable>()
5864

65+
/**
66+
* Lifecycle-aware coroutine scope that follows the MapView/MapSurface lifecycle.
67+
* This scope is created when the MapController is constructed and cancelled when destroyed.
68+
* Can be used for any background operations that should be tied to the map lifecycle.
69+
*/
70+
internal val lifecycleScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + CoroutineName(
71+
"MapControllerLifecycleScope"
72+
)
73+
5974
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
6075
internal var lifecycleState: LifecycleState = LifecycleState.STATE_STOPPED
6176
private var style: Style? = null
@@ -207,6 +222,10 @@ internal class MapController : MapPluginProviderDelegate, MapControllable {
207222
return
208223
}
209224
lifecycleState = LifecycleState.STATE_DESTROYED
225+
226+
// Cancel the lifecycle coroutine scope to prevent memory leaks
227+
lifecycleScope.cancel()
228+
210229
pluginRegistry.onDestroy()
211230
nativeObserver.onDestroy()
212231
renderer.onDestroy()

maps-sdk/src/main/java/com/mapbox/maps/MapSurface.kt

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import com.mapbox.maps.plugin.MapPlugin
1111
import com.mapbox.maps.plugin.delegates.MapPluginProviderDelegate
1212
import com.mapbox.maps.renderer.*
1313
import com.mapbox.maps.renderer.widget.Widget
14+
import kotlinx.coroutines.launch
1415

1516
/**
1617
* A [MapSurface] provides an embeddable map interface.
@@ -86,11 +87,22 @@ class MapSurface : MapPluginProviderDelegate, MapControllable {
8687
*/
8788
fun surfaceCreated() {
8889
renderer.surfaceCreated()
89-
// display should not be null at this point but to be sure we will fallback to DEFAULT_FPS
90-
@Suppress("DEPRECATION")
91-
val screenRefreshRate = (context.getSystemService(Context.WINDOW_SERVICE) as WindowManager?)
92-
?.defaultDisplay?.refreshRate?.toInt() ?: MapView.DEFAULT_FPS
93-
mapController.setScreenRefreshRate(screenRefreshRate)
90+
// Set default refresh rate immediately to ensure map controller has a valid value
91+
mapController.setScreenRefreshRate(MapView.DEFAULT_FPS)
92+
// Retrieve screen refresh rate off the main thread to prevent ANR
93+
mapController.lifecycleScope.launch {
94+
safeSystemCallWithCallback(
95+
fallback = MapView.DEFAULT_FPS,
96+
logTag = TAG,
97+
operation = {
98+
@Suppress("DEPRECATION")
99+
(context.getSystemService(Context.WINDOW_SERVICE) as WindowManager?)
100+
?.defaultDisplay?.refreshRate?.toInt() ?: MapView.DEFAULT_FPS
101+
}
102+
) { screenRefreshRate ->
103+
mapController.setScreenRefreshRate(screenRefreshRate)
104+
}
105+
}
94106
}
95107

96108
/**
@@ -293,4 +305,8 @@ class MapSurface : MapPluginProviderDelegate, MapControllable {
293305
* @return created plugin instance or null if no plugin is found for given id.
294306
*/
295307
override fun <T : MapPlugin> getPlugin(id: String): T? = mapController.getPlugin(id)
308+
309+
private companion object {
310+
private const val TAG = "MapSurface"
311+
}
296312
}

maps-sdk/src/main/java/com/mapbox/maps/MapView.kt

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import com.mapbox.maps.renderer.egl.EGLCore
2626
import com.mapbox.maps.renderer.widget.Widget
2727
import com.mapbox.maps.viewannotation.ViewAnnotationManager
2828
import com.mapbox.maps.viewannotation.ViewAnnotationManagerImpl
29+
import kotlinx.coroutines.launch
2930
import java.nio.IntBuffer
3031
import kotlin.math.hypot
3132

@@ -228,9 +229,21 @@ open class MapView : FrameLayout, MapPluginProviderDelegate, MapControllable {
228229
* @see android.app.Fragment.onStart
229230
*/
230231
override fun onStart() {
231-
// display should not be null at this point but to be sure we will fallback to DEFAULT_FPS
232-
val screenRefreshRate = display?.refreshRate?.toInt() ?: DEFAULT_FPS
233-
mapController.setScreenRefreshRate(screenRefreshRate)
232+
// Set default refresh rate immediately to ensure map controller has a valid value
233+
mapController.setScreenRefreshRate(DEFAULT_FPS)
234+
// Retrieve screen refresh rate off the main thread to prevent ANR
235+
mapController.lifecycleScope.launch {
236+
safeSystemCallWithCallback(
237+
fallback = DEFAULT_FPS,
238+
logTag = TAG,
239+
operation = {
240+
display?.refreshRate?.toInt() ?: DEFAULT_FPS
241+
}
242+
) { screenRefreshRate ->
243+
mapController.setScreenRefreshRate(screenRefreshRate)
244+
}
245+
}
246+
234247
mapController.onStart()
235248
if (debugOptionsControllerDelegate.isInitialized()) {
236249
debugOptionsController.started = true
@@ -554,6 +567,8 @@ open class MapView : FrameLayout, MapPluginProviderDelegate, MapControllable {
554567
@JvmSynthetic
555568
internal const val DEFAULT_FPS = 60
556569

570+
private const val TAG = "MapView"
571+
557572
/**
558573
* Static method to check if [MapView] could properly render on this device.
559574
* This method may take some time on slow devices.

maps-sdk/src/main/java/com/mapbox/maps/Utils.kt

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ import android.content.Context
44
import android.util.TypedValue
55
import com.mapbox.bindgen.Expected
66
import com.mapbox.common.Cancelable
7+
import kotlinx.coroutines.CoroutineDispatcher
8+
import kotlinx.coroutines.Dispatchers
79
import kotlinx.coroutines.suspendCancellableCoroutine
10+
import kotlinx.coroutines.withContext
11+
import kotlinx.coroutines.withTimeoutOrNull
812
import java.lang.ref.WeakReference
913
import kotlin.coroutines.Continuation
1014

@@ -33,4 +37,66 @@ internal fun <T : Number> T.toDP(context: Context): T {
3337
return TypedValue.applyDimension(
3438
TypedValue.COMPLEX_UNIT_DIP, this.toFloat(), context.resources.displayMetrics
3539
) as T
40+
}
41+
42+
/**
43+
* Executes a potentially blocking system call off the main thread with timeout and error handling.
44+
* This utility helps prevent ANRs caused by blocking IPC calls to system services.
45+
*
46+
* Uses [Dispatchers.Default] as system calls are CPU-bound operations involving IPC to system services.
47+
*
48+
* @param timeoutMs Timeout in milliseconds for the operation (default: 5000ms)
49+
* @param fallback Fallback value to return if the operation fails or times out
50+
* @param logTag Tag for logging errors (default: "SystemCall")
51+
* @param dispatcher The dispatcher to use for the operation (default: Dispatchers.Default)
52+
* @param operation The blocking operation to execute
53+
* @return The result of the operation or the fallback value
54+
*/
55+
internal suspend fun <T> safeSystemCall(
56+
timeoutMs: Long = 5000L,
57+
fallback: T,
58+
logTag: String = "SystemCall",
59+
dispatcher: CoroutineDispatcher = Dispatchers.Default,
60+
operation: suspend () -> T
61+
): T {
62+
return try {
63+
withTimeoutOrNull(timeoutMs) {
64+
withContext(dispatcher) {
65+
operation()
66+
}
67+
} ?: run {
68+
logW(logTag, "System call timed out after ${timeoutMs}ms, using fallback")
69+
fallback
70+
}
71+
} catch (e: Exception) {
72+
logE(logTag, "System call failed: ${e.message}, using fallback")
73+
fallback
74+
}
75+
}
76+
77+
/**
78+
* Executes a potentially blocking system call off the main thread and posts the result back to the main thread.
79+
* This is useful for updating UI components after retrieving system information.
80+
*
81+
* @param timeoutMs Timeout in milliseconds for the operation (default: 5000ms)
82+
* @param fallback Fallback value to return if the operation fails or times out
83+
* @param logTag Tag for logging errors (default: "SystemCall")
84+
* @param dispatcher The dispatcher to use for the operation (default: Dispatchers.Default)
85+
* @param mainDispatcher The dispatcher to use for the callback (default: Dispatchers.Main)
86+
* @param operation The blocking operation to execute
87+
* @param onResult Callback to handle the result on the main thread
88+
*/
89+
internal suspend fun <T> safeSystemCallWithCallback(
90+
timeoutMs: Long = 5000L,
91+
fallback: T,
92+
logTag: String = "SystemCall",
93+
dispatcher: CoroutineDispatcher = Dispatchers.Default,
94+
mainDispatcher: CoroutineDispatcher = Dispatchers.Main,
95+
operation: suspend () -> T,
96+
onResult: (T) -> Unit
97+
) {
98+
val result = safeSystemCall(timeoutMs, fallback, logTag, dispatcher, operation)
99+
withContext(mainDispatcher) {
100+
onResult(result)
101+
}
36102
}

maps-sdk/src/test/java/com/mapbox/maps/MapSurfaceTest.kt

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,20 @@ import com.mapbox.verifyOnce
1515
import io.mockk.*
1616
import junit.framework.Assert.assertEquals
1717
import junit.framework.Assert.assertNull
18+
import kotlinx.coroutines.CoroutineScope
19+
import kotlinx.coroutines.Dispatchers
20+
import kotlinx.coroutines.ExperimentalCoroutinesApi
21+
import kotlinx.coroutines.test.TestCoroutineScheduler
22+
import kotlinx.coroutines.test.UnconfinedTestDispatcher
23+
import kotlinx.coroutines.test.resetMain
24+
import kotlinx.coroutines.test.setMain
25+
import org.junit.After
1826
import org.junit.Before
1927
import org.junit.Test
2028
import org.junit.runner.RunWith
2129
import org.robolectric.RobolectricTestRunner
2230

31+
@OptIn(ExperimentalCoroutinesApi::class)
2332
@RunWith(RobolectricTestRunner::class)
2433
class MapSurfaceTest {
2534

@@ -30,14 +39,28 @@ class MapSurfaceTest {
3039
private lateinit var mapboxSurfaceRenderer: MapboxSurfaceRenderer
3140
private lateinit var surface: Surface
3241

42+
private val testScheduler = TestCoroutineScheduler()
43+
private val testDispatcher = UnconfinedTestDispatcher(testScheduler)
44+
private val mainTestDispatcher = UnconfinedTestDispatcher(testScheduler, "MainTestDispatcher")
45+
private val testScope = CoroutineScope(testDispatcher)
46+
3347
@Before
3448
fun setUp() {
49+
Dispatchers.setMain(mainTestDispatcher)
50+
mockkStatic("com.mapbox.maps.MapboxLogger")
51+
every { logI(any(), any()) } just Runs
52+
every { logW(any(), any()) } just Runs
53+
every { logE(any(), any()) } just Runs
54+
3555
context = mockk(relaxUnitFun = true)
3656
mapInitOptions = mockk(relaxUnitFun = true)
3757
mapController = mockk(relaxUnitFun = true)
3858
mapboxSurfaceRenderer = mockk(relaxUnitFun = true)
3959
surface = mockk(relaxed = true)
4060

61+
// Use test scope for lifecycleScope
62+
every { mapController.lifecycleScope } returns testScope
63+
4164
mapSurface = MapSurface(
4265
context,
4366
surface,
@@ -47,6 +70,12 @@ class MapSurfaceTest {
4770
)
4871
}
4972

73+
@After
74+
fun cleanUp() {
75+
Dispatchers.resetMain()
76+
unmockkStatic("com.mapbox.maps.MapboxLogger")
77+
}
78+
5079
@Test
5180
fun testGetSurface() {
5281
assertEquals(surface, mapSurface.surface)
@@ -57,20 +86,31 @@ class MapSurfaceTest {
5786
val display: Display = mockk()
5887
val windowManager: WindowManager = mockk()
5988
val refreshRate = 100f
89+
@Suppress("DEPRECATION")
6090
every { windowManager.defaultDisplay } returns display
6191
every { display.refreshRate } returns refreshRate
6292
every { context.getSystemService(Context.WINDOW_SERVICE) } returns windowManager
93+
6394
mapSurface.surfaceCreated()
95+
6496
verifyOnce { mapboxSurfaceRenderer.surfaceCreated() }
65-
verifyOnce { mapController.setScreenRefreshRate(refreshRate.toInt()) }
97+
// Verify the immediate default setting is called
98+
verifySequence {
99+
mapController.setScreenRefreshRate(MapView.DEFAULT_FPS)
100+
mapController.lifecycleScope
101+
mapController.setScreenRefreshRate(refreshRate.toInt())
102+
}
66103
}
67104

68105
@Test
69106
fun testSurfaceCreatedWithNoWindowManager() {
70107
every { context.getSystemService(Context.WINDOW_SERVICE) } returns null
108+
71109
mapSurface.surfaceCreated()
110+
72111
verifyOnce { mapboxSurfaceRenderer.surfaceCreated() }
73-
verifyOnce { mapController.setScreenRefreshRate(MapView.DEFAULT_FPS) }
112+
// Verify the immediate default setting is called
113+
verify { mapController.setScreenRefreshRate(MapView.DEFAULT_FPS) }
74114
}
75115

76116
@Test

maps-sdk/src/test/java/com/mapbox/maps/MapViewTest.kt

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@ import com.mapbox.maps.renderer.OnFpsChangedListener
99
import com.mapbox.maps.renderer.RendererSetupErrorListener
1010
import com.mapbox.verifyNo
1111
import io.mockk.*
12+
import kotlinx.coroutines.CoroutineScope
13+
import kotlinx.coroutines.Dispatchers
14+
import kotlinx.coroutines.ExperimentalCoroutinesApi
15+
import kotlinx.coroutines.test.TestCoroutineScheduler
16+
import kotlinx.coroutines.test.UnconfinedTestDispatcher
17+
import kotlinx.coroutines.test.resetMain
18+
import kotlinx.coroutines.test.setMain
1219
import org.junit.After
1320
import org.junit.Assert.assertFalse
1421
import org.junit.Assert.assertTrue
@@ -17,38 +24,53 @@ import org.junit.Test
1724
import org.junit.runner.RunWith
1825
import org.robolectric.RobolectricTestRunner
1926

27+
@OptIn(ExperimentalCoroutinesApi::class)
2028
@RunWith(RobolectricTestRunner::class)
2129
class MapViewTest {
2230

2331
private lateinit var mapController: MapController
2432
private lateinit var mapboxMap: MapboxMap
2533
private lateinit var mapView: MapView
2634

35+
private val testScheduler = TestCoroutineScheduler()
36+
private val testDispatcher = UnconfinedTestDispatcher(testScheduler)
37+
private val mainTestDispatcher = UnconfinedTestDispatcher(testScheduler, "MainTestDispatcher")
38+
private val testScope = CoroutineScope(testDispatcher)
39+
2740
@Before
2841
fun setUp() {
42+
Dispatchers.setMain(mainTestDispatcher)
2943
mockkConstructor(DebugOptionsController::class)
3044
every { anyConstructed<DebugOptionsController>().options = any() } just Runs
3145
mapController = mockk(relaxUnitFun = true)
3246
mapboxMap = mockk(relaxUnitFun = true)
3347
every { mapController.mapboxMap } returns mapboxMap
48+
// Use test scope for lifecycleScope
49+
every { mapController.lifecycleScope } returns testScope
50+
3451
mapView = MapView(
3552
mockk(relaxed = true),
3653
mockk(relaxed = true),
3754
mapController,
3855
)
3956
mockkStatic("com.mapbox.maps.MapboxLogger")
4057
every { logI(any(), any()) } just Runs
58+
every { logW(any(), any()) } just Runs
59+
every { logE(any(), any()) } just Runs
4160
}
4261

4362
@After
4463
fun cleanUp() {
64+
Dispatchers.resetMain()
4565
unmockkStatic("com.mapbox.maps.MapboxLogger")
4666
}
4767

4868
@Test
4969
fun start() {
5070
mapView.onStart()
71+
5172
verify { mapController.onStart() }
73+
verify { mapController.setScreenRefreshRate(MapView.DEFAULT_FPS) }
5274
}
5375

5476
@Test

0 commit comments

Comments
 (0)