Skip to content

Commit 949c515

Browse files
huntiefacebook-github-bot
authored andcommitted
Add idle frame support to Performance timeline (#56400)
Summary: Implement Idle frame spans in Chrome DevTools, by emitting synthetic `NeedsBeginFrameChanged` + `BeginFrame` + `DrawFrame` events. An idle frame = a vsync where no new rendering occurred. Changelog: [Internal] Differential Revision: D97502569
1 parent c29ef97 commit 949c515

9 files changed

Lines changed: 243 additions & 36 deletions

File tree

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

Lines changed: 26 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,12 @@ - (void)start
8485
_lastFrameData.reset();
8586
}
8687

87-
// Emit initial frame event
88+
// Emit initial render frame
8889
auto now = HighResTimeStamp::now();
89-
[self _emitFrameTimingWithBeginTimestamp:now endTimestamp:now];
90+
auto vsyncDuration = std::chrono::duration_cast<std::chrono::nanoseconds>(std::chrono::seconds(1)) /
91+
UIScreen.mainScreen.maximumFramesPerSecond;
92+
auto initialFrameEnd = now + HighResDuration::fromNanoseconds(vsyncDuration.count());
93+
[self _emitFrameTimingWithBeginTimestamp:now endTimestamp:initialFrameEnd vsyncInterval:HighResDuration::zero()];
9094

9195
_displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(_displayLinkTick:)];
9296
[_displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
@@ -115,11 +119,14 @@ - (void)_displayLinkTick:(CADisplayLink *)sender
115119
std::chrono::steady_clock::time_point(std::chrono::nanoseconds(beginNanos)));
116120
auto endTimestamp = HighResTimeStamp::fromChronoSteadyClockTimePoint(
117121
std::chrono::steady_clock::time_point(std::chrono::nanoseconds(endNanos)));
122+
auto vsyncInterval = HighResDuration::fromNanoseconds(static_cast<int64_t>(sender.duration * 1e9));
118123

119-
[self _emitFrameTimingWithBeginTimestamp:beginTimestamp endTimestamp:endTimestamp];
124+
[self _emitFrameTimingWithBeginTimestamp:beginTimestamp endTimestamp:endTimestamp vsyncInterval:vsyncInterval];
120125
}
121126

122-
- (void)_emitFrameTimingWithBeginTimestamp:(HighResTimeStamp)beginTimestamp endTimestamp:(HighResTimeStamp)endTimestamp
127+
- (void)_emitFrameTimingWithBeginTimestamp:(HighResTimeStamp)beginTimestamp
128+
endTimestamp:(HighResTimeStamp)endTimestamp
129+
vsyncInterval:(HighResDuration)vsyncInterval
123130
{
124131
uint64_t frameId = _frameCounter++;
125132
auto threadId = static_cast<jsinspector_modern::tracing::ThreadId>(pthread_mach_thread_np(pthread_self()));
@@ -130,22 +137,21 @@ - (void)_emitFrameTimingWithBeginTimestamp:(HighResTimeStamp)beginTimestamp endT
130137
threadId:threadId
131138
beginTimestamp:beginTimestamp
132139
endTimestamp:endTimestamp
133-
screenshot:std::nullopt];
140+
screenshot:std::nullopt
141+
vsyncInterval:vsyncInterval];
134142
return;
135143
}
136144

137145
UIImage *image = [self _captureScreenshot];
138146
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];
147+
// Screenshot unchanged (duplicate hash) or capture failed — don't emit
148+
// a frame event. The serializer will fill the resulting gap with an idle
149+
// frame, matching Chrome's native behavior where idle = vsync with no
150+
// new rendering.
145151
return;
146152
}
147153

148-
FrameData frameData{image, frameId, threadId, beginTimestamp, endTimestamp};
154+
FrameData frameData{image, frameId, threadId, beginTimestamp, endTimestamp, vsyncInterval};
149155

