Skip to content

Commit d989bc9

Browse files
huntiefacebook-github-bot
authored andcommitted
Fix trailing frame capture after recording ended (#55704)
Summary: 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
1 parent 00184ac commit d989bc9

File tree

1 file changed

+80
-61
lines changed

1 file changed

+80
-61
lines changed

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/inspector/FrameTimingsObserver.kt

Lines changed: 80 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import android.graphics.Bitmap
1111
import android.os.Build
1212
import android.os.Handler
1313
import android.os.Looper
14-
import android.os.Process
1514
import android.util.Base64
1615
import android.view.FrameMetrics
1716
import android.view.PixelCopy
@@ -22,91 +21,104 @@ import kotlin.coroutines.resume
2221
import kotlin.coroutines.suspendCoroutine
2322
import kotlinx.coroutines.CoroutineScope
2423
import kotlinx.coroutines.Dispatchers
25-
import kotlinx.coroutines.launch
24+
import kotlinx.coroutines.SupervisorJob
25+
import kotlinx.coroutines.cancel
26+
import kotlinx.coroutines.withContext
2627

2728
@DoNotStripAny
2829
internal class FrameTimingsObserver(
2930
private val window: Window,
3031
private val screenshotsEnabled: Boolean,
3132
private val onFrameTimingSequence: (sequence: FrameTimingSequence) -> Unit,
3233
) {
34+
// Bounds the lifetime of async frame timing and screenshot work. Cancelled in stop() to prevent
35+
// emitting any further frames once tracing is torn down.
36+
private var tracingScope: CoroutineScope? = null
37+
3338
private val handler = Handler(Looper.getMainLooper())
3439
private var frameCounter: Int = 0
3540
private var bitmapBuffer: Bitmap? = null
3641

3742
private val frameMetricsListener =
38-
Window.OnFrameMetricsAvailableListener { _, frameMetrics, _dropCount ->
43+
Window.OnFrameMetricsAvailableListener { _, frameMetrics, _ ->
3944
val beginTimestamp = frameMetrics.getMetric(FrameMetrics.VSYNC_TIMESTAMP)
4045
val endTimestamp = beginTimestamp + frameMetrics.getMetric(FrameMetrics.TOTAL_DURATION)
4146
emitFrameTiming(beginTimestamp, endTimestamp)
4247
}
4348

44-
private suspend fun captureScreenshot(): String? = suspendCoroutine { continuation ->
45-
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
46-
continuation.resume(null)
47-
return@suspendCoroutine
48-
}
49-
50-
val decorView = window.decorView
51-
val width = decorView.width
52-
val height = decorView.height
53-
54-
// Reuse bitmap if dimensions haven't changed
55-
val bitmap =
56-
bitmapBuffer?.let {
57-
if (it.width == width && it.height == height) {
58-
it
59-
} else {
60-
it.recycle()
61-
null
62-
}
63-
} ?: Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).also { bitmapBuffer = it }
64-
65-
PixelCopy.request(
66-
window,
67-
bitmap,
68-
{ copyResult ->
69-
if (copyResult == PixelCopy.SUCCESS) {
70-
CoroutineScope(Dispatchers.Default).launch {
71-
var scaledBitmap: Bitmap? = null
72-
try {
73-
val scaleFactor = 0.25f
74-
val scaledWidth = (width * scaleFactor).toInt()
75-
val scaledHeight = (height * scaleFactor).toInt()
76-
scaledBitmap = Bitmap.createScaledBitmap(bitmap, scaledWidth, scaledHeight, true)
77-
78-
val compressFormat =
79-
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
80-
Bitmap.CompressFormat.WEBP_LOSSY
81-
else Bitmap.CompressFormat.WEBP
82-
83-
val base64 =
84-
ByteArrayOutputStream().use { outputStream ->
85-
scaledBitmap.compress(compressFormat, 0, outputStream)
86-
Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP)
87-
}
88-
89-
continuation.resume(base64)
90-
} catch (e: Exception) {
91-
continuation.resume(null)
92-
} finally {
93-
scaledBitmap?.recycle()
49+
private suspend fun captureScreenshot(): String? =
50+
withContext(Dispatchers.Main) {
51+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
52+
return@withContext null
53+
}
54+
55+
val decorView = window.decorView
56+
val width = decorView.width
57+
val height = decorView.height
58+
59+
// Reuse bitmap if dimensions haven't changed
60+
val bitmap =
61+
bitmapBuffer?.let {
62+
if (it.width == width && it.height == height) {
63+
it
64+
} else {
65+
it.recycle()
66+
null
9467
}
9568
}
96-
} else {
97-
continuation.resume(null)
69+
?: Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).also {
70+
bitmapBuffer = it
71+
}
72+
73+
// Suspend for PixelCopy callback
74+
val copySuccess = suspendCoroutine { continuation ->
75+
PixelCopy.request(
76+
window,
77+
bitmap,
78+
{ copyResult -> continuation.resume(copyResult == PixelCopy.SUCCESS) },
79+
handler,
80+
)
81+
}
82+
83+
if (!copySuccess) {
84+
return@withContext null
85+
}
86+
87+
// Switch to background thread for CPU-intensive scaling/encoding work
88+
withContext(Dispatchers.Default) {
89+
var scaledBitmap: Bitmap? = null
90+
try {
91+
val scaleFactor = 0.25f
92+
val scaledWidth = (width * scaleFactor).toInt()
93+
val scaledHeight = (height * scaleFactor).toInt()
94+
scaledBitmap = Bitmap.createScaledBitmap(bitmap, scaledWidth, scaledHeight, true)
95+
96+
val compressFormat =
97+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) Bitmap.CompressFormat.WEBP_LOSSY
98+
else Bitmap.CompressFormat.WEBP
99+
100+
ByteArrayOutputStream().use { outputStream ->
101+
scaledBitmap.compress(compressFormat, 0, outputStream)
102+
Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP)
103+
}
104+
} catch (e: Exception) {
105+
null
106+
} finally {
107+
scaledBitmap?.recycle()
98108
}
99-
},
100-
handler,
101-
)
102-
}
109+
}
110+
}
103111

104112
fun start() {
105-
frameCounter = 0
106113
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
107114
return
108115
}
109116

117+
frameCounter = 0
118+
119+
// Use SupervisorJob so a failed capture on one frame doesn't cancel others
120+
tracingScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
121+
110122
// Capture initial screenshot to ensure there's always at least one frame
111123
// recorded at the start of tracing, even if no UI changes occur
112124
val timestamp = System.nanoTime()
@@ -116,10 +128,13 @@ internal class FrameTimingsObserver(
116128
}
117129

118130
private fun emitFrameTiming(beginTimestamp: Long, endTimestamp: Long) {
131+
// Guard against calls arriving after stop() has cancelled the scope
132+
val scope = tracingScope ?: return
133+
119134
val frameId = frameCounter++
120135
val threadId = Process.myTid()
121136

122-
CoroutineScope(Dispatchers.Default).launch {
137+
scope.launch {
123138
val screenshot = if (screenshotsEnabled) captureScreenshot() else null
124139

125140
onFrameTimingSequence(
@@ -142,6 +157,10 @@ internal class FrameTimingsObserver(
142157
window.removeOnFrameMetricsAvailableListener(frameMetricsListener)
143158
handler.removeCallbacksAndMessages(null)
144159

160+
// Cancel any in-flight screenshot captures before releasing the bitmap buffer
161+
tracingScope?.cancel()
162+
tracingScope = null
163+
145164
bitmapBuffer?.recycle()
146165
bitmapBuffer = null
147166
}

0 commit comments

Comments
 (0)