From c0193bf0df96fb2f41dfb0b8ccf7591c3a9992e3 Mon Sep 17 00:00:00 2001 From: Adrian Cojocaru Date: Thu, 31 Jul 2025 14:04:01 +0300 Subject: [PATCH] Add rendering stats tracker example (#3680) --- .../activity/events/ObserverActivity.kt | 41 +++- .../activity/events/RenderStatsTracker.kt | 214 ++++++++++++++++++ .../docs/observability/observe-map-events.md | 6 + 3 files changed, 251 insertions(+), 10 deletions(-) create mode 100644 platform/android/MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/events/RenderStatsTracker.kt diff --git a/platform/android/MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/events/ObserverActivity.kt b/platform/android/MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/events/ObserverActivity.kt index 214493e1b..720c05cc3 100644 --- a/platform/android/MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/events/ObserverActivity.kt +++ b/platform/android/MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/events/ObserverActivity.kt @@ -1,28 +1,20 @@ package org.maplibre.android.testapp.activity.events -import android.app.ActivityManager -import android.os.Build import android.os.Bundle import android.view.View -import android.view.WindowManager import androidx.appcompat.app.AppCompatActivity -import org.maplibre.android.MapLibre import org.maplibre.android.maps.MapView import org.maplibre.android.maps.Style import org.maplibre.android.tile.TileOperation import org.maplibre.android.testapp.R import org.maplibre.android.testapp.styles.TestStyles -import timber.log.Timber import java.util.* import kotlin.time.TimeMark import kotlin.time.TimeSource -import kotlin.time.TimeSource.Monotonic import org.maplibre.android.log.Logger -import org.maplibre.android.log.Logger.INFO import org.maplibre.android.maps.RenderingStats import org.maplibre.android.maps.MapLibreMap - // # --8<-- [start:ObserverActivity] /** * Test activity showcasing logging observer actions from the core @@ -39,6 +31,7 @@ class ObserverActivity : AppCompatActivity(), // # --8<-- [end:ObserverActivity] private lateinit var mapView: MapView + private val renderStatsTracker = RenderStatsTracker() companion object { const val TAG = "ObserverActivity" @@ -61,6 +54,33 @@ class ObserverActivity : AppCompatActivity(), mapView.addOnDidFinishRenderingFrameListener(this) // # --8<-- [end:addListeners] + // # --8<-- [start:renderStatsTracker] + renderStatsTracker.setReportFields(listOf( + "encodingTime", + "renderingTime", + "numDrawCalls", + "numActiveTextures", + "numBuffers", + "memTextures", + "memBuffers" + )) + + renderStatsTracker.setReportListener { _, _, avg -> + Logger.i(TAG, "RenderStatsReport - avg - ${avg.nonZeroValuesString()}") + } + + renderStatsTracker.setThresholds(hashMapOf( + "numDrawCalls" to 1000, + "totalBuffers" to 1000L + )) + + renderStatsTracker.setThresholdExceededListener { exceededValues, _ -> + Logger.i(TAG, "Exceeded render values $exceededValues") + } + + renderStatsTracker.startReports(10L) + // # --8<-- [end:renderStatsTracker] + mapView.getMapAsync { it.setStyle( Style.Builder().fromUri(TestStyles.DEMOTILES) @@ -78,7 +98,7 @@ class ObserverActivity : AppCompatActivity(), } // # --8<-- [start:printActionJournal] - fun printActionJournal(map: MapLibreMap) { + private fun printActionJournal(map: MapLibreMap) { // configure using `MapLibreMapOptions.actionJournal*` methods Logger.i(TAG,"ActionJournal files: \n${map.actionJournalLogFiles.joinToString("\n")}") @@ -136,7 +156,7 @@ class ObserverActivity : AppCompatActivity(), } override fun onDidFinishRenderingFrame(fully: Boolean, stats: RenderingStats) { - + renderStatsTracker.addFrame(stats) } // # --8<-- [end:mapEvents] @@ -168,6 +188,7 @@ class ObserverActivity : AppCompatActivity(), override fun onDestroy() { super.onDestroy() mapView.onDestroy() + renderStatsTracker.stopReports() } override fun onSaveInstanceState(outState: Bundle) { diff --git a/platform/android/MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/events/RenderStatsTracker.kt b/platform/android/MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/events/RenderStatsTracker.kt new file mode 100644 index 000000000..260962f8c --- /dev/null +++ b/platform/android/MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/events/RenderStatsTracker.kt @@ -0,0 +1,214 @@ +package org.maplibre.android.testapp.activity.events + +import org.maplibre.android.maps.MapView +import org.maplibre.android.maps.RenderingStats +import java.lang.reflect.Field +import java.util.Timer +import kotlin.concurrent.fixedRateTimer + +/** + * Example class that tracks rendering statistics values (based on `RenderingStats` fields) and offers: + * - periodic reports with min/max/average values + * - threshold exceeded triggers + * @see RenderingStats + */ +class RenderStatsTracker { + private var min = RenderingStats() + private var max = RenderingStats() + private var total = RenderingStats() + private var frameCount = 0 + + private var reportTimer: Timer? = null + private var reportFields = RenderingStats::class.java.fields + private var reportListener: ((RenderingStats, RenderingStats, RenderingStats) -> Unit)? = null + + private var thresholds = HashMap() + private var thresholdExceededListener: ((HashMap,RenderingStats) -> Unit)? = null + + /** + * Set fields to be tracked and updated every frame. An empty list will track all fields. + * The report listener will provide `RenderingStats` objects with the tracked values. + */ + @Synchronized fun setReportFields(values: List?) { + if (values == null) { + reportFields = RenderingStats::class.java.fields + return + } + + reportFields = values.map { RenderingStats::class.java.getField(it) }.toTypedArray() + } + + /** + * Callback that provides min/max/average values for the configured interval (`start(interval)`) + */ + @Synchronized fun setReportListener(listener: ((RenderingStats, RenderingStats, RenderingStats) -> Unit)?) { + if (reportFields.isEmpty()) { + setReportFields(null) + } + + reportListener = listener + } + + /** + * Set the thresholds to be checked + */ + @Synchronized fun setThresholds(values: HashMap) { + thresholds.clear() + val fields = RenderingStats::class.java.fields + + // cache index instead of name + values.map { value -> + val index = fields.indexOfFirst { it.name == value.key } + if (index != -1) { + thresholds[index] = value.value + } + } + } + + /** + * Callback that provides the exceeded threshold values in the last frame. + */ + @Synchronized fun setThresholdExceededListener(listener: ((HashMap,RenderingStats) -> Unit)?) { + thresholdExceededListener = listener + } + + /** + * Start periodic reports every `interval` seconds + */ + @Synchronized fun startReports(interval: Long) { + reset() + + reportTimer = fixedRateTimer( + name = "RenderStatsReportTimer", + initialDelay = interval * 1000L, + period = interval * 1000L + ) { + reportListener?.invoke(min, max, getAvg()) + reset() + } + } + + /** + * Stop periodic reports. Must be called before the object is destroyed + */ + @Synchronized fun stopReports() { + reportTimer?.cancel() + reportTimer = null + } + + /** + * Add the last frame data to the report. + * @see MapView.OnDidFinishRenderingFrameWithStatsListener(fully: Boolean, stats: RenderingStats) + */ + @Synchronized fun addFrame(frameStats: RenderingStats) { + updateReportValues(frameStats) + checkThresholds(frameStats) + } + + private fun updateReportValues(frameStats: RenderingStats) { + if (reportListener == null || reportTimer == null) { + return + } + + ++frameCount; + + reportFields.map { field: Field -> + when (val frameValue = field.get(frameStats)) { + is Int -> { + if (field.getInt(min) > frameValue) { + field.setInt(min, frameValue) + } + + if (field.getInt(max) < frameValue) { + field.setInt(max, frameValue) + } + + field.setInt(total, frameValue + field.getInt(total)) + } + + is Long -> { + if (field.getLong(min) > frameValue) { + field.setLong(min, frameValue) + } + + if (field.getLong(max) < frameValue) { + field.setLong(max, frameValue) + } + + field.setLong(total, frameValue + field.getLong(total)) + } + + is Double -> { + if (field.getDouble(min) > frameValue) { + field.setDouble(min, frameValue) + } + + if (field.getDouble(max) < frameValue) { + field.setDouble(max, frameValue) + } + + field.setDouble(total, frameValue + field.getDouble(total)) + } + + else -> {} + } + } + } + + private fun checkThresholds(frameStats: RenderingStats) { + if (thresholdExceededListener == null && thresholds.isEmpty()) { + return + } + + val fields = RenderingStats::class.java.fields + val exceededValues = HashMap() + + thresholds.map { + when (val frameValue = fields[it.key].get(frameStats)) { + is Int -> if (frameValue > it.value as Int) exceededValues[fields[it.key].name] = frameValue + is Long -> if (frameValue > it.value as Long) exceededValues[fields[it.key].name] = frameValue + is Double -> if (frameValue > it.value as Double) exceededValues[fields[it.key].name] = frameValue + else -> {} + } + } + + if (exceededValues.isNotEmpty()) { + thresholdExceededListener?.invoke(exceededValues, frameStats) + } + } + + private fun getAvg(): RenderingStats { + val avg = RenderingStats() + + if (frameCount == 0) { + return avg; + } + + reportFields.map { field: Field -> + when (val value = field.get(total)) { + is Int -> field.setInt(avg, value / frameCount) + is Long -> field.setLong(avg, value / frameCount) + is Double -> field.setDouble(avg, value / frameCount) + else -> {} + } + } + + return avg + } + + private fun reset() { + reportFields.map { field: Field -> field.set(min, Int.MAX_VALUE) } + reportFields.map { field: Field -> field.set(max, -Int.MAX_VALUE) } + reportFields.map { field: Field -> field.set(total, 0) } + + frameCount = 0 + } +} + +fun RenderingStats.nonZeroValuesString(): String { + return this::class.java.fields + .filter { (it.get(this) as? Number)?.toDouble() != 0.0 } + .joinToString(", ") { field -> + "(${field.name}=${field.get(this)})" + } +} diff --git a/platform/android/docs/observability/observe-map-events.md b/platform/android/docs/observability/observe-map-events.md index 4c27f6bf3..1a1b15137 100644 --- a/platform/android/docs/observability/observe-map-events.md +++ b/platform/android/docs/observability/observe-map-events.md @@ -19,3 +19,9 @@ In this case we implement them by implementing the interfaces below in the activ ```kotlin --8<-- "MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/events/ObserverActivity.kt:ObserverActivity" ``` + +`ObserverActivity.onDidFinishRenderingFrame` uses `RenderStatsTracker` as an example for tracking rendering statistics over time. This offers periodic reports of minimum, maximum, average values and callbacks when predefined thresholds are exceeded. + +```kotlin +--8<-- "MapLibreAndroidTestApp/src/main/java/org/maplibre/android/testapp/activity/events/ObserverActivity.kt:renderStatsTracker" +```