150156
bool expected = false;
151157
if (_encodingInProgress.compare_exchange_strong(expected, true)) {
@@ -165,7 +171,8 @@ - (void)_emitFrameTimingWithBeginTimestamp:(HighResTimeStamp)beginTimestamp endT
165171
threadId:oldFrame->threadId
166172
beginTimestamp:oldFrame->beginTimestamp
167173
endTimestamp:oldFrame->endTimestamp
168-
screenshot:std::nullopt];
174+
screenshot:std::nullopt
175+
vsyncInterval:oldFrame->vsyncInterval];
169176
}
170177
}
171178
}
@@ -175,13 +182,14 @@ - (void)_emitFrameEventWithFrameId:(uint64_t)frameId
175182
beginTimestamp:(HighResTimeStamp)beginTimestamp
176183
endTimestamp:(HighResTimeStamp)endTimestamp
177184
screenshot:(std::optional<std::vector<uint8_t>>)screenshot
185+
vsyncInterval:(HighResDuration)vsyncInterval
178186
{
179187
dispatch_async(dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0), ^{
180188
if (!self->_running.load(std::memory_order_relaxed)) {
181189
return;
182190
}
183191
jsinspector_modern::tracing::FrameTimingSequence sequence{
184-
frameId, threadId, beginTimestamp, endTimestamp, std::move(screenshot)};
192+
frameId, threadId, beginTimestamp, endTimestamp, std::move(screenshot), vsyncInterval};
185193
self->_callback(std::move(sequence));
186194
});
187195
}
@@ -198,7 +206,8 @@ - (void)_encodeFrame:(FrameData)frameData
198206
threadId:frameData.threadId
199207
beginTimestamp:frameData.beginTimestamp
200208
endTimestamp:frameData.endTimestamp
201-
screenshot:std::move(screenshot)];
209+
screenshot:std::move(screenshot)
210+
vsyncInterval:frameData.vsyncInterval];
202211

203212
// Clear encoding flag early, allowing new frames to start fresh encoding
204213
// sessions
@@ -221,7 +230,8 @@ - (void)_encodeFrame:(FrameData)frameData
221230
threadId:tailFrame->threadId
222231
beginTimestamp:tailFrame->beginTimestamp
223232
endTimestamp:tailFrame->endTimestamp
224-
screenshot:std::move(tailScreenshot)];
233+
screenshot:std::move(tailScreenshot)
234+
vsyncInterval:tailFrame->vsyncInterval];
225235
}
226236
});
227237
}

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: 50 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import android.view.Window
1818
import com.facebook.proguard.annotations.DoNotStripAny
1919
import java.io.ByteArrayOutputStream
2020
import java.util.concurrent.Executors
21+
import java.util.concurrent.TimeUnit
2122
import java.util.concurrent.atomic.AtomicBoolean
2223
import java.util.concurrent.atomic.AtomicReference
2324
import kotlinx.coroutines.CoroutineDispatcher
@@ -54,6 +55,7 @@ internal class FrameTimingsObserver(
5455
val threadId: Int,
5556
val beginTimestamp: Long,
5657
val endTimestamp: Long,
58+
val vsyncIntervalNanos: Long,
5759
)
5860

5961
fun start() {
@@ -66,9 +68,11 @@ internal class FrameTimingsObserver(
6668
lastFrameBuffer.set(null)
6769
isTracing = true
6870

69-
// Emit initial frame event
71+
// Emit initial render frame
7072
val timestamp = System.nanoTime()
71-
emitFrameTiming(timestamp, timestamp)
73+
val fps = currentWindow?.decorView?.display?.refreshRate ?: 60f
74+
val vsyncNanos = (TimeUnit.SECONDS.toNanos(1) / fps).toLong()
75+
emitFrameTiming(timestamp, timestamp + vsyncNanos, vsyncIntervalNanos = 0)
7276

7377
currentWindow?.addOnFrameMetricsAvailableListener(frameMetricsListener, mainHandler)
7478
}
@@ -97,27 +101,35 @@ internal class FrameTimingsObserver(
97101
}
98102
}
99103

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

