|
| 1 | +/* |
| 2 | + * Copyright (c) Meta Platforms, Inc. and affiliates. |
| 3 | + * |
| 4 | + * This source code is licensed under the MIT license found in the |
| 5 | + * LICENSE file in the root directory of this source tree. |
| 6 | + */ |
| 7 | + |
| 8 | +#import "RCTFrameTimingsObserver.h" |
| 9 | + |
| 10 | +#import <UIKit/UIKit.h> |
| 11 | + |
| 12 | +#import <mach/thread_act.h> |
| 13 | +#import <pthread.h> |
| 14 | + |
| 15 | +#import <atomic> |
| 16 | +#import <chrono> |
| 17 | +#import <mutex> |
| 18 | +#import <optional> |
| 19 | +#import <vector> |
| 20 | + |
| 21 | +#import <react/timing/primitives.h> |
| 22 | + |
| 23 | +using namespace facebook::react; |
| 24 | + |
| 25 | +static constexpr CGFloat kScreenshotScaleFactor = 1.0; |
| 26 | +static constexpr CGFloat kScreenshotJPEGQuality = 0.8; |
| 27 | + |
| 28 | +namespace { |
| 29 | + |
| 30 | +// Stores a captured frame screenshot and its associated metadata, used for |
| 31 | +// buffering frames during dynamic sampling. |
| 32 | +struct FrameData { |
| 33 | + UIImage *image; |
| 34 | + uint64_t frameId; |
| 35 | + jsinspector_modern::tracing::ThreadId threadId; |
| 36 | + HighResTimeStamp beginTimestamp; |
| 37 | + HighResTimeStamp endTimestamp; |
| 38 | +}; |
| 39 | + |
| 40 | +} // namespace |
| 41 | + |
| 42 | +@implementation RCTFrameTimingsObserver { |
| 43 | + BOOL _screenshotsEnabled; |
| 44 | + RCTFrameTimingCallback _callback; |
| 45 | + CADisplayLink *_displayLink; |
| 46 | + uint64_t _frameCounter; |
| 47 | + // Serial queue for encoding work (single background thread). We limit to 1 |
| 48 | + // thread to minimize the performance impact of screenshot recording. |
| 49 | + dispatch_queue_t _encodingQueue; |
| 50 | + std::atomic<bool> _running; |
| 51 | + uint64_t _lastScreenshotHash; |
| 52 | + |
| 53 | + // Stores the most recently captured frame to opportunistically encode after |
| 54 | + // the current frame. Replaced frames are emitted as timings without |
| 55 | + // screenshots. |
| 56 | + std::mutex _lastFrameMutex; |
| 57 | + std::optional<FrameData> _lastFrameData; |
| 58 | + |
| 59 | + std::atomic<bool> _encodingInProgress; |
| 60 | +} |
| 61 | + |
| 62 | +- (instancetype)initWithScreenshotsEnabled:(BOOL)screenshotsEnabled callback:(RCTFrameTimingCallback)callback |
| 63 | +{ |
| 64 | + if (self = [super init]) { |
| 65 | + _screenshotsEnabled = screenshotsEnabled; |
| 66 | + _callback = [callback copy]; |
| 67 | + _frameCounter = 0; |
| 68 | + _encodingQueue = dispatch_queue_create("com.facebook.react.frame-timings-observer", DISPATCH_QUEUE_SERIAL); |
| 69 | + _running.store(false); |
| 70 | + _lastScreenshotHash = 0; |
| 71 | + _encodingInProgress.store(false); |
| 72 | + } |
| 73 | + return self; |
| 74 | +} |
| 75 | + |
| 76 | +- (void)start |
| 77 | +{ |
| 78 | + _running.store(true, std::memory_order_relaxed); |
| 79 | + _frameCounter = 0; |
| 80 | + _lastScreenshotHash = 0; |
| 81 | + _encodingInProgress.store(false, std::memory_order_relaxed); |
| 82 | + { |
| 83 | + std::lock_guard<std::mutex> lock(_lastFrameMutex); |
| 84 | + _lastFrameData.reset(); |
| 85 | + } |
| 86 | + |
| 87 | + // Emit initial frame event |
| 88 | + auto now = HighResTimeStamp::now(); |
| 89 | + [self _emitFrameTimingWithBeginTimestamp:now endTimestamp:now]; |
| 90 | + |
| 91 | + _displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(_displayLinkTick:)]; |
| 92 | + [_displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes]; |
| 93 | +} |
| 94 | + |
| 95 | +- (void)stop |
| 96 | +{ |
| 97 | + _running.store(false, std::memory_order_relaxed); |
| 98 | + [_displayLink invalidate]; |
| 99 | + _displayLink = nil; |
| 100 | + { |
| 101 | + std::lock_guard<std::mutex> lock(_lastFrameMutex); |
| 102 | + _lastFrameData.reset(); |
| 103 | + } |
| 104 | +} |
| 105 | + |
| 106 | +- (void)_displayLinkTick:(CADisplayLink *)sender |
| 107 | +{ |
| 108 | + // CADisplayLink.timestamp and targetTimestamp are in the same timebase as |
| 109 | + // CACurrentMediaTime() / mach_absolute_time(), which on Apple platforms maps |
| 110 | + // to CLOCK_UPTIME_RAW — the same clock backing std::chrono::steady_clock. |
| 111 | + auto beginNanos = static_cast<int64_t>(sender.timestamp * 1e9); |
| 112 | + auto endNanos = static_cast<int64_t>(sender.targetTimestamp * 1e9); |
| 113 | + |
| 114 | + auto beginTimestamp = HighResTimeStamp::fromChronoSteadyClockTimePoint( |
| 115 | + std::chrono::steady_clock::time_point(std::chrono::nanoseconds(beginNanos))); |
| 116 | + auto endTimestamp = HighResTimeStamp::fromChronoSteadyClockTimePoint( |
| 117 | + std::chrono::steady_clock::time_point(std::chrono::nanoseconds(endNanos))); |
| 118 | + |
| 119 | + [self _emitFrameTimingWithBeginTimestamp:beginTimestamp endTimestamp:endTimestamp]; |
| 120 | +} |
| 121 | + |
| 122 | +- (void)_emitFrameTimingWithBeginTimestamp:(HighResTimeStamp)beginTimestamp endTimestamp:(HighResTimeStamp)endTimestamp |
| 123 | +{ |
| 124 | + uint64_t frameId = _frameCounter++; |
| 125 | + auto threadId = static_cast<jsinspector_modern::tracing::ThreadId>(pthread_mach_thread_np(pthread_self())); |
| 126 | + |
| 127 | + if (!_screenshotsEnabled) { |
| 128 | + // Screenshots disabled - emit without screenshot |
| 129 | + [self _emitFrameEventWithFrameId:frameId |
| 130 | + threadId:threadId |
| 131 | + beginTimestamp:beginTimestamp |
| 132 | + endTimestamp:endTimestamp |
| 133 | + screenshot:std::nullopt]; |
| 134 | + return; |
| 135 | + } |
| 136 | + |
| 137 | + UIImage *image = [self _captureScreenshot]; |
| 138 | + 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]; |
| 145 | + return; |
| 146 | + } |
| 147 | + |
| 148 | + FrameData frameData{image, frameId, threadId, beginTimestamp, endTimestamp}; |
| 149 | + |
| 150 | + bool expected = false; |
| 151 | + if (_encodingInProgress.compare_exchange_strong(expected, true)) { |
| 152 | + // Not encoding - encode this frame immediately |
| 153 | + [self _encodeFrame:std::move(frameData)]; |
| 154 | + } else { |
| 155 | + // Encoding thread busy - store current screenshot in buffer for tail-capture |
| 156 | + std::optional<FrameData> oldFrame; |
| 157 | + { |
| 158 | + std::lock_guard<std::mutex> lock(_lastFrameMutex); |
| 159 | + oldFrame = std::move(_lastFrameData); |
| 160 | + _lastFrameData = std::move(frameData); |
| 161 | + } |
| 162 | + if (oldFrame.has_value()) { |
| 163 | + // Skipped frame - emit event without screenshot |
| 164 | + [self _emitFrameEventWithFrameId:oldFrame->frameId |
| 165 | + threadId:oldFrame->threadId |
| 166 | + beginTimestamp:oldFrame->beginTimestamp |
| 167 | + endTimestamp:oldFrame->endTimestamp |
| 168 | + screenshot:std::nullopt]; |
| 169 | + } |
| 170 | + } |
| 171 | +} |
| 172 | + |
| 173 | +- (void)_emitFrameEventWithFrameId:(uint64_t)frameId |
| 174 | + threadId:(jsinspector_modern::tracing::ThreadId)threadId |
| 175 | + beginTimestamp:(HighResTimeStamp)beginTimestamp |
| 176 | + endTimestamp:(HighResTimeStamp)endTimestamp |
| 177 | + screenshot:(std::optional<std::vector<uint8_t>>)screenshot |
| 178 | +{ |
| 179 | + dispatch_async(dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0), ^{ |
| 180 | + if (!self->_running.load(std::memory_order_relaxed)) { |
| 181 | + return; |
| 182 | + } |
| 183 | + jsinspector_modern::tracing::FrameTimingSequence sequence{ |
| 184 | + frameId, threadId, beginTimestamp, endTimestamp, std::move(screenshot)}; |
| 185 | + self->_callback(std::move(sequence)); |
| 186 | + }); |
| 187 | +} |
| 188 | + |
| 189 | +- (void)_encodeFrame:(FrameData)frameData |
| 190 | +{ |
| 191 | + dispatch_async(_encodingQueue, ^{ |
| 192 | + if (!self->_running.load(std::memory_order_relaxed)) { |
| 193 | + return; |
| 194 | + } |
| 195 | + |
| 196 | + auto screenshot = [self _encodeScreenshot:frameData.image]; |
| 197 | + [self _emitFrameEventWithFrameId:frameData.frameId |
| 198 | + threadId:frameData.threadId |
| 199 | + beginTimestamp:frameData.beginTimestamp |
| 200 | + endTimestamp:frameData.endTimestamp |
| 201 | + screenshot:std::move(screenshot)]; |
| 202 | + |
| 203 | + // Clear encoding flag early, allowing new frames to start fresh encoding |
| 204 | + // sessions |
| 205 | + self->_encodingInProgress.store(false, std::memory_order_release); |
| 206 | + |
| 207 | + // Opportunistically encode tail frame (if present) without blocking new |
| 208 | + // frames |
| 209 | + std::optional<FrameData> tailFrame; |
| 210 | + { |
| 211 | + std::lock_guard<std::mutex> lock(self->_lastFrameMutex); |
| 212 | + tailFrame = std::move(self->_lastFrameData); |
| 213 | + self->_lastFrameData.reset(); |
| 214 | + } |
| 215 | + if (tailFrame.has_value()) { |
| 216 | + if (!self->_running.load(std::memory_order_relaxed)) { |
| 217 | + return; |
| 218 | + } |
| 219 | + auto tailScreenshot = [self _encodeScreenshot:tailFrame->image]; |
| 220 | + [self _emitFrameEventWithFrameId:tailFrame->frameId |
| 221 | + threadId:tailFrame->threadId |
| 222 | + beginTimestamp:tailFrame->beginTimestamp |
| 223 | + endTimestamp:tailFrame->endTimestamp |
| 224 | + screenshot:std::move(tailScreenshot)]; |
| 225 | + } |
| 226 | + }); |
| 227 | +} |
| 228 | + |
| 229 | +// Captures a screenshot of the current window. Must be called on the main |
| 230 | +// thread. Returns nil if capture fails or if the frame content is unchanged. |
| 231 | +- (UIImage *)_captureScreenshot |
| 232 | +{ |
| 233 | + UIWindow *keyWindow = [self _getKeyWindow]; |
| 234 | + if (keyWindow == nil) { |
| 235 | + return nil; |
| 236 | + } |
| 237 | + |
| 238 | + UIView *rootView = keyWindow.rootViewController.view ?: keyWindow; |
| 239 | + CGSize viewSize = rootView.bounds.size; |
| 240 | + CGSize scaledSize = CGSizeMake(viewSize.width * kScreenshotScaleFactor, viewSize.height * kScreenshotScaleFactor); |
| 241 | + |
| 242 | + UIGraphicsImageRendererFormat *format = [UIGraphicsImageRendererFormat defaultFormat]; |
| 243 | + format.scale = 1.0; |
| 244 | + UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:scaledSize format:format]; |
| 245 | + |
| 246 | + UIImage *image = [renderer imageWithActions:^(UIGraphicsImageRendererContext *context) { |
| 247 | + [rootView drawViewHierarchyInRect:CGRectMake(0, 0, scaledSize.width, scaledSize.height) afterScreenUpdates:NO]; |
| 248 | + }]; |
| 249 | + |
| 250 | + // Skip duplicate frames via sampled FNV-1a pixel hash |
| 251 | + CGImageRef cgImage = image.CGImage; |
| 252 | + CFDataRef pixelData = CGDataProviderCopyData(CGImageGetDataProvider(cgImage)); |
| 253 | + uint64_t hash = 0xcbf29ce484222325ULL; |
| 254 | + const uint8_t *ptr = CFDataGetBytePtr(pixelData); |
| 255 | + CFIndex length = CFDataGetLength(pixelData); |
| 256 | + // Use prime stride to prevent row alignment on power-of-2 pixel widths |
| 257 | + for (CFIndex i = 0; i < length; i += 67) { |
| 258 | + hash ^= ptr[i]; |
| 259 | + hash *= 0x100000001b3ULL; |
| 260 | + } |
| 261 | + CFRelease(pixelData); |
| 262 | + |
| 263 | + if (hash == _lastScreenshotHash) { |
| 264 | + return nil; |
| 265 | + } |
| 266 | + _lastScreenshotHash = hash; |
| 267 | + |
| 268 | + return image; |
| 269 | +} |
| 270 | + |
| 271 | +- (std::optional<std::vector<uint8_t>>)_encodeScreenshot:(UIImage *)image |
| 272 | +{ |
| 273 | + NSData *jpegData = UIImageJPEGRepresentation(image, kScreenshotJPEGQuality); |
| 274 | + if (jpegData == nil) { |
| 275 | + return std::nullopt; |
| 276 | + } |
| 277 | + |
| 278 | + const auto *bytes = static_cast<const uint8_t *>(jpegData.bytes); |
| 279 | + return std::vector<uint8_t>(bytes, bytes + jpegData.length); |
| 280 | +} |
| 281 | + |
| 282 | +- (UIWindow *)_getKeyWindow |
| 283 | +{ |
| 284 | + for (UIScene *scene in UIApplication.sharedApplication.connectedScenes) { |
| 285 | + if (scene.activationState == UISceneActivationStateForegroundActive && |
| 286 | + [scene isKindOfClass:[UIWindowScene class]]) { |
| 287 | + auto windowScene = (UIWindowScene *)scene; |
| 288 | + for (UIWindow *window = nullptr in windowScene.windows) { |
| 289 | + if (window.isKeyWindow) { |
| 290 | + return window; |
| 291 | + } |
| 292 | + } |
| 293 | + } |
| 294 | + } |
| 295 | + return nil; |
| 296 | +} |
| 297 | + |
| 298 | +@end |
0 commit comments