Skip to content

Commit bcf28b7

Browse files
huntiefacebook-github-bot
authored andcommitted
Add dynamic sampling to frame screenshots (facebook#56048)
Summary: Update Android frame screenshot processing to skip screenshot capture when encoding is already in progress — now limited to a single background thread — while always emitting frame timing events. **Motivation** 1. Prevents truncated trace data on slower devices (e.g. missing screenshots for the last 1/3 of the trace), with the tradeoff of some intermediate frame screenshot loss. 2. Reduces total recording overhead by freeing up device threads - prevents excessive encoding work from blocking or slowing down the UI and other app threads. **Algorithm** Uses `encodingInProgress` atomic flag with single encoding thread and `lastFrameBuffer` storage for tail-capture of the last frame before idling (to capture settled animation states): - **Not encoding:** Frame passes directly to encoder → emits with screenshot when done - **Encoding busy:** Frame stored in `lastFrameBuffer` for tail-capture → any replaced frame emits without screenshot - **Encoding done:** Clears flag early, then opportunistically encodes tail frame without blocking new frames - **Failed captures:** Emit without screenshot immediately Result: Every frame emitted exactly once. Encoding adapts to device speed. Settled animation state guaranteed captured. **Remaining work** - ⚠️ This still does not yet solve crashes (OkHttp network chunk size overflow) for heavy frame data at a high FPS on fast devices (coming next). Changelog: [Internal] Reviewed By: rubennorte Differential Revision: D95987488
1 parent 321afe6 commit bcf28b7

File tree

1 file changed

+119
-27
lines changed

1 file changed

+119
-27
lines changed

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

Lines changed: 119 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,13 @@ import android.view.PixelCopy
1717
import android.view.Window
1818
import com.facebook.proguard.annotations.DoNotStripAny
1919
import java.io.ByteArrayOutputStream
20+
import java.util.concurrent.Executors
21+
import java.util.concurrent.atomic.AtomicBoolean
22+
import java.util.concurrent.atomic.AtomicReference
23+
import kotlinx.coroutines.CoroutineDispatcher
2024
import kotlinx.coroutines.CoroutineScope
2125
import kotlinx.coroutines.Dispatchers
26+
import kotlinx.coroutines.asCoroutineDispatcher
2227
import kotlinx.coroutines.launch
2328

2429
@DoNotStripAny
@@ -29,20 +34,39 @@ internal class FrameTimingsObserver(
2934
private val isSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N
3035
private val mainHandler = Handler(Looper.getMainLooper())
3136

37+
// Serial dispatcher for encoding work (single background thread). We limit to 1 thread to
38+
// minimize the performance impact of screenshot recording.
39+
private val encodingDispatcher: CoroutineDispatcher =
40+
Executors.newSingleThreadExecutor().asCoroutineDispatcher()
41+
42+
// Stores the most recently captured frame to opportunistically encode after the current frame.
43+
// Replaced frames are emitted as timings without screenshots.
44+
private val lastFrameBuffer = AtomicReference<FrameData?>(null)
45+
3246
private var frameCounter: Int = 0
47+
private val encodingInProgress = AtomicBoolean(false)
3348
@Volatile private var isTracing: Boolean = false
3449
@Volatile private var currentWindow: Window? = null
3550

51+
private data class FrameData(
52+
val bitmap: Bitmap,
53+
val frameId: Int,
54+
val threadId: Int,
55+
val beginTimestamp: Long,
56+
val endTimestamp: Long,
57+
)
58+
3659
fun start() {
3760
if (!isSupported) {
3861
return
3962
}
4063

4164
frameCounter = 0
65+
encodingInProgress.set(false)
66+
lastFrameBuffer.set(null)
4267
isTracing = true
4368

44-
// Capture initial screenshot to ensure there's always at least one frame
45-
// recorded at the start of tracing, even if no UI changes occur
69+
// Emit initial frame event
4670
val timestamp = System.nanoTime()
4771
emitFrameTiming(timestamp, timestamp)
4872

@@ -58,6 +82,7 @@ internal class FrameTimingsObserver(
5882

5983
currentWindow?.removeOnFrameMetricsAvailableListener(frameMetricsListener)
6084
mainHandler.removeCallbacksAndMessages(null)
85+
lastFrameBuffer.getAndSet(null)?.bitmap?.recycle()
6186
}
6287

6388
fun setCurrentWindow(window: Window?) {
@@ -74,8 +99,7 @@ internal class FrameTimingsObserver(
7499

75100
private val frameMetricsListener =
76101
Window.OnFrameMetricsAvailableListener { _, frameMetrics, _ ->
77-
// Guard against calls arriving after stop() has ended tracing. Async work scheduled from
78-
// previous frames will still finish.
102+
// Guard against calls after stop()
79103
if (!isTracing) {
80104
return@OnFrameMetricsAvailableListener
81105
}
@@ -88,34 +112,107 @@ internal class FrameTimingsObserver(
88112
val frameId = frameCounter++
89113
val threadId = Process.myTid()
90114

91-
if (screenshotsEnabled) {
92-
// Initiate PixelCopy immediately on the main thread, while still in the current frame,
93-
// then process and emit asynchronously once the copy is complete.
94-
captureScreenshot { screenshot ->
95-
CoroutineScope(Dispatchers.Default).launch {
96-
onFrameTimingSequence(
97-
FrameTimingSequence(frameId, threadId, beginTimestamp, endTimestamp, screenshot)
98-
)
115+
if (!screenshotsEnabled) {
116+
// Screenshots disabled - emit without screenshot
117+
emitFrameEvent(frameId, threadId, beginTimestamp, endTimestamp, null)
118+
return
119+
}
120+
121+
captureScreenshot(frameId, threadId, beginTimestamp, endTimestamp) { frameData ->
122+
if (frameData != null) {
123+
if (encodingInProgress.compareAndSet(false, true)) {
124+
// Not encoding - encode this frame immediately
125+
encodeFrame(frameData)
126+
} else {
127+
// Encoding thread busy - store current screenshot in buffer for tail-capture
128+
val oldFrameData = lastFrameBuffer.getAndSet(frameData)
129+
if (oldFrameData != null) {
130+
// Skipped frame - emit event without screenshot
131+
emitFrameEvent(
132+
oldFrameData.frameId,
133+
oldFrameData.threadId,
134+
oldFrameData.beginTimestamp,
135+
oldFrameData.endTimestamp,
136+
null,
137+
)
138+
oldFrameData.bitmap.recycle()
139+
}
99140
}
141+
} else {
142+
// Failed to capture (e.g. timeout) - emit without screenshot
143+
emitFrameEvent(frameId, threadId, beginTimestamp, endTimestamp, null)
100144
}
101-
} else {
102-
CoroutineScope(Dispatchers.Default).launch {
103-
onFrameTimingSequence(
104-
FrameTimingSequence(frameId, threadId, beginTimestamp, endTimestamp, null)
145+
}
146+
}
147+
148+
private fun emitFrameEvent(
149+
frameId: Int,
150+
threadId: Int,
151+
beginTimestamp: Long,
152+
endTimestamp: Long,
153+
screenshot: ByteArray?,
154+
) {
155+
CoroutineScope(Dispatchers.Default).launch {
156+
onFrameTimingSequence(
157+
FrameTimingSequence(frameId, threadId, beginTimestamp, endTimestamp, screenshot)
158+
)
159+
}
160+
}
161+
162+
private fun encodeFrame(frameData: FrameData) {
163+
CoroutineScope(encodingDispatcher).launch {
164+
try {
165+
val screenshot = encodeScreenshot(frameData.bitmap)
166+
emitFrameEvent(
167+
frameData.frameId,
168+
frameData.threadId,
169+
frameData.beginTimestamp,
170+
frameData.endTimestamp,
171+
screenshot,
105172
)
173+
} finally {
174+
frameData.bitmap.recycle()
175+
}
176+
177+
// Clear encoding flag early, allowing new frames to start fresh encoding sessions
178+
encodingInProgress.set(false)
179+
180+
// Opportunistically encode tail frame (if present) without blocking new frames
181+
val tailFrame = lastFrameBuffer.getAndSet(null)
182+
if (tailFrame != null) {
183+
try {
184+
val screenshot = encodeScreenshot(tailFrame.bitmap)
185+
emitFrameEvent(
186+
tailFrame.frameId,
187+
tailFrame.threadId,
188+
tailFrame.beginTimestamp,
189+
tailFrame.endTimestamp,
190+
screenshot,
191+
)
192+
} finally {
193+
tailFrame.bitmap.recycle()
194+
}
106195
}
107196
}
108197
}
109198

110199
// Must be called from the main thread so that PixelCopy captures the current frame.
111-
private fun captureScreenshot(callback: (ByteArray?) -> Unit) {
200+
private fun captureScreenshot(
201+
frameId: Int,
202+
threadId: Int,
203+
beginTimestamp: Long,
204+
endTimestamp: Long,
205+
callback: (FrameData?) -> Unit,
206+
) {
112207
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
208+
// PixelCopy not available
113209
callback(null)
114210
return
115211
}
116212

117213
val window = currentWindow
118214
if (window == null) {
215+
// No window
119216
callback(null)
120217
return
121218
}
@@ -130,9 +227,7 @@ internal class FrameTimingsObserver(
130227
bitmap,
131228
{ copyResult ->
132229
if (copyResult == PixelCopy.SUCCESS) {
133-
CoroutineScope(Dispatchers.Default).launch {
134-
callback(encodeScreenshot(window, bitmap, width, height))
135-
}
230+
callback(FrameData(bitmap, frameId, threadId, beginTimestamp, endTimestamp))
136231
} else {
137232
bitmap.recycle()
138233
callback(null)
@@ -142,14 +237,12 @@ internal class FrameTimingsObserver(
142237
)
143238
}
144239

145-
private fun encodeScreenshot(
146-
window: Window,
147-
bitmap: Bitmap,
148-
width: Int,
149-
height: Int,
150-
): ByteArray? {
240+
private fun encodeScreenshot(bitmap: Bitmap): ByteArray? {
151241
var scaledBitmap: Bitmap? = null
152242
return try {
243+
val window = currentWindow ?: return null
244+
val width = bitmap.width
245+
val height = bitmap.height
153246
val density = window.context.resources.displayMetrics.density
154247
val scaledWidth = (width / density * SCREENSHOT_SCALE_FACTOR).toInt()
155248
val scaledHeight = (height / density * SCREENSHOT_SCALE_FACTOR).toInt()
@@ -167,7 +260,6 @@ internal class FrameTimingsObserver(
167260
null
168261
} finally {
169262
scaledBitmap?.recycle()
170-
bitmap.recycle()
171263
}
172264
}
173265

0 commit comments

Comments
 (0)