From b2b4aa766e8e7eb2ef5a9e40c1efd3b50dd5b573 Mon Sep 17 00:00:00 2001 From: Alex Hunt Date: Wed, 25 Feb 2026 08:00:21 -0800 Subject: [PATCH 1/5] Reorder FrameTimingsObserver methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Small refactor — locate public methods first, sort private methods in call order. Changelog: [Internal] Differential Revision: D94366535 --- .../inspector/FrameTimingsObserver.kt | 110 +++++++++--------- 1 file changed, 55 insertions(+), 55 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/inspector/FrameTimingsObserver.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/inspector/FrameTimingsObserver.kt index f69455f2c2c7..0a6746a60354 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/inspector/FrameTimingsObserver.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/inspector/FrameTimingsObserver.kt @@ -38,12 +38,35 @@ internal class FrameTimingsObserver( @Volatile private var currentWindow: Window? = null - private val frameMetricsListener = - Window.OnFrameMetricsAvailableListener { _, frameMetrics, _dropCount -> - val beginTimestamp = frameMetrics.getMetric(FrameMetrics.VSYNC_TIMESTAMP) - val endTimestamp = beginTimestamp + frameMetrics.getMetric(FrameMetrics.TOTAL_DURATION) - emitFrameTiming(beginTimestamp, endTimestamp) - } + fun start() { + if (!isSupported) { + return + } + + frameCounter = 0 + isStarted = true + + // Capture initial screenshot to ensure there's always at least one frame + // recorded at the start of tracing, even if no UI changes occur + val timestamp = System.nanoTime() + emitFrameTiming(timestamp, timestamp) + + currentWindow?.addOnFrameMetricsAvailableListener(frameMetricsListener, handler) + } + + fun stop() { + if (!isSupported) { + return + } + + isStarted = false + + currentWindow?.removeOnFrameMetricsAvailableListener(frameMetricsListener) + handler.removeCallbacksAndMessages(null) + + bitmapBuffer?.recycle() + bitmapBuffer = null + } fun setCurrentWindow(window: Window?) { if (!isSupported || currentWindow === window) { @@ -57,6 +80,32 @@ internal class FrameTimingsObserver( } } + private val frameMetricsListener = + Window.OnFrameMetricsAvailableListener { _, frameMetrics, _ -> + val beginTimestamp = frameMetrics.getMetric(FrameMetrics.VSYNC_TIMESTAMP) + val endTimestamp = beginTimestamp + frameMetrics.getMetric(FrameMetrics.TOTAL_DURATION) + emitFrameTiming(beginTimestamp, endTimestamp) + } + + private fun emitFrameTiming(beginTimestamp: Long, endTimestamp: Long) { + val frameId = frameCounter++ + val threadId = Process.myTid() + + CoroutineScope(Dispatchers.Default).launch { + val screenshot = if (screenshotsEnabled) captureScreenshot() else null + + onFrameTimingSequence( + FrameTimingSequence( + frameId, + threadId, + beginTimestamp, + endTimestamp, + screenshot, + ) + ) + } + } + private suspend fun captureScreenshot(): String? = suspendCoroutine { continuation -> if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { continuation.resume(null) @@ -122,53 +171,4 @@ internal class FrameTimingsObserver( handler, ) } - - fun start() { - if (!isSupported) { - return - } - - frameCounter = 0 - isStarted = true - - // Capture initial screenshot to ensure there's always at least one frame - // recorded at the start of tracing, even if no UI changes occur - val timestamp = System.nanoTime() - emitFrameTiming(timestamp, timestamp) - - currentWindow?.addOnFrameMetricsAvailableListener(frameMetricsListener, handler) - } - - private fun emitFrameTiming(beginTimestamp: Long, endTimestamp: Long) { - val frameId = frameCounter++ - val threadId = Process.myTid() - - CoroutineScope(Dispatchers.Default).launch { - val screenshot = if (screenshotsEnabled) captureScreenshot() else null - - onFrameTimingSequence( - FrameTimingSequence( - frameId, - threadId, - beginTimestamp, - endTimestamp, - screenshot, - ) - ) - } - } - - fun stop() { - if (!isSupported) { - return - } - - isStarted = false - - currentWindow?.removeOnFrameMetricsAvailableListener(frameMetricsListener) - handler.removeCallbacksAndMessages(null) - - bitmapBuffer?.recycle() - bitmapBuffer = null - } } From caf9f6780668517407b77bea63a4e855bec18c8b Mon Sep 17 00:00:00 2001 From: Alex Hunt Date: Wed, 25 Feb 2026 08:00:21 -0800 Subject: [PATCH 2/5] Increase frame capture quality, apply scaling after DPI normalization (#55731) Summary: Changelog: [Internal] Differential Revision: D94256691 --- .../inspector/FrameTimingsObserver.kt | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/inspector/FrameTimingsObserver.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/inspector/FrameTimingsObserver.kt index 0a6746a60354..2a13a5e5b5a4 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/inspector/FrameTimingsObserver.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/inspector/FrameTimingsObserver.kt @@ -124,14 +124,11 @@ internal class FrameTimingsObserver( // Reuse bitmap if dimensions haven't changed val bitmap = - bitmapBuffer?.let { - if (it.width == width && it.height == height) { - it - } else { - it.recycle() - null - } - } ?: Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).also { bitmapBuffer = it } + bitmapBuffer?.takeIf { it.width == width && it.height == height } + ?: run { + bitmapBuffer?.recycle() + Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).also { bitmapBuffer = it } + } PixelCopy.request( window, @@ -141,19 +138,19 @@ internal class FrameTimingsObserver( CoroutineScope(Dispatchers.Default).launch { var scaledBitmap: Bitmap? = null try { - val scaleFactor = 0.25f - val scaledWidth = (width * scaleFactor).toInt() - val scaledHeight = (height * scaleFactor).toInt() + val density = window.context.resources.displayMetrics.density + val scaledWidth = (width / density * SCREENSHOT_SCALE_FACTOR).toInt() + val scaledHeight = (height / density * SCREENSHOT_SCALE_FACTOR).toInt() scaledBitmap = Bitmap.createScaledBitmap(bitmap, scaledWidth, scaledHeight, true) val compressFormat = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) Bitmap.CompressFormat.WEBP_LOSSY - else Bitmap.CompressFormat.WEBP + else Bitmap.CompressFormat.JPEG val base64 = ByteArrayOutputStream().use { outputStream -> - scaledBitmap.compress(compressFormat, 0, outputStream) + scaledBitmap.compress(compressFormat, SCREENSHOT_QUALITY, outputStream) Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP) } @@ -171,4 +168,9 @@ internal class FrameTimingsObserver( handler, ) } + + companion object { + private const val SCREENSHOT_SCALE_FACTOR = 0.75f + private const val SCREENSHOT_QUALITY = 80 + } } From 49fa58c1ce15a9693dbb132b5ea79ff6e5517e30 Mon Sep 17 00:00:00 2001 From: Alex Hunt Date: Wed, 25 Feb 2026 08:00:21 -0800 Subject: [PATCH 3/5] Fix FrameTimingsObverver to initiate PixelCopy on main thread Summary: Previously, screenshot capture was initiated on a background thread, so `PixelCopy` could run during a later frame than the one being reported. Screenshots now correctly correspond to their frame metrics callback. Changelog: [Internal] Differential Revision: D94368259 --- .../inspector/FrameTimingsObserver.kt | 99 ++++++++++--------- 1 file changed, 51 insertions(+), 48 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/inspector/FrameTimingsObserver.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/inspector/FrameTimingsObserver.kt index 2a13a5e5b5a4..5d75daea8141 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/inspector/FrameTimingsObserver.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/inspector/FrameTimingsObserver.kt @@ -18,8 +18,6 @@ import android.view.PixelCopy import android.view.Window import com.facebook.proguard.annotations.DoNotStripAny import java.io.ByteArrayOutputStream -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -31,7 +29,7 @@ internal class FrameTimingsObserver( ) { private val isSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N - private val handler = Handler(Looper.getMainLooper()) + private val mainHandler = Handler(Looper.getMainLooper()) private var frameCounter: Int = 0 private var bitmapBuffer: Bitmap? = null private var isStarted: Boolean = false @@ -51,7 +49,7 @@ internal class FrameTimingsObserver( val timestamp = System.nanoTime() emitFrameTiming(timestamp, timestamp) - currentWindow?.addOnFrameMetricsAvailableListener(frameMetricsListener, handler) + currentWindow?.addOnFrameMetricsAvailableListener(frameMetricsListener, mainHandler) } fun stop() { @@ -62,7 +60,7 @@ internal class FrameTimingsObserver( isStarted = false currentWindow?.removeOnFrameMetricsAvailableListener(frameMetricsListener) - handler.removeCallbacksAndMessages(null) + mainHandler.removeCallbacksAndMessages(null) bitmapBuffer?.recycle() bitmapBuffer = null @@ -76,7 +74,7 @@ internal class FrameTimingsObserver( currentWindow?.removeOnFrameMetricsAvailableListener(frameMetricsListener) currentWindow = window if (isStarted) { - currentWindow?.addOnFrameMetricsAvailableListener(frameMetricsListener, handler) + currentWindow?.addOnFrameMetricsAvailableListener(frameMetricsListener, mainHandler) } } @@ -91,31 +89,36 @@ internal class FrameTimingsObserver( val frameId = frameCounter++ val threadId = Process.myTid() - CoroutineScope(Dispatchers.Default).launch { - val screenshot = if (screenshotsEnabled) captureScreenshot() else null - - onFrameTimingSequence( - FrameTimingSequence( - frameId, - threadId, - beginTimestamp, - endTimestamp, - screenshot, + if (screenshotsEnabled) { + // Initiate PixelCopy immediately on the main thread, while still in the current frame, + // then process and emit asynchronously once the copy is complete. + captureScreenshot { screenshot -> + CoroutineScope(Dispatchers.Default).launch { + onFrameTimingSequence( + FrameTimingSequence(frameId, threadId, beginTimestamp, endTimestamp, screenshot) ) - ) + } + } + } else { + CoroutineScope(Dispatchers.Default).launch { + onFrameTimingSequence( + FrameTimingSequence(frameId, threadId, beginTimestamp, endTimestamp, null) + ) + } } } - private suspend fun captureScreenshot(): String? = suspendCoroutine { continuation -> + // Must be called from the main thread so that PixelCopy captures the current frame. + private fun captureScreenshot(callback: (String?) -> Unit) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { - continuation.resume(null) - return@suspendCoroutine + callback(null) + return } val window = currentWindow if (window == null) { - continuation.resume(null) - return@suspendCoroutine + callback(null) + return } val decorView = window.decorView @@ -136,39 +139,39 @@ internal class FrameTimingsObserver( { copyResult -> if (copyResult == PixelCopy.SUCCESS) { CoroutineScope(Dispatchers.Default).launch { - var scaledBitmap: Bitmap? = null - try { - val density = window.context.resources.displayMetrics.density - val scaledWidth = (width / density * SCREENSHOT_SCALE_FACTOR).toInt() - val scaledHeight = (height / density * SCREENSHOT_SCALE_FACTOR).toInt() - scaledBitmap = Bitmap.createScaledBitmap(bitmap, scaledWidth, scaledHeight, true) - - val compressFormat = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) - Bitmap.CompressFormat.WEBP_LOSSY - else Bitmap.CompressFormat.JPEG - - val base64 = - ByteArrayOutputStream().use { outputStream -> - scaledBitmap.compress(compressFormat, SCREENSHOT_QUALITY, outputStream) - Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP) - } - - continuation.resume(base64) - } catch (e: Exception) { - continuation.resume(null) - } finally { - scaledBitmap?.recycle() - } + callback(encodeScreenshot(window, bitmap, width, height)) } } else { - continuation.resume(null) + callback(null) } }, - handler, + mainHandler, ) } + private fun encodeScreenshot(window: Window, bitmap: Bitmap, width: Int, height: Int): String? { + var scaledBitmap: Bitmap? = null + return try { + val density = window.context.resources.displayMetrics.density + val scaledWidth = (width / density * SCREENSHOT_SCALE_FACTOR).toInt() + val scaledHeight = (height / density * SCREENSHOT_SCALE_FACTOR).toInt() + scaledBitmap = Bitmap.createScaledBitmap(bitmap, scaledWidth, scaledHeight, true) + + val compressFormat = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) Bitmap.CompressFormat.WEBP_LOSSY + else Bitmap.CompressFormat.JPEG + + ByteArrayOutputStream().use { outputStream -> + scaledBitmap.compress(compressFormat, SCREENSHOT_QUALITY, outputStream) + Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP) + } + } catch (e: Exception) { + null + } finally { + scaledBitmap?.recycle() + } + } + companion object { private const val SCREENSHOT_SCALE_FACTOR = 0.75f private const val SCREENSHOT_QUALITY = 80 From 71860b53fa4f5804176e5b5cd5afe7fa5d9278f4 Mon Sep 17 00:00:00 2001 From: Alex Hunt Date: Wed, 25 Feb 2026 08:00:21 -0800 Subject: [PATCH 4/5] Fix bitmap reuse race condition in FrameTimingsObserver Summary: The shared `bitmapBuffer` could be overwritten by a new `PixelCopy` request on the main thread while a previous frame's encoding coroutine was still reading from it. Each frame now gets its own bitmap, recycled after encoding. NOTE: This will come at a slight perf cost (allocating extra new bitmaps in memory), for the tradeoff of correctness. Changelog: [Internal] Differential Revision: D94368260 --- .../devsupport/inspector/FrameTimingsObserver.kt | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/inspector/FrameTimingsObserver.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/inspector/FrameTimingsObserver.kt index 5d75daea8141..16e8560c36e7 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/inspector/FrameTimingsObserver.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/inspector/FrameTimingsObserver.kt @@ -31,7 +31,6 @@ internal class FrameTimingsObserver( private val mainHandler = Handler(Looper.getMainLooper()) private var frameCounter: Int = 0 - private var bitmapBuffer: Bitmap? = null private var isStarted: Boolean = false @Volatile private var currentWindow: Window? = null @@ -61,9 +60,6 @@ internal class FrameTimingsObserver( currentWindow?.removeOnFrameMetricsAvailableListener(frameMetricsListener) mainHandler.removeCallbacksAndMessages(null) - - bitmapBuffer?.recycle() - bitmapBuffer = null } fun setCurrentWindow(window: Window?) { @@ -124,14 +120,7 @@ internal class FrameTimingsObserver( val decorView = window.decorView val width = decorView.width val height = decorView.height - - // Reuse bitmap if dimensions haven't changed - val bitmap = - bitmapBuffer?.takeIf { it.width == width && it.height == height } - ?: run { - bitmapBuffer?.recycle() - Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).also { bitmapBuffer = it } - } + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) PixelCopy.request( window, @@ -142,6 +131,7 @@ internal class FrameTimingsObserver( callback(encodeScreenshot(window, bitmap, width, height)) } } else { + bitmap.recycle() callback(null) } }, @@ -169,6 +159,7 @@ internal class FrameTimingsObserver( null } finally { scaledBitmap?.recycle() + bitmap.recycle() } } From 2d23996334429301c8d5bdb23f976f48edf95560 Mon Sep 17 00:00:00 2001 From: Alex Hunt Date: Wed, 25 Feb 2026 08:00:21 -0800 Subject: [PATCH 5/5] Fix trailing frame capture after recording ended (#55704) Summary: Previously, this could lead to capturing an additional 120-240 new frames. Changelog: [Internal] Differential Revision: D93863310 --- .../devsupport/inspector/FrameTimingsObserver.kt | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/inspector/FrameTimingsObserver.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/inspector/FrameTimingsObserver.kt index 16e8560c36e7..bf352f9b8f17 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/inspector/FrameTimingsObserver.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/inspector/FrameTimingsObserver.kt @@ -28,11 +28,10 @@ internal class FrameTimingsObserver( private val onFrameTimingSequence: (sequence: FrameTimingSequence) -> Unit, ) { private val isSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N - private val mainHandler = Handler(Looper.getMainLooper()) - private var frameCounter: Int = 0 - private var isStarted: Boolean = false + private var frameCounter: Int = 0 + @Volatile private var isTracing: Boolean = false @Volatile private var currentWindow: Window? = null fun start() { @@ -41,7 +40,7 @@ internal class FrameTimingsObserver( } frameCounter = 0 - isStarted = true + isTracing = true // Capture initial screenshot to ensure there's always at least one frame // recorded at the start of tracing, even if no UI changes occur @@ -56,7 +55,7 @@ internal class FrameTimingsObserver( return } - isStarted = false + isTracing = false currentWindow?.removeOnFrameMetricsAvailableListener(frameMetricsListener) mainHandler.removeCallbacksAndMessages(null) @@ -69,13 +68,18 @@ internal class FrameTimingsObserver( currentWindow?.removeOnFrameMetricsAvailableListener(frameMetricsListener) currentWindow = window - if (isStarted) { + if (isTracing) { currentWindow?.addOnFrameMetricsAvailableListener(frameMetricsListener, mainHandler) } } private val frameMetricsListener = Window.OnFrameMetricsAvailableListener { _, frameMetrics, _ -> + // Guard against calls arriving after stop() has ended tracing. Async work scheduled from + // previous frames will still finish. + if (!isTracing) { + return@OnFrameMetricsAvailableListener + } val beginTimestamp = frameMetrics.getMetric(FrameMetrics.VSYNC_TIMESTAMP) val endTimestamp = beginTimestamp + frameMetrics.getMetric(FrameMetrics.TOTAL_DURATION) emitFrameTiming(beginTimestamp, endTimestamp)