Skip to content

Commit e1f2673

Browse files
huntiefacebook-github-bot
authored andcommitted
Add idle frame support to Performance timeline (#57952)
Summary: Implement Idle frame spans in React Native DevTools, by emitting synthetic `NeedsBeginFrameChanged` + `BeginFrame` events. **Definition** An idle frame is emitted whenever the gap between two consecutive frames exceeds one vsync interval, derived from the display's refresh rate (`CADisplayLink.duration` on iOS, `Display.refreshRate` on Android, falling back to 60 Hz). ``` frame N gap > 1 vsync frame N+1 +------------+ +--------------------------------+ +------------+ | BeginFrame | | NeedsBeginFrameChanged | | BeginFrame | | DrawFrame | | BeginFrame (no DrawFrame) | | DrawFrame | +------------+ +--------------------------------+ +------------+ rendered as an "Idle frame" ``` **Implementation notes** - Drop frames that begin before the recording start — Android `FrameMetrics` can deliver frames from app startup, and the first iOS `CADisplayLink` callback reports the previous vsync. - Sort frames by begin timestamp before serializing, since async screenshot encoding can deliver them out of order and break gap detection. - iOS: skip the frame event when the screenshot is unchanged, letting the gap render as an idle frame. Changelog: [Internal] Reviewed By: hoxyq Differential Revision: D97502569
1 parent 8804333 commit e1f2673

18 files changed

Lines changed: 280 additions & 45 deletions

packages/react-native/React/DevSupport/RCTFrameTimingsObserver.mm

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
jsinspector_modern::tracing::ThreadId threadId;
3636
HighResTimeStamp beginTimestamp;
3737
HighResTimeStamp endTimestamp;
38+
HighResDuration vsyncInterval;
3839
};
3940

4041
} // namespace
@@ -84,9 +85,11 @@ - (void)start
8485
_lastFrameData.reset();
8586
}
8687

87-
// Emit initial frame event
88+
// Emit an initial render frame. Keep it zero-duration: the frontend derives
89+
// each frame's rendered length from the gap to the next frame, so a synthetic
90+
// end time here can overlap the first real frame and corrupt the frames track.
8891
auto now = HighResTimeStamp::now();
89-
[self _emitFrameTimingWithBeginTimestamp:now endTimestamp:now];
92+
[self _emitFrameTimingWithBeginTimestamp:now endTimestamp:now vsyncInterval:HighResDuration::zero()];
9093

9194
_displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(_displayLinkTick:)];
9295
[_displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
@@ -115,11 +118,14 @@ - (void)_displayLinkTick:(CADisplayLink *)sender
115118
std::chrono::steady_clock::time_point(std::chrono::nanoseconds(beginNanos)));
116119
auto endTimestamp = HighResTimeStamp::fromChronoSteadyClockTimePoint(
117120
std::chrono::steady_clock::time_point(std::chrono::nanoseconds(endNanos)));
121+
auto vsyncInterval = HighResDuration::fromNanoseconds(static_cast<int64_t>(sender.duration * 1e9));
118122

119-
[self _emitFrameTimingWithBeginTimestamp:beginTimestamp endTimestamp:endTimestamp];
123+
[self _emitFrameTimingWithBeginTimestamp:beginTimestamp endTimestamp:endTimestamp vsyncInterval:vsyncInterval];
120124
}
121125

122-
- (void)_emitFrameTimingWithBeginTimestamp:(HighResTimeStamp)beginTimestamp endTimestamp:(HighResTimeStamp)endTimestamp
126+
- (void)_emitFrameTimingWithBeginTimestamp:(HighResTimeStamp)beginTimestamp
127+
endTimestamp:(HighResTimeStamp)endTimestamp
128+
vsyncInterval:(HighResDuration)vsyncInterval
123129
{
124130
uint64_t frameId = _frameCounter++;
125131
auto threadId = static_cast<jsinspector_modern::tracing::ThreadId>(pthread_mach_thread_np(pthread_self()));
@@ -130,22 +136,21 @@ - (void)_emitFrameTimingWithBeginTimestamp:(HighResTimeStamp)beginTimestamp endT
130136
threadId:threadId
131137
beginTimestamp:beginTimestamp
132138
endTimestamp:endTimestamp
133-
screenshot:std::nullopt];
139+
screenshot:std::nullopt
140+
vsyncInterval:vsyncInterval];
134141
return;
135142
}
136143

