diff --git a/packages/react-native/React/DevSupport/RCTFrameTimingsObserver.mm b/packages/react-native/React/DevSupport/RCTFrameTimingsObserver.mm index ec9a0ae01fba..00b566a92146 100644 --- a/packages/react-native/React/DevSupport/RCTFrameTimingsObserver.mm +++ b/packages/react-native/React/DevSupport/RCTFrameTimingsObserver.mm @@ -35,6 +35,7 @@ jsinspector_modern::tracing::ThreadId threadId; HighResTimeStamp beginTimestamp; HighResTimeStamp endTimestamp; + HighResDuration vsyncInterval; }; } // namespace @@ -84,9 +85,12 @@ - (void)start _lastFrameData.reset(); } - // Emit initial frame event + // Emit initial render frame auto now = HighResTimeStamp::now(); - [self _emitFrameTimingWithBeginTimestamp:now endTimestamp:now]; + auto vsyncDuration = std::chrono::duration_cast(std::chrono::seconds(1)) / + UIScreen.mainScreen.maximumFramesPerSecond; + auto initialFrameEnd = now + HighResDuration::fromNanoseconds(vsyncDuration.count()); + [self _emitFrameTimingWithBeginTimestamp:now endTimestamp:initialFrameEnd vsyncInterval:HighResDuration::zero()]; _displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(_displayLinkTick:)]; [_displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes]; @@ -115,11 +119,14 @@ - (void)_displayLinkTick:(CADisplayLink *)sender std::chrono::steady_clock::time_point(std::chrono::nanoseconds(beginNanos))); auto endTimestamp = HighResTimeStamp::fromChronoSteadyClockTimePoint( std::chrono::steady_clock::time_point(std::chrono::nanoseconds(endNanos))); + auto vsyncInterval = HighResDuration::fromNanoseconds(static_cast(sender.duration * 1e9)); - [self _emitFrameTimingWithBeginTimestamp:beginTimestamp endTimestamp:endTimestamp]; + [self _emitFrameTimingWithBeginTimestamp:beginTimestamp endTimestamp:endTimestamp vsyncInterval:vsyncInterval]; } -- (void)_emitFrameTimingWithBeginTimestamp:(HighResTimeStamp)beginTimestamp endTimestamp:(HighResTimeStamp)endTimestamp +- (void)_emitFrameTimingWithBeginTimestamp:(HighResTimeStamp)beginTimestamp + endTimestamp:(HighResTimeStamp)endTimestamp + vsyncInterval:(HighResDuration)vsyncInterval { uint64_t frameId = _frameCounter++; auto threadId = static_cast(pthread_mach_thread_np(pthread_self())); @@ -130,22 +137,21 @@ - (void)_emitFrameTimingWithBeginTimestamp:(HighResTimeStamp)beginTimestamp endT threadId:threadId beginTimestamp:beginTimestamp endTimestamp:endTimestamp - screenshot:std::nullopt]; + screenshot:std::nullopt + vsyncInterval:vsyncInterval]; return; } UIImage *image = [self _captureScreenshot]; if (image == nil) { - // Failed to capture (e.g. no window, duplicate hash) - emit without screenshot - [self _emitFrameEventWithFrameId:frameId - threadId:threadId - beginTimestamp:beginTimestamp - endTimestamp:endTimestamp - screenshot:std::nullopt]; + // Screenshot unchanged (duplicate hash) or capture failed — don't emit + // a frame event. The serializer will fill the resulting gap with an idle + // frame, matching Chrome's native behavior where idle = vsync with no + // new rendering. return; } - FrameData frameData{image, frameId, threadId, beginTimestamp, endTimestamp}; + FrameData frameData{image, frameId, threadId, beginTimestamp, endTimestamp, vsyncInterval}; bool expected = false; if (_encodingInProgress.compare_exchange_strong(expected, true)) { @@ -165,7 +171,8 @@ - (void)_emitFrameTimingWithBeginTimestamp:(HighResTimeStamp)beginTimestamp endT threadId:oldFrame->threadId beginTimestamp:oldFrame->beginTimestamp endTimestamp:oldFrame->endTimestamp - screenshot:std::nullopt]; + screenshot:std::nullopt + vsyncInterval:oldFrame->vsyncInterval]; } } } @@ -175,13 +182,14 @@ - (void)_emitFrameEventWithFrameId:(uint64_t)frameId beginTimestamp:(HighResTimeStamp)beginTimestamp endTimestamp:(HighResTimeStamp)endTimestamp screenshot:(std::optional>)screenshot + vsyncInterval:(HighResDuration)vsyncInterval { dispatch_async(dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0), ^{ if (!self->_running.load(std::memory_order_relaxed)) { return; } jsinspector_modern::tracing::FrameTimingSequence sequence{ - frameId, threadId, beginTimestamp, endTimestamp, std::move(screenshot)}; + frameId, threadId, beginTimestamp, endTimestamp, std::move(screenshot), vsyncInterval}; self->_callback(std::move(sequence)); }); } @@ -198,7 +206,8 @@ - (void)_encodeFrame:(FrameData)frameData threadId:frameData.threadId beginTimestamp:frameData.beginTimestamp endTimestamp:frameData.endTimestamp - screenshot:std::move(screenshot)]; + screenshot:std::move(screenshot) + vsyncInterval:frameData.vsyncInterval]; // Clear encoding flag early, allowing new frames to start fresh encoding // sessions @@ -221,7 +230,8 @@ - (void)_encodeFrame:(FrameData)frameData threadId:tailFrame->threadId beginTimestamp:tailFrame->beginTimestamp endTimestamp:tailFrame->endTimestamp - screenshot:std::move(tailScreenshot)]; + screenshot:std::move(tailScreenshot) + vsyncInterval:tailFrame->vsyncInterval]; } }); } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/inspector/FrameTimingSequence.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/inspector/FrameTimingSequence.kt index 5eaae383eed3..21cc62addf78 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/inspector/FrameTimingSequence.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/inspector/FrameTimingSequence.kt @@ -13,4 +13,5 @@ internal data class FrameTimingSequence( val beginTimestamp: Long, val endTimestamp: Long, val screenshot: ByteArray? = null, + val vsyncIntervalNanos: Long = 0, ) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/inspector/FrameTimingsObserver.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/inspector/FrameTimingsObserver.kt index a88ef73aa95b..756d5653bdcc 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/inspector/FrameTimingsObserver.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/inspector/FrameTimingsObserver.kt @@ -18,6 +18,7 @@ import android.view.Window import com.facebook.proguard.annotations.DoNotStripAny import java.io.ByteArrayOutputStream import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference import kotlinx.coroutines.CoroutineDispatcher @@ -54,6 +55,7 @@ internal class FrameTimingsObserver( val threadId: Int, val beginTimestamp: Long, val endTimestamp: Long, + val vsyncIntervalNanos: Long, ) fun start() { @@ -66,9 +68,11 @@ internal class FrameTimingsObserver( lastFrameBuffer.set(null) isTracing = true - // Emit initial frame event + // Emit initial render frame val timestamp = System.nanoTime() - emitFrameTiming(timestamp, timestamp) + val fps = currentWindow?.decorView?.display?.refreshRate ?: 60f + val vsyncNanos = (TimeUnit.SECONDS.toNanos(1) / fps).toLong() + emitFrameTiming(timestamp, timestamp + vsyncNanos, vsyncIntervalNanos = 0) currentWindow?.addOnFrameMetricsAvailableListener(frameMetricsListener, mainHandler) } @@ -97,27 +101,35 @@ internal class FrameTimingsObserver( } } - private val frameMetricsListener = Window.OnFrameMetricsAvailableListener { _, frameMetrics, _ -> - // Guard against calls after stop() - if (!isTracing) { - return@OnFrameMetricsAvailableListener - } - val beginTimestamp = frameMetrics.getMetric(FrameMetrics.VSYNC_TIMESTAMP) - val endTimestamp = beginTimestamp + frameMetrics.getMetric(FrameMetrics.TOTAL_DURATION) - emitFrameTiming(beginTimestamp, endTimestamp) - } + private val frameMetricsListener = + Window.OnFrameMetricsAvailableListener { window, frameMetrics, _ -> + // Guard against calls after stop() + if (!isTracing) { + return@OnFrameMetricsAvailableListener + } + val beginTimestamp = frameMetrics.getMetric(FrameMetrics.VSYNC_TIMESTAMP) + val endTimestamp = beginTimestamp + frameMetrics.getMetric(FrameMetrics.TOTAL_DURATION) + val refreshRate = window.decorView.display?.refreshRate ?: 60f + val vsyncIntervalNanos = (1_000_000_000L / refreshRate).toLong() + emitFrameTiming(beginTimestamp, endTimestamp, vsyncIntervalNanos) + } - private fun emitFrameTiming(beginTimestamp: Long, endTimestamp: Long) { + private fun emitFrameTiming( + beginTimestamp: Long, + endTimestamp: Long, + vsyncIntervalNanos: Long = 0, + ) { val frameId = frameCounter++ val threadId = Process.myTid() if (!screenshotsEnabled) { // Screenshots disabled - emit without screenshot - emitFrameEvent(frameId, threadId, beginTimestamp, endTimestamp, null) + emitFrameEvent(frameId, threadId, beginTimestamp, endTimestamp, null, vsyncIntervalNanos) return } - captureScreenshot(frameId, threadId, beginTimestamp, endTimestamp) { frameData -> + captureScreenshot(frameId, threadId, beginTimestamp, endTimestamp, vsyncIntervalNanos) { + frameData -> if (frameData != null) { if (encodingInProgress.compareAndSet(false, true)) { // Not encoding - encode this frame immediately @@ -133,13 +145,14 @@ internal class FrameTimingsObserver( oldFrameData.beginTimestamp, oldFrameData.endTimestamp, null, + oldFrameData.vsyncIntervalNanos, ) oldFrameData.bitmap.recycle() } } } else { // Failed to capture (e.g. timeout) - emit without screenshot - emitFrameEvent(frameId, threadId, beginTimestamp, endTimestamp, null) + emitFrameEvent(frameId, threadId, beginTimestamp, endTimestamp, null, vsyncIntervalNanos) } } } @@ -150,10 +163,18 @@ internal class FrameTimingsObserver( beginTimestamp: Long, endTimestamp: Long, screenshot: ByteArray?, + vsyncIntervalNanos: Long = 0, ) { CoroutineScope(Dispatchers.Default).launch { onFrameTimingSequence( - FrameTimingSequence(frameId, threadId, beginTimestamp, endTimestamp, screenshot) + FrameTimingSequence( + frameId, + threadId, + beginTimestamp, + endTimestamp, + screenshot, + vsyncIntervalNanos, + ) ) } } @@ -168,6 +189,7 @@ internal class FrameTimingsObserver( frameData.beginTimestamp, frameData.endTimestamp, screenshot, + frameData.vsyncIntervalNanos, ) } finally { frameData.bitmap.recycle() @@ -187,6 +209,7 @@ internal class FrameTimingsObserver( tailFrame.beginTimestamp, tailFrame.endTimestamp, screenshot, + tailFrame.vsyncIntervalNanos, ) } finally { tailFrame.bitmap.recycle() @@ -201,6 +224,7 @@ internal class FrameTimingsObserver( threadId: Int, beginTimestamp: Long, endTimestamp: Long, + vsyncIntervalNanos: Long, callback: (FrameData?) -> Unit, ) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { @@ -226,7 +250,16 @@ internal class FrameTimingsObserver( bitmap, { copyResult -> if (copyResult == PixelCopy.SUCCESS) { - callback(FrameData(bitmap, frameId, threadId, beginTimestamp, endTimestamp)) + callback( + FrameData( + bitmap, + frameId, + threadId, + beginTimestamp, + endTimestamp, + vsyncIntervalNanos, + ) + ) } else { bitmap.recycle() callback(null) diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/runtime/jni/JReactHostInspectorTarget.cpp b/packages/react-native/ReactAndroid/src/main/jni/react/runtime/jni/JReactHostInspectorTarget.cpp index d8498aeea988..9560e4ce0962 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/runtime/jni/JReactHostInspectorTarget.cpp +++ b/packages/react-native/ReactAndroid/src/main/jni/react/runtime/jni/JReactHostInspectorTarget.cpp @@ -241,6 +241,7 @@ void JReactHostInspectorTarget::recordFrameTimings( frameTimingSequence->getBeginTimestamp(), frameTimingSequence->getEndTimestamp(), frameTimingSequence->getScreenshot(), + frameTimingSequence->getVsyncInterval(), }); } diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/runtime/jni/JReactHostInspectorTarget.h b/packages/react-native/ReactAndroid/src/main/jni/react/runtime/jni/JReactHostInspectorTarget.h index d2389940308b..09d08ee13172 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/runtime/jni/JReactHostInspectorTarget.h +++ b/packages/react-native/ReactAndroid/src/main/jni/react/runtime/jni/JReactHostInspectorTarget.h @@ -113,6 +113,12 @@ struct JFrameTimingSequence : public jni::JavaClass { } return std::nullopt; } + + HighResDuration getVsyncInterval() const + { + auto field = javaClassStatic()->getField("vsyncIntervalNanos"); + return HighResDuration::fromNanoseconds(static_cast(getFieldValue(field))); + } }; struct JReactHostImpl : public jni::JavaClass { diff --git a/packages/react-native/ReactCommon/jsinspector-modern/tracing/FrameTimingSequence.h b/packages/react-native/ReactCommon/jsinspector-modern/tracing/FrameTimingSequence.h index 6aa0db5d1744..56855ebb10f1 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/tracing/FrameTimingSequence.h +++ b/packages/react-native/ReactCommon/jsinspector-modern/tracing/FrameTimingSequence.h @@ -30,12 +30,14 @@ struct FrameTimingSequence { ThreadId threadId, HighResTimeStamp beginTimestamp, HighResTimeStamp endTimestamp, - std::optional> screenshot = std::nullopt) + std::optional> screenshot = std::nullopt, + HighResDuration vsyncInterval = HighResDuration::zero()) : id(id), threadId(threadId), beginTimestamp(beginTimestamp), endTimestamp(endTimestamp), - screenshot(std::move(screenshot)) + screenshot(std::move(screenshot)), + vsyncInterval(vsyncInterval) { } @@ -56,6 +58,12 @@ struct FrameTimingSequence { * Optional screenshot data captured during the frame. */ std::optional> screenshot; + + /** + * Duration of one vsync interval from the device's display refresh rate. + * Zero when unknown (e.g. the initial synthetic frame). + */ + HighResDuration vsyncInterval = HighResDuration::zero(); }; } // namespace facebook::react::jsinspector_modern::tracing diff --git a/packages/react-native/ReactCommon/jsinspector-modern/tracing/HostTracingProfileSerializer.cpp b/packages/react-native/ReactCommon/jsinspector-modern/tracing/HostTracingProfileSerializer.cpp index 1053790a3532..745f5edf6060 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/tracing/HostTracingProfileSerializer.cpp +++ b/packages/react-native/ReactCommon/jsinspector-modern/tracing/HostTracingProfileSerializer.cpp @@ -10,6 +10,8 @@ #include "TraceEventGenerator.h" #include "TraceEventSerializer.h" +#include + namespace facebook::react::jsinspector_modern::tracing { namespace { @@ -20,6 +22,11 @@ namespace { */ constexpr int FALLBACK_LAYER_TREE_ID = 1; +// Default vsync interval (60 Hz), used as a fallback when the device's +// actual refresh rate is unknown (e.g. the initial synthetic frame). +const auto DEFAULT_VSYNC_INTERVAL = + HighResDuration::fromNanoseconds(16'667'000); + } // namespace /* static */ void HostTracingProfileSerializer::emitAsDataCollectedChunks( @@ -104,11 +111,87 @@ constexpr int FALLBACK_LAYER_TREE_ID = 1; TraceEventSerializer::estimateJsonSize(serializedSetLayerTreeId); chunk.push_back(std::move(serializedSetLayerTreeId)); + // Filter out frames that started before recording began. On Android, + // FrameMetrics may deliver frames from app startup that predate the recording + // session; on iOS the first CADisplayLink callback reports sender.timestamp + // (the previous vsync) which can be before the recording start. These would + // otherwise appear as large pre-recording render frames in the timeline. + frameTimings.erase( + std::remove_if( + frameTimings.begin(), + frameTimings.end(), + [&recordingStartTimestamp](const FrameTimingSequence& ft) { + return ft.beginTimestamp < recordingStartTimestamp; + }), + frameTimings.end()); + + if (frameTimings.empty()) { + chunkCallback(std::move(chunk)); + return; + } + + // Sort frames by beginTimestamp to handle out-of-order arrivals caused by + // async screenshot encoding. The initial synthetic frame may arrive in the + // buffer after real frames because its screenshot encoding takes longer than + // one vsync (~16ms). Sorting ensures the idle-gap detection loop below sees + // frames in chronological order. + std::sort( + frameTimings.begin(), + frameTimings.end(), + [](const FrameTimingSequence& a, const FrameTimingSequence& b) { + return a.beginTimestamp < b.beginTimestamp; + }); + + // Compute the next available sequence ID for synthetic frames (idle frames + // and dropped frames). + FrameSequenceId nextSyntheticSeqId = 0; + for (const auto& ft : frameTimings) { + nextSyntheticSeqId = std::max(nextSyntheticSeqId, ft.id + 1); + } + + std::optional prevEndTimestamp; + for (auto&& frameTimingSequence : frameTimings) { // Serialize all events for this frame. folly::dynamic frameEvents = folly::dynamic::array(); size_t totalFrameBytes = 0; + // Detect idle period: gap between previous frame's end and this frame's + // begin exceeding one vsync interval. Emit NeedsBeginFrameChanged + + // BeginFrame to fill the gap. Chrome DevTools renders a BeginFrame + // without a corresponding DrawFrame as an "Idle frame". + auto minIdleGap = + frameTimingSequence.vsyncInterval > HighResDuration::zero() + ? frameTimingSequence.vsyncInterval + : DEFAULT_VSYNC_INTERVAL; + if (prevEndTimestamp.has_value() && + (frameTimingSequence.beginTimestamp - *prevEndTimestamp) > minIdleGap) { + auto needsBeginFrameEvent = + TraceEventGenerator::createNeedsBeginFrameChangedEvent( + FALLBACK_LAYER_TREE_ID, + *prevEndTimestamp, + processId, + frameTimingSequence.threadId); + auto serializedNeedsBeginFrame = + TraceEventSerializer::serialize(std::move(needsBeginFrameEvent)); + totalFrameBytes += + TraceEventSerializer::estimateJsonSize(serializedNeedsBeginFrame); + frameEvents.push_back(std::move(serializedNeedsBeginFrame)); + + auto idleBeginEvent = TraceEventGenerator::createIdleBeginFrameEvent( + nextSyntheticSeqId++, + FALLBACK_LAYER_TREE_ID, + *prevEndTimestamp, + processId, + frameTimingSequence.threadId); + + auto serializedIdleBegin = + TraceEventSerializer::serialize(std::move(idleBeginEvent)); + totalFrameBytes += + TraceEventSerializer::estimateJsonSize(serializedIdleBegin); + frameEvents.push_back(std::move(serializedIdleBegin)); + } + auto [beginDrawingEvent, endDrawingEvent] = TraceEventGenerator::createFrameTimingsEvents( frameTimingSequence.id, @@ -155,6 +238,8 @@ constexpr int FALLBACK_LAYER_TREE_ID = 1; chunk.push_back(std::move(frameEvent)); } currentChunkBytes += totalFrameBytes; + + prevEndTimestamp = frameTimingSequence.endTimestamp; } if (!chunk.empty()) { diff --git a/packages/react-native/ReactCommon/jsinspector-modern/tracing/TraceEventGenerator.cpp b/packages/react-native/ReactCommon/jsinspector-modern/tracing/TraceEventGenerator.cpp index 215c25e1bd8a..17b4db5343ad 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/tracing/TraceEventGenerator.cpp +++ b/packages/react-native/ReactCommon/jsinspector-modern/tracing/TraceEventGenerator.cpp @@ -34,6 +34,47 @@ namespace facebook::react::jsinspector_modern::tracing { }; } +/* static */ TraceEvent TraceEventGenerator::createNeedsBeginFrameChangedEvent( + int layerTreeId, + HighResTimeStamp timestamp, + ProcessId processId, + ThreadId threadId) { + folly::dynamic data = folly::dynamic::object("needsBeginFrame", 1); + + return TraceEvent{ + .name = "NeedsBeginFrameChanged", + .cat = {Category::Frame}, + .ph = 'I', + .ts = timestamp, + .pid = processId, + .s = 't', + .tid = threadId, + .args = folly::dynamic::object("layerTreeId", layerTreeId)( + "data", std::move(data)), + }; +} + +/* static */ TraceEvent TraceEventGenerator::createIdleBeginFrameEvent( + FrameSequenceId sequenceId, + int layerTreeId, + HighResTimeStamp timestamp, + ProcessId processId, + ThreadId threadId) { + folly::dynamic args = folly::dynamic::object("frameSeqId", sequenceId)( + "layerTreeId", layerTreeId); + + return TraceEvent{ + .name = "BeginFrame", + .cat = {Category::Frame}, + .ph = 'I', + .ts = timestamp, + .pid = processId, + .s = 't', + .tid = threadId, + .args = std::move(args), + }; +} + /* static */ std::pair TraceEventGenerator::createFrameTimingsEvents( uint64_t sequenceId, diff --git a/packages/react-native/ReactCommon/jsinspector-modern/tracing/TraceEventGenerator.h b/packages/react-native/ReactCommon/jsinspector-modern/tracing/TraceEventGenerator.h index 898fba6ef729..1eaa959166c9 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/tracing/TraceEventGenerator.h +++ b/packages/react-native/ReactCommon/jsinspector-modern/tracing/TraceEventGenerator.h @@ -35,7 +35,29 @@ class TraceEventGenerator { HighResTimeStamp timestamp); /** - * Creates canonical "BeginFrame", "Commit", "DrawFrame" trace events. + * Creates a "NeedsBeginFrameChanged" trace event to mark the start of an + * idle frame period. + */ + static TraceEvent createNeedsBeginFrameChangedEvent( + int layerTreeId, + HighResTimeStamp timestamp, + ProcessId processId, + ThreadId threadId); + + /** + * Creates a single "BeginFrame" trace event for an idle frame (no + * DrawFrame). Chrome DevTools renders a BeginFrame without a corresponding + * DrawFrame as an "Idle frame" in the Frames track. + */ + static TraceEvent createIdleBeginFrameEvent( + FrameSequenceId sequenceId, + int layerTreeId, + HighResTimeStamp timestamp, + ProcessId processId, + ThreadId threadId); + + /** + * Creates canonical "BeginFrame", "DrawFrame" trace events. */ static std::pair createFrameTimingsEvents( FrameSequenceId sequenceId,