Skip to content

Commit 00fefc4

Browse files
huntiefacebook-github-bot
authored andcommitted
Fix trailing frame capture after recording ended (facebook#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 89df2e3 commit 00fefc4

File tree

1 file changed

+80
-59
lines changed

1 file changed

+80
-59
lines changed

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

Lines changed: 80 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -22,91 +22,105 @@ import kotlin.coroutines.resume
2222
import kotlin.coroutines.suspendCoroutine
2323
import kotlinx.coroutines.CoroutineScope
2424
import kotlinx.coroutines.Dispatchers
25+
import kotlinx.coroutines.SupervisorJob
26+
import kotlinx.coroutines.cancel
2527
import kotlinx.coroutines.launch
28+
import kotlinx.coroutines.withContext
2629

2730
@DoNotStripAny
2831
internal class FrameTimingsObserver(
2932
private val window: Window,
3033
private val screenshotsEnabled: Boolean,
3134
private val onFrameTimingSequence: (sequence: FrameTimingSequence) -> Unit,
3235
) {
36+
// Bounds the lifetime of async frame timing and screenshot work. Cancelled in stop() to prevent
37+
// emitting any further frames once tracing is torn down.
38+
private var tracingScope: CoroutineScope? = null
39+
3340
private val handler = Handler(Looper.getMainLooper())
3441
private var frameCounter: Int = 0
3542
private var bitmapBuffer: Bitmap? = null
3643

3744
private val frameMetricsListener =
38-
Window.OnFrameMetricsAvailableListener { _, frameMetrics, _dropCount ->
45+
Window.OnFrameMetricsAvailableListener { _, frameMetrics, _ ->
3946
val beginTimestamp = frameMetrics.getMetric(FrameMetrics.VSYNC_TIMESTAMP)
4047
val endTimestamp = beginTimestamp + frameMetrics.getMetric(FrameMetrics.TOTAL_DURATION)
4148
emitFrameTiming(beginTimestamp, endTimestamp)
4249
}
4350

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

104114
fun start() {
105-
frameCounter = 0
106115
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
107116
return
108117
}
109118

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

118132
private fun emitFrameTiming(beginTimestamp: Long, endTimestamp: Long) {
133+
// Guard against calls arriving after stop() has cancelled the scope
134+
val scope = tracingScope ?: return
135+
119136
val frameId = frameCounter++
120137
val threadId = Process.myTid()
121138

122-
CoroutineScope(Dispatchers.Default).launch {
139+
scope.launch {
123140
val screenshot = if (screenshotsEnabled) captureScreenshot() else null
124141

125142
onFrameTimingSequence(
@@ -142,6 +159,10 @@ internal class FrameTimingsObserver(
142159
window.removeOnFrameMetricsAvailableListener(frameMetricsListener)
143160
handler.removeCallbacksAndMessages(null)
144161

162+
// Cancel any in-flight screenshot captures before releasing the bitmap buffer
163+
tracingScope?.cancel()
164+
tracingScope = null
165+
145166
bitmapBuffer?.recycle()
146167
bitmapBuffer = null
147168
}

0 commit comments

Comments
 (0)