137144
UIImage *image = [self _captureScreenshot];
138145
if (image == nil) {
139-
// Failed to capture (e.g. no window, duplicate hash) - emit without screenshot
140-
[self _emitFrameEventWithFrameId:frameId
141-
threadId:threadId
142-
beginTimestamp:beginTimestamp
143-
endTimestamp:endTimestamp
144-
screenshot:std::nullopt];
146+
// Screenshot unchanged (duplicate hash) or capture failed — don't emit
147+
// a frame event. The serializer will fill the resulting gap with an idle
148+
// frame, matching Chrome's native behavior where idle = vsync with no
149+
// new rendering.
145150
return;
146151
}
147152

148-
FrameData frameData{image, frameId, threadId, beginTimestamp, endTimestamp};
153+
FrameData frameData{image, frameId, threadId, beginTimestamp, endTimestamp, vsyncInterval};
149154

150155
bool expected = false;
151156
if (_encodingInProgress.compare_exchange_strong(expected, true)) {
@@ -165,7 +170,8 @@ - (void)_emitFrameTimingWithBeginTimestamp:(HighResTimeStamp)beginTimestamp endT
165170
threadId:oldFrame->threadId
166171
beginTimestamp:oldFrame->beginTimestamp
167172
endTimestamp:oldFrame->endTimestamp
168-
screenshot:std::nullopt];
173+
screenshot:std::nullopt
174+
vsyncInterval:oldFrame->vsyncInterval];
169175
}
170176
}
171177
}
@@ -175,13 +181,14 @@ - (void)_emitFrameEventWithFrameId:(uint64_t)frameId
175181
beginTimestamp:(HighResTimeStamp)beginTimestamp
176182
endTimestamp:(HighResTimeStamp)endTimestamp
177183
screenshot:(std::optional<std::vector<uint8_t>>)screenshot
184+
vsyncInterval:(HighResDuration)vsyncInterval
178185
{
179186
dispatch_async(dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0), ^{
180187
if (!self->_running.load(std::memory_order_relaxed)) {
181188
return;
182189
}
183190
jsinspector_modern::tracing::FrameTimingSequence sequence{
184-
frameId, threadId, beginTimestamp, endTimestamp, std::move(screenshot)};
191+
frameId, threadId, beginTimestamp, endTimestamp, std::move(screenshot), vsyncInterval};
185192
self->_callback(std::move(sequence));
186193
});
187194
}
@@ -198,7 +205,8 @@ - (void)_encodeFrame:(FrameData)frameData
198205
threadId:frameData.threadId
199206
beginTimestamp:frameData.beginTimestamp
200207
endTimestamp:frameData.endTimestamp
201-
screenshot:std::move(screenshot)];
208+
screenshot:std::move(screenshot)
209+
vsyncInterval:frameData.vsyncInterval];
202210

203211
// Clear encoding flag early, allowing new frames to start fresh encoding
204212
// sessions
@@ -221,7 +229,8 @@ - (void)_encodeFrame:(FrameData)frameData
221229
threadId:tailFrame->threadId
222230
beginTimestamp:tailFrame->beginTimestamp
223231
endTimestamp:tailFrame->endTimestamp
224-
screenshot:std::move(tailScreenshot)];
232+
screenshot:std::move(tailScreenshot)
233+
vsyncInterval:tailFrame->vsyncInterval];
225234
}
226235
});
227236
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,5 @@ internal data class FrameTimingSequence(
1313
val beginTimestamp: Long,
1414
val endTimestamp: Long,
1515
val screenshot: ByteArray? = null,
16+
val vsyncIntervalNanos: Long = 0,
1617
)

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

Lines changed: 49 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ internal class FrameTimingsObserver(
5454
val threadId: Int,
5555
val beginTimestamp: Long,
5656
val endTimestamp: Long,
57+
val vsyncIntervalNanos: Long,
5758
)
5859

