From 4d64cb5c311e4b618eb228b97dd83cfff76125b8 Mon Sep 17 00:00:00 2001 From: Alex Hunt Date: Wed, 25 Feb 2026 05:19:53 -0800 Subject: [PATCH 1/2] Fix trailing frame capture after recording ended (#55704) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/55704 Previously, this could lead to capturing an additional 120-240 new frames. Also refactor to de-nest and simplify coroutine logic. Changelog: [Internal] Differential Revision: D93863310 --- .../inspector/FrameTimingsObserver.kt | 139 ++++++++++-------- 1 file changed, 80 insertions(+), 59 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 d1fbe73fa5e..1dc5ad9932d 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 @@ -22,7 +22,10 @@ import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext @DoNotStripAny internal class FrameTimingsObserver( @@ -30,83 +33,94 @@ internal class FrameTimingsObserver( private val screenshotsEnabled: Boolean, private val onFrameTimingSequence: (sequence: FrameTimingSequence) -> Unit, ) { + // Bounds the lifetime of async frame timing and screenshot work. Cancelled in stop() to prevent + // emitting any further frames once tracing is torn down. + private var tracingScope: CoroutineScope? = null + private val handler = Handler(Looper.getMainLooper()) private var frameCounter: Int = 0 private var bitmapBuffer: Bitmap? = null private val frameMetricsListener = - Window.OnFrameMetricsAvailableListener { _, frameMetrics, _dropCount -> + Window.OnFrameMetricsAvailableListener { _, frameMetrics, _ -> val beginTimestamp = frameMetrics.getMetric(FrameMetrics.VSYNC_TIMESTAMP) val endTimestamp = beginTimestamp + frameMetrics.getMetric(FrameMetrics.TOTAL_DURATION) emitFrameTiming(beginTimestamp, endTimestamp) } - private suspend fun captureScreenshot(): String? = suspendCoroutine { continuation -> - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { - continuation.resume(null) - return@suspendCoroutine - } - - val decorView = window.decorView - val width = decorView.width - val height = decorView.height - - // 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 } - - PixelCopy.request( - window, - bitmap, - { copyResult -> - if (copyResult == PixelCopy.SUCCESS) { - CoroutineScope(Dispatchers.Default).launch { - var scaledBitmap: Bitmap? = null - try { - val scaleFactor = 0.25f - val scaledWidth = (width * scaleFactor).toInt() - val scaledHeight = (height * scaleFactor).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 - - val base64 = - ByteArrayOutputStream().use { outputStream -> - scaledBitmap.compress(compressFormat, 0, outputStream) - Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP) - } - - continuation.resume(base64) - } catch (e: Exception) { - continuation.resume(null) - } finally { - scaledBitmap?.recycle() + private suspend fun captureScreenshot(): String? = + withContext(Dispatchers.Main) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return@withContext null + } + + val decorView = window.decorView + val width = decorView.width + val height = decorView.height + + // Reuse bitmap if dimensions haven't changed + val bitmap = + bitmapBuffer?.let { + if (it.width == width && it.height == height) { + it + } else { + it.recycle() + null } } - } else { - continuation.resume(null) + ?: Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).also { + bitmapBuffer = it + } + + // Suspend for PixelCopy callback + val copySuccess = suspendCoroutine { continuation -> + PixelCopy.request( + window, + bitmap, + { copyResult -> continuation.resume(copyResult == PixelCopy.SUCCESS) }, + handler, + ) + } + + if (!copySuccess) { + return@withContext null + } + + // Switch to background thread for CPU-intensive scaling/encoding work + withContext(Dispatchers.Default) { + var scaledBitmap: Bitmap? = null + try { + val scaleFactor = 0.25f + val scaledWidth = (width * scaleFactor).toInt() + val scaledHeight = (height * scaleFactor).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 + + ByteArrayOutputStream().use { outputStream -> + scaledBitmap.compress(compressFormat, 0, outputStream) + Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP) + } + } catch (e: Exception) { + null + } finally { + scaledBitmap?.recycle() } - }, - handler, - ) - } + } + } fun start() { - frameCounter = 0 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { return } + frameCounter = 0 + + // Use SupervisorJob so a failed capture on one frame doesn't cancel others + tracingScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + // 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() @@ -116,10 +130,13 @@ internal class FrameTimingsObserver( } private fun emitFrameTiming(beginTimestamp: Long, endTimestamp: Long) { + // Guard against calls arriving after stop() has cancelled the scope + val scope = tracingScope ?: return + val frameId = frameCounter++ val threadId = Process.myTid() - CoroutineScope(Dispatchers.Default).launch { + scope.launch { val screenshot = if (screenshotsEnabled) captureScreenshot() else null onFrameTimingSequence( @@ -142,6 +159,10 @@ internal class FrameTimingsObserver( window.removeOnFrameMetricsAvailableListener(frameMetricsListener) handler.removeCallbacksAndMessages(null) + // Cancel any in-flight screenshot captures before releasing the bitmap buffer + tracingScope?.cancel() + tracingScope = null + bitmapBuffer?.recycle() bitmapBuffer = null } From 57abe8003c6d63583998accd628995b78fe55fb1 Mon Sep 17 00:00:00 2001 From: Alex Hunt Date: Wed, 25 Feb 2026 05:39:12 -0800 Subject: [PATCH 2/2] Document internals of FrameTimingsObserver, reorder methods (#55730) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/55730 Refactor for understandability/organisation. Changelog: [Internal] Differential Revision: D94257731 --- .../inspector/FrameTimingsObserver.kt | 120 +++++++++--------- 1 file changed, 62 insertions(+), 58 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 1dc5ad9932d..db2545a9272 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 @@ -33,14 +33,52 @@ internal class FrameTimingsObserver( private val screenshotsEnabled: Boolean, private val onFrameTimingSequence: (sequence: FrameTimingSequence) -> Unit, ) { + // Used to schedule Window.OnFrameMetricsAvailableListener callbacks on the main thread + private val mainHandler = Handler(Looper.getMainLooper()) + // Bounds the lifetime of async frame timing and screenshot work. Cancelled in stop() to prevent // emitting any further frames once tracing is torn down. private var tracingScope: CoroutineScope? = null - private val handler = Handler(Looper.getMainLooper()) private var frameCounter: Int = 0 + + // Reused to avoid allocating a new bitmap for each capture private var bitmapBuffer: Bitmap? = null + fun start() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + return + } + + frameCounter = 0 + + // Use SupervisorJob so a failed capture on one frame doesn't cancel others + tracingScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + // 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) + + window.addOnFrameMetricsAvailableListener(frameMetricsListener, mainHandler) + } + + fun stop() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + return + } + + window.removeOnFrameMetricsAvailableListener(frameMetricsListener) + mainHandler.removeCallbacksAndMessages(null) + + // Cancel any in-flight screenshot captures before releasing the bitmap buffer + tracingScope?.cancel() + tracingScope = null + + bitmapBuffer?.recycle() + bitmapBuffer = null + } + private val frameMetricsListener = Window.OnFrameMetricsAvailableListener { _, frameMetrics, _ -> val beginTimestamp = frameMetrics.getMetric(FrameMetrics.VSYNC_TIMESTAMP) @@ -48,6 +86,28 @@ internal class FrameTimingsObserver( emitFrameTiming(beginTimestamp, endTimestamp) } + private fun emitFrameTiming(beginTimestamp: Long, endTimestamp: Long) { + // Guard against calls arriving after stop() has cancelled the scope + val scope = tracingScope ?: return + + val frameId = frameCounter++ + val threadId = Process.myTid() + + scope.launch { + val screenshot = if (screenshotsEnabled) captureScreenshot() else null + + onFrameTimingSequence( + FrameTimingSequence( + frameId, + threadId, + beginTimestamp, + endTimestamp, + screenshot, + ) + ) + } + } + private suspend fun captureScreenshot(): String? = withContext(Dispatchers.Main) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { @@ -78,7 +138,7 @@ internal class FrameTimingsObserver( window, bitmap, { copyResult -> continuation.resume(copyResult == PixelCopy.SUCCESS) }, - handler, + mainHandler, ) } @@ -110,60 +170,4 @@ internal class FrameTimingsObserver( } } } - - fun start() { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { - return - } - - frameCounter = 0 - - // Use SupervisorJob so a failed capture on one frame doesn't cancel others - tracingScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - - // 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) - - window.addOnFrameMetricsAvailableListener(frameMetricsListener, handler) - } - - private fun emitFrameTiming(beginTimestamp: Long, endTimestamp: Long) { - // Guard against calls arriving after stop() has cancelled the scope - val scope = tracingScope ?: return - - val frameId = frameCounter++ - val threadId = Process.myTid() - - scope.launch { - val screenshot = if (screenshotsEnabled) captureScreenshot() else null - - onFrameTimingSequence( - FrameTimingSequence( - frameId, - threadId, - beginTimestamp, - endTimestamp, - screenshot, - ) - ) - } - } - - fun stop() { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { - return - } - - window.removeOnFrameMetricsAvailableListener(frameMetricsListener) - handler.removeCallbacksAndMessages(null) - - // Cancel any in-flight screenshot captures before releasing the bitmap buffer - tracingScope?.cancel() - tracingScope = null - - bitmapBuffer?.recycle() - bitmapBuffer = null - } }