diff --git a/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker+Private.h b/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker+Private.h index 9cbb868d799..3965417ee00 100644 --- a/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker+Private.h +++ b/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker+Private.h @@ -112,6 +112,20 @@ FOUNDATION_EXTERN CFTimeInterval const kFPRFrozenFrameThreshold; */ - (void)viewControllerDidDisappear:(id)viewController; +#if TARGET_OS_TV +/** Handles the UIScreenModeDidChangeNotification. Recomputes the cached slow budget when the screen + * mode changes on tvOS. + * + * @param notification The NSNotification object. + */ +- (void)screenModeDidChangeNotification:(NSNotification *)notification; +#endif + +/** Updates the cached maxFPS and slowBudget from UIScreen.maximumFramesPerSecond. + * This method must be called on the main thread. + */ +- (void)updateCachedSlowBudget; + @end NS_ASSUME_NONNULL_END diff --git a/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m b/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m index 5137776fb5f..104569d942c 100644 --- a/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m +++ b/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m @@ -28,9 +28,14 @@ // Note: This was previously 60 FPS, but that resulted in 90% + of all frames collected to be // flagged as slow frames, and so the threshold for iOS is being changed to 59 FPS. // TODO(b/73498642): Make these configurable. +// This constant is kept for backward compatibility but is no longer used directly. +// The actual threshold is computed dynamically from UIScreen.maximumFramesPerSecond. CFTimeInterval const kFPRSlowFrameThreshold = 1.0 / 59.0; // Anything less than 59 FPS is slow. CFTimeInterval const kFPRFrozenFrameThreshold = 700.0 / 1000.0; +/** Epsilon value to avoid floating point comparison issues (e.g., 59.94 vs 60). */ +static const CFTimeInterval kFPRSlowFrameEpsilon = 0.001; + /** Constant that indicates an invalid time. */ CFAbsoluteTime const kFPRInvalidTime = -1.0; @@ -80,6 +85,14 @@ @implementation FPRScreenTraceTracker { /** Instance variable storing the frozen frames observed so far. */ atomic_int_fast64_t _frozenFramesCount; + + /** Cached maximum frames per second from UIScreen. */ + NSInteger _cachedMaxFPS; + + /** Cached slow frame budget computed from maxFPS. Initialized to the old constant value + * for backward compatibility until updateCachedSlowBudget is called. + */ + CFTimeInterval _cachedSlowBudget; } @dynamic totalFramesCount; @@ -112,6 +125,25 @@ - (instancetype)init { atomic_store_explicit(&_totalFramesCount, 0, memory_order_relaxed); atomic_store_explicit(&_frozenFramesCount, 0, memory_order_relaxed); atomic_store_explicit(&_slowFramesCount, 0, memory_order_relaxed); + + // Initialize cached values with defaults. These will be updated by updateCachedSlowBudget, + // but having defaults ensures reasonable behavior if initialization is delayed or fails. + // Use 59 FPS as default to match legacy behavior (60 FPS devices use 59 FPS threshold). + _cachedMaxFPS = 59; + _cachedSlowBudget = 1.0 / 59.0; + + // Initialize cached maxFPS and slowBudget on main thread. + // UIScreen.maximumFramesPerSecond reflects device capability and can be up to 120 on ProMotion. + // TODO: Support ProMotion devices that dynamically adjust refresh rate based on content. + // Use synchronous dispatch to ensure values are set before first frame is recorded. + if ([NSThread isMainThread]) { + [self updateCachedSlowBudget]; + } else { + dispatch_sync(dispatch_get_main_queue(), ^{ + [self updateCachedSlowBudget]; + }); + } + _displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(displayLinkStep)]; [_displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes]; @@ -126,6 +158,15 @@ - (instancetype)init { selector:@selector(appWillResignActiveNotification:) name:UIApplicationWillResignActiveNotification object:[UIApplication sharedApplication]]; + +#if TARGET_OS_TV + // On tvOS, the refresh rate can change when the user switches display modes or connects to + // different displays. Listen for mode changes to recompute the slow budget. + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(screenModeDidChangeNotification:) + name:UIScreenModeDidChangeNotification + object:nil]; +#endif } return self; } @@ -139,6 +180,11 @@ - (void)dealloc { [[NSNotificationCenter defaultCenter] removeObserver:self name:UIApplicationWillResignActiveNotification object:[UIApplication sharedApplication]]; +#if TARGET_OS_TV + [[NSNotificationCenter defaultCenter] removeObserver:self + name:UIScreenModeDidChangeNotification + object:nil]; +#endif } - (void)appDidBecomeActiveNotification:(NSNotification *)notification { @@ -183,13 +229,51 @@ - (void)appWillResignActiveNotification:(NSNotification *)notification { }); } +#if TARGET_OS_TV +/** Handles the UIScreenModeDidChangeNotification. Recomputes the cached slow budget when the screen + * mode changes on tvOS. + * + * @param notification The NSNotification object. + */ +- (void)screenModeDidChangeNotification:(NSNotification *)notification { + dispatch_async(dispatch_get_main_queue(), ^{ + [self updateCachedSlowBudget]; + }); +} +#endif + +/** Updates the cached maxFPS and slowBudget from UIScreen.maximumFramesPerSecond. + * This method must be called on the main thread. + */ +- (void)updateCachedSlowBudget { + NSAssert([NSThread isMainThread], @"updateCachedSlowBudget must be called on main thread"); + UIScreen *mainScreen = [UIScreen mainScreen]; + NSInteger maxFPS = 0; + if (mainScreen) { + maxFPS = mainScreen.maximumFramesPerSecond; + } + if (maxFPS > 0) { + _cachedMaxFPS = maxFPS; + // Preserve legacy behavior: 60 FPS devices historically used 59 FPS threshold + // to avoid too many false positives for slow frames. + NSInteger effectiveFPS = (maxFPS == 60) ? 59 : maxFPS; + _cachedSlowBudget = 1.0 / effectiveFPS; + } else { + // Fallback to 59 FPS (matching legacy behavior) if maximumFramesPerSecond is unavailable or + // invalid. + _cachedMaxFPS = 59; + _cachedSlowBudget = 1.0 / 59.0; + } +} + #pragma mark - Frozen, slow and good frames - (void)displayLinkStep { static CFAbsoluteTime previousTimestamp = kFPRInvalidTime; CFAbsoluteTime currentTimestamp = self.displayLink.timestamp; + // Use the cached slow budget computed from UIScreen.maximumFramesPerSecond. RecordFrameType(currentTimestamp, previousTimestamp, &_slowFramesCount, &_frozenFramesCount, - &_totalFramesCount); + &_totalFramesCount, _cachedSlowBudget); previousTimestamp = currentTimestamp; } @@ -207,12 +291,15 @@ void RecordFrameType(CFAbsoluteTime currentTimestamp, CFAbsoluteTime previousTimestamp, atomic_int_fast64_t *slowFramesCounter, atomic_int_fast64_t *frozenFramesCounter, - atomic_int_fast64_t *totalFramesCounter) { + atomic_int_fast64_t *totalFramesCounter, + CFTimeInterval slowBudget) { CFTimeInterval frameDuration = currentTimestamp - previousTimestamp; if (previousTimestamp == kFPRInvalidTime) { return; } - if (frameDuration > kFPRSlowFrameThreshold) { + // Use cached slowBudget with epsilon to avoid floating point comparison issues + // (e.g., 59.94 vs 60 Hz displays). + if (frameDuration > slowBudget + kFPRSlowFrameEpsilon) { atomic_fetch_add_explicit(slowFramesCounter, 1, memory_order_relaxed); } if (frameDuration > kFPRFrozenFrameThreshold) { diff --git a/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m b/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m index 2f5cbf40c61..37e46f9ef78 100644 --- a/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m +++ b/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m @@ -626,8 +626,11 @@ - (void)testSlowFrameIsRecorded { /** Tests that the slow and frozen frame counter is not incremented in the case of a good frame. */ - (void)testSlowAndFrozenFrameIsNotRecordedInCaseOfGoodFrame { CFAbsoluteTime firstFrameRenderTimestamp = 1.0; + // Use a frame duration that's clearly below any reasonable threshold (even for 120 FPS devices). + // For 120 FPS: threshold = 1/120 = 0.008333, with epsilon = 0.001, so slow if > 0.009333. + // Using 0.005 ensures it's a good frame on all devices. CFAbsoluteTime secondFrameRenderTimestamp = - firstFrameRenderTimestamp + kFPRSlowFrameThreshold - 0.005; // Good frame. + firstFrameRenderTimestamp + 0.005; // Good frame (5ms, well below any threshold). id displayLinkMock = OCMClassMock([CADisplayLink class]); [self.tracker.displayLink invalidate]; @@ -895,6 +898,242 @@ - (void)testScreenTracesAreCreatedForContainerViewControllerSubclasses { XCTAssertEqual(self.tracker.activeScreenTraces.count, 4); } +#pragma mark - Dynamic FPS Tests + +/** Helper method to swizzle UIScreen.maximumFramesPerSecond for testing. + * + * @param fps The FPS value to stub. + * @param block The block to execute with the stubbed FPS. + */ +- (void)withStubbedMaxFPS:(NSInteger)fps performBlock:(void (^)(void))block { + Method originalMethod = + class_getInstanceMethod([UIScreen class], @selector(maximumFramesPerSecond)); + IMP originalIMP = method_getImplementation(originalMethod); + + NSInteger (^stubBlock)(id) = ^NSInteger(id self) { + return fps; + }; + IMP stubIMP = imp_implementationWithBlock(stubBlock); + method_setImplementation(originalMethod, stubIMP); + + @try { + block(); + } @finally { + method_setImplementation(originalMethod, originalIMP); + } +} + +/** Helper method to create a test tracker with stubbed FPS and updated cached budget. + * + * @param fps The FPS value to stub. + * @return A configured FPRScreenTraceTracker instance. + */ +- (FPRScreenTraceTracker *)createTestTrackerWithStubbedFPS:(NSInteger)fps { + FPRScreenTraceTracker *testTracker = [[FPRScreenTraceTracker alloc] init]; + testTracker.displayLink.paused = YES; + // Tests run on main thread, so call updateCachedSlowBudget directly. + [testTracker updateCachedSlowBudget]; + return testTracker; +} + +#if TARGET_OS_TV +/** Tests that slow frames are correctly detected with a custom maxFPS value on tvOS. + * This test stubs UIScreen.maximumFramesPerSecond to 50 FPS and verifies that frames + * at ~21ms (slow) and ~19ms (not slow) are correctly classified. + */ +- (void)testSlowFrameIsRecordedWithCustomMaxFPSOnTvOS { + // At 50 FPS, slow budget = 1.0/50 = 0.02 seconds = 20ms. + [self withStubbedMaxFPS:50 + performBlock:^{ + FPRScreenTraceTracker *testTracker = [self createTestTrackerWithStubbedFPS:50]; + + // Verify the stub is working and budget is set correctly. + UIScreen *mainScreen = [UIScreen mainScreen]; + XCTAssertEqual(mainScreen.maximumFramesPerSecond, 50, @"Stub should return 50 FPS"); + // At 50 FPS, effectiveFPS = 50 (not 60, so no 59 conversion), threshold = 1/50 = + // 0.02 = 20ms + CFTimeInterval expectedBudget = 1.0 / 50.0; + // We can't directly access _cachedSlowBudget, but we can verify behavior. + + // At 50 FPS, slow budget = 20ms. With epsilon (0.001), frames > 20.001ms are slow. + // Test with 21ms frame (should be slow). + CFAbsoluteTime firstFrameRenderTimestamp = 1.0; + CFAbsoluteTime secondFrameRenderTimestamp = + firstFrameRenderTimestamp + 0.021; // 21ms, slow + + id displayLinkMock = OCMClassMock([CADisplayLink class]); + [testTracker.displayLink invalidate]; + testTracker.displayLink = displayLinkMock; + + OCMExpect([displayLinkMock timestamp]).andReturn(firstFrameRenderTimestamp); + [testTracker displayLinkStep]; + int64_t initialSlowFramesCount = testTracker.slowFramesCount; + + OCMExpect([displayLinkMock timestamp]).andReturn(secondFrameRenderTimestamp); + [testTracker displayLinkStep]; + + int64_t newSlowFramesCount = testTracker.slowFramesCount; + XCTAssertEqual(newSlowFramesCount, initialSlowFramesCount + 1, + @"Frame at 21ms should be marked as slow at 50 FPS (20ms threshold)"); + + // Test with 19ms frame (should NOT be slow). + CFAbsoluteTime thirdFrameRenderTimestamp = + secondFrameRenderTimestamp + 0.019; // 19ms, not slow + OCMExpect([displayLinkMock timestamp]).andReturn(thirdFrameRenderTimestamp); + [testTracker displayLinkStep]; + + int64_t finalSlowFramesCount = testTracker.slowFramesCount; + XCTAssertEqual( + finalSlowFramesCount, newSlowFramesCount, + @"Frame at 19ms should NOT be marked as slow at 50 FPS (20ms threshold)"); + }]; +} +#endif + +/** Tests that the epsilon value correctly handles edge cases around 59.94 vs 60 Hz displays. + * Frames right at the threshold should not be miscounted due to floating point precision. + */ +- (void)testSlowFrameRate_isHandled_inEdgeCases { + [self withStubbedMaxFPS:60 + performBlock:^{ + FPRScreenTraceTracker *testTracker = [self createTestTrackerWithStubbedFPS:60]; + + // Verify the stub is working - UIScreen should return 60 FPS. + UIScreen *mainScreen = [UIScreen mainScreen]; + XCTAssertEqual(mainScreen.maximumFramesPerSecond, 60, @"Stub should return 60 FPS"); + + // At 60 FPS, slow budget = 1.0/60 = 0.016666... seconds. + // With epsilon (0.001), frames > 0.017666... are slow. + // Test with frame exactly at threshold (should NOT be slow due to epsilon). + CFAbsoluteTime firstFrameRenderTimestamp = 1.0; + CFTimeInterval exactThreshold = 1.0 / 60.0; // Exactly 1/60 second + CFAbsoluteTime secondFrameRenderTimestamp = + firstFrameRenderTimestamp + exactThreshold; + + id displayLinkMock = OCMClassMock([CADisplayLink class]); + [testTracker.displayLink invalidate]; + testTracker.displayLink = displayLinkMock; + + OCMExpect([displayLinkMock timestamp]).andReturn(firstFrameRenderTimestamp); + [testTracker displayLinkStep]; + int64_t initialSlowFramesCount = testTracker.slowFramesCount; + + OCMExpect([displayLinkMock timestamp]).andReturn(secondFrameRenderTimestamp); + [testTracker displayLinkStep]; + + int64_t newSlowFramesCount = testTracker.slowFramesCount; + XCTAssertEqual( + newSlowFramesCount, initialSlowFramesCount, + @"Frame exactly at threshold should NOT be marked as slow due to epsilon"); + + // Test with frame just above threshold + epsilon (should be slow). + // Use a value clearly above threshold + epsilon (0.001) to account for floating + // point precision. We use 0.002 above threshold to ensure it's clearly above the + // epsilon threshold. + CFTimeInterval justAboveThreshold = + exactThreshold + 0.001 + 0.001; // 0.002 above threshold (epsilon is 0.001) + CFAbsoluteTime thirdFrameRenderTimestamp = + secondFrameRenderTimestamp + justAboveThreshold; + OCMExpect([displayLinkMock timestamp]).andReturn(thirdFrameRenderTimestamp); + [testTracker displayLinkStep]; + + int64_t finalSlowFramesCount = testTracker.slowFramesCount; + XCTAssertEqual(finalSlowFramesCount, newSlowFramesCount + 1, + @"Frame just above threshold + epsilon should be marked as slow"); + }]; +} + +#if TARGET_OS_TV +/** Tests that the slow budget is recomputed when UIScreenModeDidChangeNotification is posted on + * tvOS. This verifies that the tracker adapts to display mode changes that affect refresh rate. + */ +- (void)testScreenModeChangeUpdatesSlowBudgetOnTvOS { + // Swizzle UIScreen.maximumFramesPerSecond to return 60 FPS initially, then 50 FPS. + Method originalMethod = + class_getInstanceMethod([UIScreen class], @selector(maximumFramesPerSecond)); + IMP originalIMP = method_getImplementation(originalMethod); + + __block NSInteger stubbedMaxFPS = 60; + NSInteger (^stubBlock)(id) = ^NSInteger(id self) { + return stubbedMaxFPS; + }; + IMP stubIMP = imp_implementationWithBlock(stubBlock); + method_setImplementation(originalMethod, stubIMP); + + @try { + FPRScreenTraceTracker *testTracker = [self createTestTrackerWithStubbedFPS:60]; + + // Verify initial behavior: at 60 FPS, slow budget = ~16.67ms. + // An 18ms frame should be slow at 60 FPS. + CFAbsoluteTime firstFrameRenderTimestamp = 1.0; + CFAbsoluteTime secondFrameRenderTimestamp = firstFrameRenderTimestamp + 0.018; // 18ms + + id displayLinkMock = OCMClassMock([CADisplayLink class]); + [testTracker.displayLink invalidate]; + testTracker.displayLink = displayLinkMock; + + OCMExpect([displayLinkMock timestamp]).andReturn(firstFrameRenderTimestamp); + [testTracker displayLinkStep]; + int64_t initialSlowFramesCount = testTracker.slowFramesCount; + + OCMExpect([displayLinkMock timestamp]).andReturn(secondFrameRenderTimestamp); + [testTracker displayLinkStep]; + + int64_t slowFramesAfter18ms = testTracker.slowFramesCount; + // At 60 FPS (~16.67ms threshold), 18ms frame should be slow. + XCTAssertEqual(slowFramesAfter18ms, initialSlowFramesCount + 1, + @"At 60 FPS, 18ms frame should be slow (threshold is ~16.67ms)"); + + // Change the stubbed maxFPS to 50 FPS. + stubbedMaxFPS = 50; + + // Post the notification to trigger recomputation. + NSNotification *modeChangeNotification = + [NSNotification notificationWithName:UIScreenModeDidChangeNotification object:nil]; + [testTracker screenModeDidChangeNotification:modeChangeNotification]; + + // Wait for the async update to complete. Since screenModeDidChangeNotification dispatches + // async to main queue, and tests run on main thread, we need to run the run loop to process it. + // Run the run loop multiple times to ensure the async dispatch completes. + NSDate *timeout = [NSDate dateWithTimeIntervalSinceNow:0.5]; + while ([timeout timeIntervalSinceNow] > 0) { + [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode + beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.01]]; + } + + // Also directly update to ensure it's set (in case async didn't complete). + [testTracker updateCachedSlowBudget]; + + // Verify the stub is now returning 50 FPS. + UIScreen *mainScreen = [UIScreen mainScreen]; + XCTAssertEqual(mainScreen.maximumFramesPerSecond, 50, + @"Stub should now return 50 FPS after change"); + + // Verify the new budget is used: at 50 FPS, slow budget = 20ms. + // An 18ms frame should NOT be slow at 50 FPS (it's below the 20ms threshold). + testTracker.slowFramesCount = 0; + firstFrameRenderTimestamp = 2.0; + secondFrameRenderTimestamp = firstFrameRenderTimestamp + 0.018; // 18ms + + OCMExpect([displayLinkMock timestamp]).andReturn(firstFrameRenderTimestamp); + [testTracker displayLinkStep]; + initialSlowFramesCount = testTracker.slowFramesCount; + + OCMExpect([displayLinkMock timestamp]).andReturn(secondFrameRenderTimestamp); + [testTracker displayLinkStep]; + + int64_t slowFramesAfterModeChange = testTracker.slowFramesCount; + // At 50 FPS (20ms threshold), 18ms frame should NOT be slow. + XCTAssertEqual( + slowFramesAfterModeChange, initialSlowFramesCount, + @"After mode change to 50 FPS, 18ms frame should NOT be slow (threshold is 20ms)"); + } @finally { + // Restore original implementation. + method_setImplementation(originalMethod, originalIMP); + } +} +#endif + #pragma mark - Helper methods + (NSString *)expectedTraceNameForViewController:(UIViewController *)viewController {