5960
fun start() {
@@ -66,9 +67,11 @@ internal class FrameTimingsObserver(
6667
lastFrameBuffer.set(null)
6768
isTracing = true
6869

69-
// Emit initial frame event
70+
// Emit an initial render frame. Keep it zero-duration: the frontend derives
71+
// each frame's rendered length from the gap to the next frame, so a synthetic
72+
// end time here can overlap the first real frame and corrupt the frames track.
7073
val timestamp = System.nanoTime()
71-
emitFrameTiming(timestamp, timestamp)
74+
emitFrameTiming(timestamp, timestamp, vsyncIntervalNanos = 0)
7275

7376
currentWindow?.addOnFrameMetricsAvailableListener(frameMetricsListener, mainHandler)
7477
}
@@ -97,27 +100,35 @@ internal class FrameTimingsObserver(
97100
}
98101
}
99102

100-
private val frameMetricsListener = Window.OnFrameMetricsAvailableListener { _, frameMetrics, _ ->
101-
// Guard against calls after stop()
102-
if (!isTracing) {
103-
return@OnFrameMetricsAvailableListener
104-
}
105-
val beginTimestamp = frameMetrics.getMetric(FrameMetrics.VSYNC_TIMESTAMP)
106-
val endTimestamp = beginTimestamp + frameMetrics.getMetric(FrameMetrics.TOTAL_DURATION)
107-
emitFrameTiming(beginTimestamp, endTimestamp)
108-
}
103+
private val frameMetricsListener =
104+
Window.OnFrameMetricsAvailableListener { window, frameMetrics, _ ->
105+
// Guard against calls after stop()
106+
if (!isTracing) {
107+
return@OnFrameMetricsAvailableListener
108+
}
109+
val beginTimestamp = frameMetrics.getMetric(FrameMetrics.VSYNC_TIMESTAMP)
110+
val endTimestamp = beginTimestamp + frameMetrics.getMetric(FrameMetrics.TOTAL_DURATION)
111+
val refreshRate = window.decorView.display?.refreshRate ?: 60f
112+
val vsyncIntervalNanos = (1_000_000_000L / refreshRate).toLong()
113+
emitFrameTiming(beginTimestamp, endTimestamp, vsyncIntervalNanos)
114+
}
109115

