Skip to content

Commit f2155ae

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 visual snapshot 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. NOTE: ⚠️ 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). **Dynamic throttling algorithm** - Use a single `lastFrameBuffer` to store the most recently captured frame (bitmap + metadata) - Use a serial background thread (single-thread executor) for all encoding work - Use an atomic `encodingInProgress` flag to track encoding state - Always capture screenshots to `lastFrameBuffer` on every frame (even during encoding), replacing any previous waiting frame - When not encoding: start encoding the captured frame - When encoding finishes: loop back and check if buffer was refilled, encode that frame if present - Always emit frame timing events immediately (without screenshot) This ensures we don't queue up excessive encoding work while maintaining continuous frame timing data and guaranteeing the final settled state of animations is captured (tail-capture), minimizing the performance impact of recording on the device. NOTE: ⚠️ We do still typically lose a small region of pending frames at the end of a trace, but this is now more reasonable. A further fix (likely unnecessary) is to add lifecycle methods to await frame processing before responding to `Tracing.stop`. Changelog: [Internal] Differential Revision: D95987488
1 parent 116ea59 commit f2155ae

File tree

1 file changed

+82
-25
lines changed

1 file changed

+82
-25
lines changed

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

Lines changed: 82 additions & 25 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,16 +34,35 @@ 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)
38+
private val encodingDispatcher: CoroutineDispatcher =
39+
Executors.newSingleThreadExecutor().asCoroutineDispatcher()
40+
41+
// Stores the most recently captured frame. When previous frame encoding finishes, ensures we
42+
// encode the final settled frame even if intermediate frames were skipped.
43+
private val lastFrameBuffer = AtomicReference<FrameData?>(null)
44+
3245
private var frameCounter: Int = 0
46+
private val encodingInProgress = AtomicBoolean(false)
3347
@Volatile private var isTracing: Boolean = false
3448
@Volatile private var currentWindow: Window? = null
3549

50+
private data class FrameData(
51+
val bitmap: Bitmap,
52+
val frameId: Int,
53+
val threadId: Int,
54+
val beginTimestamp: Long,
55+
val endTimestamp: Long,
56+
)
57+
3658
fun start() {
3759
if (!isSupported) {
3860
return
3961
}
4062

4163
frameCounter = 0
64+
encodingInProgress.set(false)
65+
lastFrameBuffer.set(null)
4266
isTracing = true
4367

4468
// Capture initial screenshot to ensure there's always at least one frame
@@ -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?) {
@@ -88,35 +113,67 @@ internal class FrameTimingsObserver(
88113
val frameId = frameCounter++
89114
val threadId = Process.myTid()
90115

116+
// Always emit frame event without screenshot immediately
117+
CoroutineScope(Dispatchers.Default).launch {
118+
onFrameTimingSequence(
119+
FrameTimingSequence(frameId, threadId, beginTimestamp, endTimestamp, null)
120+
)
121+
}
122+
123+
// If screenshots enabled, always capture to lastFrameBuffer (even during encoding).
124+
// This ensures we capture the settled/end state of animations.
91125
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 {
126+
captureScreenshotToBuffer(frameId, threadId, beginTimestamp, endTimestamp) {
127+
// If not currently encoding, start encoding the last captured frame
128+
if (encodingInProgress.compareAndSet(false, true)) {
129+
encodeBufferedFrames()
130+
}
131+
}
132+
}
133+
}
134+
135+
private fun encodeBufferedFrames() {
136+
CoroutineScope(encodingDispatcher).launch {
137+
// Keep encoding until lastFrameBuffer is empty.
138+
// This loop ensures we encode the "last frame" - the settled state of any animation,
139+
// even if intermediate frames were captured while we were encoding.
140+
while (true) {
141+
val frameData = lastFrameBuffer.getAndSet(null) ?: break
142+
try {
143+
val screenshot = encodeScreenshotFromBuffer(frameData.bitmap)
96144
onFrameTimingSequence(
97-
FrameTimingSequence(frameId, threadId, beginTimestamp, endTimestamp, screenshot)
145+
FrameTimingSequence(
146+
frameData.frameId,
147+
frameData.threadId,
148+
frameData.beginTimestamp,
149+
frameData.endTimestamp,
150+
screenshot,
151+
)
98152
)
153+
} finally {
154+
frameData.bitmap.recycle()
99155
}
100156
}
101-
} else {
102-
CoroutineScope(Dispatchers.Default).launch {
103-
onFrameTimingSequence(
104-
FrameTimingSequence(frameId, threadId, beginTimestamp, endTimestamp, null)
105-
)
106-
}
157+
encodingInProgress.set(false)
107158
}
108159
}
109160

110161
// Must be called from the main thread so that PixelCopy captures the current frame.
111-
private fun captureScreenshot(callback: (ByteArray?) -> Unit) {
162+
// Stores the captured bitmap and frame metadata in lastFrameBuffer, replacing any previous
163+
// frame. This ensures we always have the most recent frame available for encoding.
164+
private fun captureScreenshotToBuffer(
165+
frameId: Int,
166+
threadId: Int,
167+
beginTimestamp: Long,
168+
endTimestamp: Long,
169+
callback: () -> Unit,
170+
) {
112171
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
113-
callback(null)
114172
return
115173
}
116174

117175
val window = currentWindow
118176
if (window == null) {
119-
callback(null)
120177
return
121178
}
122179

@@ -130,26 +187,27 @@ internal class FrameTimingsObserver(
130187
bitmap,
131188
{ copyResult ->
132189
if (copyResult == PixelCopy.SUCCESS) {
133-
CoroutineScope(Dispatchers.Default).launch {
134-
callback(encodeScreenshot(window, bitmap, width, height))
135-
}
190+
// Store frame data in lastFrameBuffer, recycling any previous frame's bitmap
191+
val oldFrameData =
192+
lastFrameBuffer.getAndSet(
193+
FrameData(bitmap, frameId, threadId, beginTimestamp, endTimestamp)
194+
)
195+
oldFrameData?.bitmap?.recycle()
196+
callback()
136197
} else {
137198
bitmap.recycle()
138-
callback(null)
139199
}
140200
},
141201
mainHandler,
142202
)
143203
}
144204

145-
private fun encodeScreenshot(
146-
window: Window,
147-
bitmap: Bitmap,
148-
width: Int,
149-
height: Int,
150-
): ByteArray? {
205+
private fun encodeScreenshotFromBuffer(bitmap: Bitmap): ByteArray? {
151206
var scaledBitmap: Bitmap? = null
152207
return try {
208+
val window = currentWindow ?: return null
209+
val width = bitmap.width
210+
val height = bitmap.height
153211
val density = window.context.resources.displayMetrics.density
154212
val scaledWidth = (width / density * SCREENSHOT_SCALE_FACTOR).toInt()
155213
val scaledHeight = (height / density * SCREENSHOT_SCALE_FACTOR).toInt()
@@ -167,7 +225,6 @@ internal class FrameTimingsObserver(
167225
null
168226
} finally {
169227
scaledBitmap?.recycle()
170-
bitmap.recycle()
171228
}
172229
}
173230

0 commit comments

Comments
 (0)