110-
private fun emitFrameTiming(beginTimestamp: Long, endTimestamp: Long) {
117+
private fun emitFrameTiming(
118+
beginTimestamp: Long,
119+
endTimestamp: Long,
120+
vsyncIntervalNanos: Long = 0,
121+
) {
111122
val frameId = frameCounter++
112123
val threadId = Process.myTid()
113124

114125
if (!screenshotsEnabled) {
115126
// Screenshots disabled - emit without screenshot
116-
emitFrameEvent(frameId, threadId, beginTimestamp, endTimestamp, null)
127+
emitFrameEvent(frameId, threadId, beginTimestamp, endTimestamp, null, vsyncIntervalNanos)
117128
return
118129
}
119130

120-
captureScreenshot(frameId, threadId, beginTimestamp, endTimestamp) { frameData ->
131+
captureScreenshot(frameId, threadId, beginTimestamp, endTimestamp, vsyncIntervalNanos) {
132+
frameData ->
121133
if (frameData != null) {
122134
if (encodingInProgress.compareAndSet(false, true)) {
123135
// Not encoding - encode this frame immediately
@@ -133,13 +145,14 @@ internal class FrameTimingsObserver(
133145
oldFrameData.beginTimestamp,
134146
oldFrameData.endTimestamp,
135147
null,
148+
oldFrameData.vsyncIntervalNanos,
136149
)
137150
oldFrameData.bitmap.recycle()
138151
}
139152
}
140153
} else {
141154
// Failed to capture (e.g. timeout) - emit without screenshot
142-
emitFrameEvent(frameId, threadId, beginTimestamp, endTimestamp, null)
155+
emitFrameEvent(frameId, threadId, beginTimestamp, endTimestamp, null, vsyncIntervalNanos)
143156
}
144157
}
145158
}
@@ -150,10 +163,18 @@ internal class FrameTimingsObserver(
150163
beginTimestamp: Long,
151164
endTimestamp: Long,
152165
screenshot: ByteArray?,
166+
vsyncIntervalNanos: Long = 0,
153167
) {
154168
CoroutineScope(Dispatchers.Default).launch {
155169
onFrameTimingSequence(
156-
FrameTimingSequence(frameId, threadId, beginTimestamp, endTimestamp, screenshot)
170+
FrameTimingSequence(
171+
frameId,
172+
threadId,
173+
beginTimestamp,
174+
endTimestamp,
175+
screenshot,
176+
vsyncIntervalNanos,
177+
)
157178
)
158179
}
159180
}
@@ -168,6 +189,7 @@ internal class FrameTimingsObserver(
168189
frameData.beginTimestamp,
169190
frameData.endTimestamp,
170191
screenshot,
192+
frameData.vsyncIntervalNanos,
171193
)
172194
} finally {
173195
frameData.bitmap.recycle()
@@ -187,6 +209,7 @@ internal class FrameTimingsObserver(
187209
tailFrame.beginTimestamp,
188210
tailFrame.endTimestamp,
189211
screenshot,
212+
tailFrame.vsyncIntervalNanos,
190213
)
191214
} finally {
192215
tailFrame.bitmap.recycle()
@@ -201,6 +224,7 @@ internal class FrameTimingsObserver(
201224
threadId: Int,
202225
beginTimestamp: Long,
203226
endTimestamp: Long,
227+
vsyncIntervalNanos: Long,
204228
callback: (FrameData?) -> Unit,
205229
) {
206230
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
@@ -226,7 +250,16 @@ internal class FrameTimingsObserver(
226250
bitmap,
227251
{ copyResult ->
228252
if (copyResult == PixelCopy.SUCCESS) {
229-
callback(FrameData(bitmap, frameId, threadId, beginTimestamp, endTimestamp))
253+
callback(
254+
FrameData(
255+
bitmap,
256+
frameId,
257+
threadId,
258+
beginTimestamp,
259+
endTimestamp,
260+
vsyncIntervalNanos,
261+
)
262+
)
230263
} else {
231264
bitmap.recycle()
232265
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
@@ -241,6 +241,7 @@ void JReactHostInspectorTarget::recordFrameTimings(
241241
frameTimingSequence->getBeginTimestamp(),
242242
frameTimingSequence->getEndTimestamp(),
243243
frameTimingSequence->getScreenshot(),
244+
frameTimingSequence->getVsyncInterval(),
244245
});
245246
}
246247

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)