110-
private fun emitFrameTiming(beginTimestamp: Long, endTimestamp: Long) {
116+
private fun emitFrameTiming(
117+
beginTimestamp: Long,
118+
endTimestamp: Long,
119+
vsyncIntervalNanos: Long = 0,
120+
) {
111121
val frameId = frameCounter++
112122
val threadId = Process.myTid()
113123

114124
if (!screenshotsEnabled) {
115125
// Screenshots disabled - emit without screenshot
116-
emitFrameEvent(frameId, threadId, beginTimestamp, endTimestamp, null)
126+
emitFrameEvent(frameId, threadId, beginTimestamp, endTimestamp, null, vsyncIntervalNanos)
117127
return
118128
}
119129

120-
captureScreenshot(frameId, threadId, beginTimestamp, endTimestamp) { frameData ->
130+
captureScreenshot(frameId, threadId, beginTimestamp, endTimestamp, vsyncIntervalNanos) {
131+
frameData ->
121132
if (frameData != null) {
122133
if (encodingInProgress.compareAndSet(false, true)) {
123134
// Not encoding - encode this frame immediately
@@ -133,13 +144,14 @@ internal class FrameTimingsObserver(
133144
oldFrameData.beginTimestamp,
134145
oldFrameData.endTimestamp,
135146
null,
147+
oldFrameData.vsyncIntervalNanos,
136148
)
137149
oldFrameData.bitmap.recycle()
138150
}
139151
}
140152
} else {
141153
// Failed to capture (e.g. timeout) - emit without screenshot
142-
emitFrameEvent(frameId, threadId, beginTimestamp, endTimestamp, null)
154+
emitFrameEvent(frameId, threadId, beginTimestamp, endTimestamp, null, vsyncIntervalNanos)
143155
}
144156
}
145157
}
@@ -150,10 +162,18 @@ internal class FrameTimingsObserver(
150162
beginTimestamp: Long,
151163
endTimestamp: Long,
152164
screenshot: ByteArray?,
165+
vsyncIntervalNanos: Long = 0,
153166
) {
154167
CoroutineScope(Dispatchers.Default).launch {
155168
onFrameTimingSequence(
156-
FrameTimingSequence(frameId, threadId, beginTimestamp, endTimestamp, screenshot),
169+
FrameTimingSequence(
170+
frameId,
171+
threadId,
172+
beginTimestamp,
173+
endTimestamp,
174+
screenshot,
175+
vsyncIntervalNanos,
176+
),
157177
)
158178
}
159179
}
@@ -168,6 +188,7 @@ internal class FrameTimingsObserver(
168188
frameData.beginTimestamp,
169189
frameData.endTimestamp,
170190
screenshot,
191+
frameData.vsyncIntervalNanos,
171192
)
172193
} finally {
173194
frameData.bitmap.recycle()
@@ -187,6 +208,7 @@ internal class FrameTimingsObserver(
187208
tailFrame.beginTimestamp,
188209
tailFrame.endTimestamp,
189210
screenshot,
211+
tailFrame.vsyncIntervalNanos,
190212
)
191213
} finally {
192214
tailFrame.bitmap.recycle()
@@ -201,6 +223,7 @@ internal class FrameTimingsObserver(
201223
threadId: Int,
202224
beginTimestamp: Long,
203225
endTimestamp: Long,
226+
vsyncIntervalNanos: Long,
204227
callback: (FrameData?) -> Unit,
205228
) {
206229
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
@@ -226,7 +249,16 @@ internal class FrameTimingsObserver(
226249
bitmap,
227250
{ copyResult ->
228251
if (copyResult == PixelCopy.SUCCESS) {
229-
callback(FrameData(bitmap, frameId, threadId, beginTimestamp, endTimestamp))
252+
callback(
253+
FrameData(
254+
bitmap,
255+
frameId,
256+
threadId,
257+
beginTimestamp,
258+
endTimestamp,
259+
vsyncIntervalNanos,
260+
),
261+
)
230262
} else {
231263
bitmap.recycle()
232264
callback(null)

packages/react-native/ReactAndroid/src/main/jni/react/runtime/jni/JReactHostInspectorTarget.cpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,7 @@ void JReactHostInspectorTarget::recordFrameTimings(
251251
frameTimingSequence->getBeginTimestamp(),
252252
frameTimingSequence->getEndTimestamp(),
253253
frameTimingSequence->getScreenshot(),
254+
frameTimingSequence->getVsyncInterval(),
254255
});
255256
}
256257

packages/react-native/ReactAndroid/src/main/jni/react/runtime/jni/JReactHostInspectorTarget.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,12 @@ struct JFrameTimingSequence : public jni::JavaClass<JFrameTimingSequence> {
113113
}
114114
return std::nullopt;
115115
}
116+
117+
HighResDuration getVsyncInterval() const
118+
{
119+
auto field = javaClassStatic()->getField<jlong>("vsyncIntervalNanos");
120+
return HighResDuration::fromNanoseconds(static_cast<int64_t>(getFieldValue(field)));
121+
}
116122
};
117123

118124
struct JReactHostImpl : public jni::JavaClass<JReactHostImpl> {

packages/react-native/ReactCommon/jsinspector-modern/tracing/FrameTimingSequence.h

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,14 @@ struct FrameTimingSequence {
3030
ThreadId threadId,
3131
HighResTimeStamp beginTimestamp,
3232
HighResTimeStamp endTimestamp,
33-
std::optional<std::vector<uint8_t>> screenshot = std::nullopt)
33+
std::optional<std::vector<uint8_t>> screenshot = std::nullopt,
34+
HighResDuration vsyncInterval = HighResDuration::zero())
3435
: id(id),
3536
threadId(threadId),
3637
beginTimestamp(beginTimestamp),
3738
endTimestamp(endTimestamp),
38-
screenshot(std::move(screenshot))
39+
screenshot(std::move(screenshot)),
40+
vsyncInterval(vsyncInterval)
3941
{
4042
}
4143

@@ -56,6 +58,12 @@ struct FrameTimingSequence {
5658
* Optional screenshot data captured during the frame.
5759
*/
5860
std::optional<std::vector<uint8_t>> screenshot;
61+
62+
/**
63+
* Duration of one vsync interval from the device's display refresh rate.
64+
* Zero when unknown (e.g. the initial synthetic frame).
65+
*/
66+
HighResDuration vsyncInterval = HighResDuration::zero();
5967
};
6068

6169
} // namespace facebook::react::jsinspector_modern::tracing

0 commit comments

Comments
 (0)