Skip to content

Commit 7b2a98f

Browse files
pengdevgithub-actions[bot]
authored andcommitted
[maps-sdk] Deprecate RenderThreadStats.totalDroppedFrames; add accurate frame breakdown (#15422)
GitOrigin-RevId: 09c954adb4f234341a862708c539d91a97899cd4
1 parent 887fa67 commit 7b2a98f

11 files changed

Lines changed: 245 additions & 18 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ Mapbox welcomes participation and contributions from everyone.
77
# main
88

99
## Features ✨ and improvements 🏁
10+
* Deprecated `RenderThreadStats.totalDroppedFrames`. Added `totalSkippedVsync`, `pacedSkippedVsync`, and `missedMapRenderFrames` for an accurate breakdown of frame performance when `setMaximumFps()` is used.
1011
* Report render-thread frame timing to the Android Dynamic Performance Framework (ADPF) on API 31+ devices, allowing the OS to scale CPU frequency based on actual rendering load.
1112

1213
# 11.29.0-rc.1 August 10, 2026

‎app/src/main/java/com/mapbox/maps/testapp/examples/SimulateNavigationRouteActivity.kt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ class SimulateNavigationRouteActivity : AppCompatActivity() {
9090
logI(TAG, "\ttotal time: $totalTime ms")
9191
logI(TAG, "\ttotal frames (rendered + skipped): $totalFrames")
9292
logI(TAG, "\taverage FPS: ${(totalFrames / (totalTime / 1000F)).format()}")
93-
logI(TAG, "\tskipped frames: $totalDroppedFrames")
93+
logI(TAG, "\tskipped frames: $totalSkippedVsync (pacing: $pacedSkippedVsync, missed render: $missedMapRenderFrames)")
9494
logI(TAG, "\t50 percentile: ${percentile50.format()} ms")
9595
logI(TAG, "\t90 percentile: ${percentile90.format()} ms")
9696
logI(TAG, "\t95 percentile: ${percentile95.format()} ms")

‎compose-app/src/main/java/com/mapbox/maps/compose/testapp/utils/FrameStatsRecorder.kt‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,11 @@ public class FrameStatsRecorder {
4545
}
4646
jsonObject.addProperty(KEY_TOTAL_FRAMES, stats.totalFrames)
4747
jsonObject.addProperty(KEY_TOTAL_TIME, stats.totalTime)
48-
jsonObject.addProperty(KEY_DROPPED, stats.totalDroppedFrames)
48+
jsonObject.addProperty(KEY_DROPPED, stats.totalSkippedVsync)
4949
jsonObject.addProperty(
5050
KEY_DROPPED_PERCENT,
5151
BigDecimal
52-
.valueOf((stats.totalDroppedFrames.toDouble() / stats.totalFrames) * 100.0)
52+
.valueOf((stats.totalSkippedVsync.toDouble() / stats.totalFrames) * 100.0)
5353
.setScale(2, RoundingMode.HALF_UP)
5454
.toDouble()
5555
)

‎maps-sdk/api/Release/metalava.txt‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -697,20 +697,26 @@ package com.mapbox.maps.renderer {
697697

698698
@com.mapbox.maps.MapboxExperimental public final class RenderThreadStats {
699699
method public java.util.List<java.lang.Double> getFrameTimeList();
700+
method public long getMissedMapRenderFrames();
701+
method public long getPacedSkippedVsync();
700702
method public Double? getPercentile50();
701703
method public Double? getPercentile90();
702704
method public Double? getPercentile95();
703705
method public Double? getPercentile99();
704-
method public long getTotalDroppedFrames();
706+
method @Deprecated public long getTotalDroppedFrames();
705707
method public long getTotalFrames();
708+
method public long getTotalSkippedVsync();
706709
method public long getTotalTime();
707710
property public final java.util.List<java.lang.Double> frameTimeList;
711+
property public final long missedMapRenderFrames;
712+
property public final long pacedSkippedVsync;
708713
property public final Double? percentile50;
709714
property public final Double? percentile90;
710715
property public final Double? percentile95;
711716
property public final Double? percentile99;
712-
property public final long totalDroppedFrames;
717+
property @Deprecated public final long totalDroppedFrames;
713718
property public final long totalFrames;
719+
property public final long totalSkippedVsync;
714720
property public final long totalTime;
715721
}
716722

‎maps-sdk/api/maps-sdk.api‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -751,15 +751,18 @@ public abstract interface annotation class com/mapbox/maps/renderer/RenderThread
751751
}
752752

753753
public final class com/mapbox/maps/renderer/RenderThreadStats {
754-
public synthetic fun <init> (JJJLjava/util/List;Ljava/lang/Double;Ljava/lang/Double;Ljava/lang/Double;Ljava/lang/Double;Lkotlin/jvm/internal/DefaultConstructorMarker;)V
754+
public synthetic fun <init> (JJJJJJLjava/util/List;Ljava/lang/Double;Ljava/lang/Double;Ljava/lang/Double;Ljava/lang/Double;Lkotlin/jvm/internal/DefaultConstructorMarker;)V
755755
public fun equals (Ljava/lang/Object;)Z
756756
public final fun getFrameTimeList ()Ljava/util/List;
757+
public final fun getMissedMapRenderFrames ()J
758+
public final fun getPacedSkippedVsync ()J
757759
public final fun getPercentile50 ()Ljava/lang/Double;
758760
public final fun getPercentile90 ()Ljava/lang/Double;
759761
public final fun getPercentile95 ()Ljava/lang/Double;
760762
public final fun getPercentile99 ()Ljava/lang/Double;
761763
public final fun getTotalDroppedFrames ()J
762764
public final fun getTotalFrames ()J
765+
public final fun getTotalSkippedVsync ()J
763766
public final fun getTotalTime ()J
764767
public fun hashCode ()I
765768
public fun toString ()Ljava/lang/String;

‎maps-sdk/src/main/java/com/mapbox/maps/renderer/FpsManager.kt‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ internal class FpsManager(
2424
private var frameRenderTimeAccumulatedNs = 0L
2525
var skippedNow = 0
2626
private set
27+
var pacingSkipsNow: Int = 0
28+
private set
29+
var missedMapRenderFramesNow: Int = 0
30+
private set
2731

2832
private var preRenderTimeNs = -1L
2933
private var choreographerTicks = 0
@@ -152,6 +156,8 @@ internal class FpsManager(
152156
*/
153157
private fun updateFrameStats(frameTimeNs: Long) {
154158
skippedNow = 0
159+
pacingSkipsNow = 0
160+
missedMapRenderFramesNow = 0
155161
if (previousFrameTimeNs != -1L) {
156162
val frameElapsedTimeNs = frameTimeNs - previousFrameTimeNs
157163

@@ -167,10 +173,11 @@ internal class FpsManager(
167173
// The map render frame time is based on the userToScreenRefreshRateRatio or the screen refresh rate (1.0)
168174
val mapRenderFrameRatio = userToScreenRefreshRateRatio ?: 1.0
169175
val mapRenderTimeNs = screenRefreshPeriodNs / mapRenderFrameRatio
170-
val missedMapRenderFramesNow =
176+
val missedMapRenderFramesDelta =
171177
(frameElapsedTimeNs / (mapRenderTimeNs + ONE_MILLISECOND_NS)).toInt()
172-
if (missedMapRenderFramesNow > 0) {
173-
missedMapRenderFrames += missedMapRenderFramesNow
178+
if (missedMapRenderFramesDelta > 0) {
179+
missedMapRenderFrames += missedMapRenderFramesDelta
180+
missedMapRenderFramesNow = missedMapRenderFramesDelta
174181
}
175182
}
176183
previousFrameTimeNs = frameTimeNs
@@ -230,6 +237,7 @@ internal class FpsManager(
230237
return true
231238
}
232239
choreographerPacingSkips++
240+
pacingSkipsNow = 1
233241
// We also increase choreographerSkips when performing pacing. Note this is not really a
234242
// missed map render frame since we're doing pacing.
235243
choreographerSkips++

‎maps-sdk/src/main/java/com/mapbox/maps/renderer/MapboxRenderThread.kt‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -656,7 +656,9 @@ internal abstract class MapboxRenderThread : Choreographer.FrameCallback {
656656
if (startTime != 0L && endTime != 0L) {
657657
renderThreadStatsRecorder?.addFrameStats(
658658
(endTime - startTime) / 1e6,
659-
fpsManager.skippedNow
659+
fpsManager.skippedNow,
660+
fpsManager.pacingSkipsNow,
661+
fpsManager.missedMapRenderFramesNow
660662
)
661663
}
662664
}

‎maps-sdk/src/main/java/com/mapbox/maps/renderer/RenderThreadStatsRecorder.kt‎

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ import kotlin.math.ceil
1515
class RenderThreadStatsRecorder {
1616

1717
private var startTime = 0L
18-
private var totalDroppedFrames = 0L
18+
private var totalSkippedVsync = 0L
19+
private var pacedSkippedVsync = 0L
20+
private var missedMapRenderFrames = 0L
1921
private val frameTimeList = mutableListOf<Double>()
2022

2123
/**
@@ -36,8 +38,10 @@ class RenderThreadStatsRecorder {
3638
startTime = SystemClock.elapsedRealtime()
3739
}
3840

39-
internal fun addFrameStats(frameTime: Double, droppedFrames: Int) {
40-
totalDroppedFrames += droppedFrames
41+
internal fun addFrameStats(frameTime: Double, droppedFrames: Int, pacingSkips: Int, missedMapFrames: Int) {
42+
totalSkippedVsync += droppedFrames
43+
pacedSkippedVsync += pacingSkips
44+
missedMapRenderFrames += missedMapFrames
4145
frameTimeList.add(frameTime)
4246
}
4347

@@ -62,8 +66,11 @@ class RenderThreadStatsRecorder {
6266

6367
return RenderThreadStats.Builder()
6468
.setTotalTime(SystemClock.elapsedRealtime() - startTime)
65-
.setTotalFrames(frameTimeList.size + totalDroppedFrames)
66-
.setTotalDroppedFrames(totalDroppedFrames)
69+
.setTotalFrames(frameTimeList.size + totalSkippedVsync)
70+
.setTotalDroppedFrames(totalSkippedVsync)
71+
.setTotalSkippedVsync(totalSkippedVsync)
72+
.setPacedSkippedVsync(pacedSkippedVsync)
73+
.setMissedMapRenderFrames(missedMapRenderFrames)
6774
.setFrameTimeList(frameTimeListCopy)
6875
.setPercentile50(percentileOfSortedList(sortedFrameTimeList, 50.0))
6976
.setPercentile90(percentileOfSortedList(sortedFrameTimeList, 90.0))
@@ -72,7 +79,9 @@ class RenderThreadStatsRecorder {
7279
.build()
7380
.also {
7481
startTime = 0L
75-
totalDroppedFrames = 0L
82+
totalSkippedVsync = 0L
83+
pacedSkippedVsync = 0L
84+
missedMapRenderFrames = 0L
7685
frameTimeList.clear()
7786
}
7887
}
@@ -92,9 +101,33 @@ class RenderThreadStats private constructor(
92101
*/
93102
val totalFrames: Long,
94103
/**
95-
* Total number of dropped frames.
104+
* Number of VSYNC pulses skipped because a render missed its deadline (took too long).
105+
*
106+
* @deprecated Renamed to [totalSkippedVsync]. This field never included intentional pacing
107+
* skips from [setMaximumFps][com.mapbox.maps.MapboxMap.setMaximumFps] - see [pacedSkippedVsync]
108+
* for those, tracked separately.
96109
*/
110+
@Deprecated(
111+
message = "Renamed to totalSkippedVsync. This field only counts missed-render-deadline VSYNCs; see pacedSkippedVsync for intentional pacing skips.",
112+
replaceWith = ReplaceWith("totalSkippedVsync")
113+
)
97114
val totalDroppedFrames: Long,
115+
/**
116+
* Number of VSYNC pulses skipped because a render missed its deadline (took too long).
117+
* Does not include intentional pacing skips from [setMaximumFps][com.mapbox.maps.MapboxMap.setMaximumFps] -
118+
* see [pacedSkippedVsync] for those. Replacement for the deprecated [totalDroppedFrames].
119+
*/
120+
val totalSkippedVsync: Long,
121+
/**
122+
* Number of VSYNC pulses intentionally skipped due to frame pacing (i.e., [setMaximumFps][com.mapbox.maps.MapboxMap.setMaximumFps] is active).
123+
* These are not missed render deadlines — they are expected skips.
124+
*/
125+
val pacedSkippedVsync: Long,
126+
/**
127+
* Number of map render frames missed because the render took longer than the target frame time.
128+
* This counts misses relative to the map render frame rate (which may differ from the screen refresh rate when [setMaximumFps][com.mapbox.maps.MapboxMap.setMaximumFps] is set).
129+
*/
130+
val missedMapRenderFrames: Long,
98131
/**
99132
* List of rendered frames times in milliseconds.
100133
*/
@@ -120,7 +153,7 @@ class RenderThreadStats private constructor(
120153
* @return Returns a string representation of the object.
121154
*/
122155
override fun toString() =
123-
"RenderThreadStats(totalTime=$totalTime, totalFrames=$totalFrames, totalDroppedFrames=$totalDroppedFrames, frameTimeList=$frameTimeList, percentile50=$percentile50, percentile90=$percentile90, percentile95=$percentile95, percentile99=$percentile99)"
156+
"RenderThreadStats(totalTime=$totalTime, totalFrames=$totalFrames, totalDroppedFrames=$totalDroppedFrames, totalSkippedVsync=$totalSkippedVsync, pacedSkippedVsync=$pacedSkippedVsync, missedMapRenderFrames=$missedMapRenderFrames, frameTimeList=$frameTimeList, percentile50=$percentile50, percentile90=$percentile90, percentile95=$percentile95, percentile99=$percentile99)"
124157

125158
/**
126159
* @return Returns true if the object is equal to the other object.
@@ -134,6 +167,9 @@ class RenderThreadStats private constructor(
134167
if (totalTime != other.totalTime) return false
135168
if (totalFrames != other.totalFrames) return false
136169
if (totalDroppedFrames != other.totalDroppedFrames) return false
170+
if (totalSkippedVsync != other.totalSkippedVsync) return false
171+
if (pacedSkippedVsync != other.pacedSkippedVsync) return false
172+
if (missedMapRenderFrames != other.missedMapRenderFrames) return false
137173
if (percentile50 != other.percentile50) return false
138174
if (percentile90 != other.percentile90) return false
139175
if (percentile95 != other.percentile95) return false
@@ -150,6 +186,9 @@ class RenderThreadStats private constructor(
150186
totalTime,
151187
totalFrames,
152188
totalDroppedFrames,
189+
totalSkippedVsync,
190+
pacedSkippedVsync,
191+
missedMapRenderFrames,
153192
percentile50,
154193
percentile90,
155194
percentile95,
@@ -160,6 +199,9 @@ class RenderThreadStats private constructor(
160199
private var totalTime: Long = 0
161200
private var totalFrames: Long = 0
162201
private var totalDroppedFrames: Long = 0
202+
private var totalSkippedVsync: Long = 0
203+
private var pacedSkippedVsync: Long = 0
204+
private var missedMapRenderFrames: Long = 0
163205
private var frameTimeList: List<Double> = emptyList()
164206
private var percentile50: Double? = null
165207
private var percentile90: Double? = null
@@ -171,6 +213,10 @@ class RenderThreadStats private constructor(
171213
fun setTotalDroppedFrames(totalDroppedFrames: Long) =
172214
apply { this.totalDroppedFrames = totalDroppedFrames }
173215

216+
fun setTotalSkippedVsync(totalSkippedVsync: Long) = apply { this.totalSkippedVsync = totalSkippedVsync }
217+
fun setPacedSkippedVsync(pacedSkippedVsync: Long) = apply { this.pacedSkippedVsync = pacedSkippedVsync }
218+
fun setMissedMapRenderFrames(missedMapRenderFrames: Long) = apply { this.missedMapRenderFrames = missedMapRenderFrames }
219+
174220
fun setFrameTimeList(frameTimeList: List<Double>) = apply { this.frameTimeList = frameTimeList }
175221
fun setPercentile50(percentile50: Double?) = apply { this.percentile50 = percentile50 }
176222
fun setPercentile90(percentile90: Double?) = apply { this.percentile90 = percentile90 }
@@ -182,6 +228,9 @@ class RenderThreadStats private constructor(
182228
totalTime,
183229
totalFrames,
184230
totalDroppedFrames,
231+
totalSkippedVsync,
232+
pacedSkippedVsync,
233+
missedMapRenderFrames,
185234
frameTimeList,
186235
percentile50,
187236
percentile90,

‎maps-sdk/src/test/java/com/mapbox/maps/renderer/FpsManagerTest.kt‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,77 @@ class FpsManagerTest {
278278
)
279279
}
280280

281+
@Test
282+
fun pacingSkipsNowMatchesRenderedPattern() {
283+
fpsManager.setUserRefreshRate(24)
284+
pauseHandler()
285+
val renderedPattern = mutableListOf<Boolean>()
286+
val pacingSkipsPattern = mutableListOf<Int>()
287+
idleHandler(vsyncCount = 10) {
288+
val rendered = fpsManager.preRender(it)
289+
renderedPattern.add(rendered)
290+
pacingSkipsPattern.add(fpsManager.pacingSkipsNow)
291+
fpsManager.postRender()
292+
}
293+
Assert.assertArrayEquals(
294+
arrayOf(
295+
false, false, true, false, true,
296+
false, false, true, false, true
297+
),
298+
renderedPattern.toTypedArray()
299+
)
300+
// pacingSkipsNow must be exactly 1 on ticks where preRender skipped rendering for pacing,
301+
// and 0 on ticks where it actually rendered.
302+
renderedPattern.forEachIndexed { index, rendered ->
303+
Assert.assertEquals(
304+
"pacingSkipsNow mismatch at tick $index",
305+
if (rendered) 0 else 1,
306+
pacingSkipsPattern[index]
307+
)
308+
}
309+
}
310+
311+
@Test
312+
fun unpacedRenderingReportsNoSkipsOrMisses() {
313+
pauseHandler()
314+
idleHandler(vsyncCount = 10) {
315+
assert(fpsManager.preRender(it, recorderStarted = true))
316+
Assert.assertEquals(0, fpsManager.pacingSkipsNow)
317+
Assert.assertEquals(0, fpsManager.missedMapRenderFramesNow)
318+
fpsManager.postRender()
319+
}
320+
}
321+
322+
@Test
323+
fun missedMapRenderFramesNowOnSlowRender() {
324+
// 60 Hz -> ONE_SECOND_NS(1_000_000_000) / 60 = 16_666_666 ns period (integer division),
325+
// same computation FpsManager.setScreenRefreshRate performs internally (see
326+
// `setScreenRefreshRate passes the raw refresh period to the performance hint reporter`).
327+
val screenRefreshPeriodNs = 16_666_666L
328+
val t0 = 1_000_000_000L
329+
// Establish a baseline timestamp; recorderStarted=true forces updateFrameStats() to run even
330+
// though no user refresh rate / fpsChangedListener is set (mirrors MapboxRenderThread.doFrame
331+
// passing renderThreadStatsRecorder?.isRecording == true).
332+
fpsManager.preRender(t0, recorderStarted = true)
333+
Assert.assertEquals(0, fpsManager.missedMapRenderFramesNow)
334+
335+
// Jump far enough ahead (10 refresh periods) to guarantee a slow-render gap that exceeds the
336+
// map-render-frame budget (unpaced, so budget == screenRefreshPeriodNs).
337+
val slowFrameTimeNs = t0 + 10 * screenRefreshPeriodNs
338+
fpsManager.preRender(slowFrameTimeNs, recorderStarted = true)
339+
340+
val frameElapsedTimeNs = slowFrameTimeNs - t0
341+
val expectedMissedFrames = (frameElapsedTimeNs / (screenRefreshPeriodNs + 1_000_000L)).toInt()
342+
Assert.assertTrue("Expected a positive missed map render frame delta", expectedMissedFrames > 0)
343+
Assert.assertEquals(expectedMissedFrames, fpsManager.missedMapRenderFramesNow)
344+
Assert.assertEquals(0, fpsManager.pacingSkipsNow)
345+
346+
// The very next, normally-spaced tick must reset missedMapRenderFramesNow back to 0 - proves
347+
// updateFrameStats()'s per-tick reset, not just that the delta can go positive once.
348+
fpsManager.preRender(slowFrameTimeNs + screenRefreshPeriodNs, recorderStarted = true)
349+
Assert.assertEquals(0, fpsManager.missedMapRenderFramesNow)
350+
}
351+
281352
private fun mapIdleTest() {
282353
// pattern is [0, 1, 1, 1, 1]
283354
fpsManager.setUserRefreshRate(48)

‎maps-sdk/src/test/java/com/mapbox/maps/renderer/GLMapboxRenderThreadTest.kt‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,29 @@ class GLMapboxRenderThreadTest {
365365
}
366366
}
367367

368+
@Test
369+
@OptIn(MapboxExperimental::class)
370+
fun doFrameWiresPacingAndMissedRenderCountsIntoRecorder() {
371+
initRenderThread()
372+
provideValidSurface()
373+
// the shared initRenderThread() stub only covers preRender(any(), eq(false)); a started
374+
// recorder makes MapboxRenderThread pass recorderStarted = true, which needs its own stub
375+
every { fpsManager.preRender(any(), true) } returns true
376+
every { fpsManager.skippedNow } returns 2
377+
every { fpsManager.pacingSkipsNow } returns 1
378+
every { fpsManager.missedMapRenderFramesNow } returns 3
379+
val recorder = RenderThreadStatsRecorder()
380+
recorder.start()
381+
mapboxRenderThread.renderThreadStatsRecorder = recorder
382+
pauseHandler()
383+
mapboxRenderThread.queueRenderEvent(MapboxRenderThread.repaintRenderEvent)
384+
idleHandler()
385+
val stats = recorder.end()
386+
assertEquals(2L, stats.totalSkippedVsync)
387+
assertEquals(1L, stats.pacedSkippedVsync)
388+
assertEquals(3L, stats.missedMapRenderFrames)
389+
}
390+
368391
@Test
369392
fun setUserRefreshRateTest() {
370393
initRenderThread()

0 commit comments

Comments
 (0)