From cf41b427d92f86f4a768ff1ca8346b2ba5de2e87 Mon Sep 17 00:00:00 2001 From: Jesus Rojas Date: Mon, 10 Nov 2025 18:51:26 -0600 Subject: [PATCH 01/26] Use maximumFramesPerSecond for slow-frame threshold --- .../FPRScreenTraceTracker+Private.h | 6 +- .../AppActivity/FPRScreenTraceTracker.m | 29 ++- .../Tests/Unit/FPRScreenTraceTrackerTest.m | 189 ++++++++++++++++++ 3 files changed, 218 insertions(+), 6 deletions(-) diff --git a/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker+Private.h b/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker+Private.h index 9cbb868d799..82151e12ffa 100644 --- a/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker+Private.h +++ b/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker+Private.h @@ -37,8 +37,10 @@ FOUNDATION_EXTERN NSString *const kFPRSlowFrameCounterName; /** Counter name for total frames. */ FOUNDATION_EXTERN NSString *const kFPRTotalFramesCounterName; -/** Slow frame threshold (for time difference between current and previous frame render time) - * in sec. +/** Legacy slow frame threshold constant (formerly 1/59). + * NOTE: This constant is deprecated and maintained only for test compatibility. + * The actual slow frame detection now uses UIScreen.maximumFramesPerSecond dynamically. + * New code should not rely on this value. */ FOUNDATION_EXTERN CFTimeInterval const kFPRSlowFrameThreshold; diff --git a/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m b/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m index 5137776fb5f..d888c0c8840 100644 --- a/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m +++ b/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m @@ -25,15 +25,35 @@ NSString *const kFPRSlowFrameCounterName = @"_fr_slo"; NSString *const kFPRTotalFramesCounterName = @"_fr_tot"; -// 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. +// Note: The slow frame threshold is now dynamically computed based on UIScreen's +// maximumFramesPerSecond to align with device capabilities (ProMotion, tvOS, etc.). +// For devices reporting 60 FPS, the threshold is approximately 16.67ms (1/60). // TODO(b/73498642): Make these configurable. -CFTimeInterval const kFPRSlowFrameThreshold = 1.0 / 59.0; // Anything less than 59 FPS is slow. + +// Legacy constant maintained for test compatibility. The actual slow frame detection +// uses FPRSlowBudgetSeconds() which queries UIScreen.maximumFramesPerSecond dynamically. +CFTimeInterval const kFPRSlowFrameThreshold = 1.0 / 60.0; + CFTimeInterval const kFPRFrozenFrameThreshold = 700.0 / 1000.0; /** Constant that indicates an invalid time. */ CFAbsoluteTime const kFPRInvalidTime = -1.0; +/** Returns the maximum frames per second supported by the device's main screen. + * Falls back to 60 if the value is unavailable or invalid. + */ +static inline NSInteger FPRMaxFPS(void) { + NSInteger maxFPS = [UIScreen mainScreen].maximumFramesPerSecond; + return maxFPS > 0 ? maxFPS : 60; +} + +/** Returns the slow frame budget in seconds based on the device's maximum FPS. + * A frame is considered slow if it takes longer than this duration to render. + */ +static inline CFTimeInterval FPRSlowBudgetSeconds(void) { + return 1.0 / (double)FPRMaxFPS(); +} + /** Returns the class name without the prefixed module name present in Swift classes * (e.g. MyModule.MyViewController -> MyViewController). */ @@ -212,7 +232,8 @@ void RecordFrameType(CFAbsoluteTime currentTimestamp, if (previousTimestamp == kFPRInvalidTime) { return; } - if (frameDuration > kFPRSlowFrameThreshold) { + CFTimeInterval slowBudget = FPRSlowBudgetSeconds(); + if (frameDuration > slowBudget) { 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..f8011559d5e 100644 --- a/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m +++ b/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m @@ -901,4 +901,193 @@ + (NSString *)expectedTraceNameForViewController:(UIViewController *)viewControl return [@"_st_" stringByAppendingString:NSStringFromClass([viewController class])]; } +#pragma mark - Dynamic FPS Threshold Tests + +/** Category to swizzle UIScreen.maximumFramesPerSecond for testing. */ +@interface UIScreen (FPRTestSwizzle) +@property(nonatomic) NSInteger fpr_testMaxFPS; +@end + +static NSInteger gFPRTestMaxFPS = 0; + +@implementation UIScreen (FPRTestSwizzle) + +- (NSInteger)fpr_swizzled_maximumFramesPerSecond { + if (gFPRTestMaxFPS > 0) { + return gFPRTestMaxFPS; + } + return [self fpr_swizzled_maximumFramesPerSecond]; // Call original implementation +} + +- (void)setFpr_testMaxFPS:(NSInteger)fps { + gFPRTestMaxFPS = fps; +} + +- (NSInteger)fpr_testMaxFPS { + return gFPRTestMaxFPS; +} + +@end + +/** Helper method to swizzle UIScreen.maximumFramesPerSecond for testing. */ +static void FPRSwizzleMaxFPS(BOOL enable) { + static dispatch_once_t onceToken; + static Method originalMethod; + static Method swizzledMethod; + + dispatch_once(&onceToken, ^{ + Class class = [UIScreen class]; + SEL originalSelector = @selector(maximumFramesPerSecond); + SEL swizzledSelector = @selector(fpr_swizzled_maximumFramesPerSecond); + + originalMethod = class_getInstanceMethod(class, originalSelector); + swizzledMethod = class_getInstanceMethod(class, swizzledSelector); + }); + + if (enable) { + method_exchangeImplementations(originalMethod, swizzledMethod); + } else { + method_exchangeImplementations(swizzledMethod, originalMethod); + gFPRTestMaxFPS = 0; + } +} + +/** Tests that the slow frame threshold correctly adapts to 60 FPS displays. + * At 60 FPS, slow budget is ~16.67ms (1/60). + */ +- (void)testSlowThreshold60FPS { + FPRSwizzleMaxFPS(YES); + [[UIScreen mainScreen] setFpr_testMaxFPS:60]; + + CFAbsoluteTime firstFrameRenderTimestamp = 1.0; + // 0.017s (17ms) should be slow for 60 FPS (budget ~16.67ms) + CFAbsoluteTime secondFrameRenderTimestamp = firstFrameRenderTimestamp + 0.017; + // 0.016s (16ms) should NOT be slow for 60 FPS + CFAbsoluteTime thirdFrameRenderTimestamp = secondFrameRenderTimestamp + 0.016; + // 0.701s should be frozen + CFAbsoluteTime fourthFrameRenderTimestamp = thirdFrameRenderTimestamp + 0.701; + + id displayLinkMock = OCMClassMock([CADisplayLink class]); + [self.tracker.displayLink invalidate]; + self.tracker.displayLink = displayLinkMock; + + // Reset previousFrameTimestamp + OCMExpect([displayLinkMock timestamp]).andReturn(firstFrameRenderTimestamp); + [self.tracker displayLinkStep]; + int64_t initialSlowFramesCount = self.tracker.slowFramesCount; + int64_t initialFrozenFramesCount = self.tracker.frozenFramesCount; + + // Test 17ms frame (should be slow) + OCMExpect([displayLinkMock timestamp]).andReturn(secondFrameRenderTimestamp); + [self.tracker displayLinkStep]; + XCTAssertEqual(self.tracker.slowFramesCount, initialSlowFramesCount + 1); + XCTAssertEqual(self.tracker.frozenFramesCount, initialFrozenFramesCount); + + // Test 16ms frame (should NOT be slow) + OCMExpect([displayLinkMock timestamp]).andReturn(thirdFrameRenderTimestamp); + [self.tracker displayLinkStep]; + XCTAssertEqual(self.tracker.slowFramesCount, initialSlowFramesCount + 1); + XCTAssertEqual(self.tracker.frozenFramesCount, initialFrozenFramesCount); + + // Test 701ms frame (should be frozen and slow) + OCMExpect([displayLinkMock timestamp]).andReturn(fourthFrameRenderTimestamp); + [self.tracker displayLinkStep]; + XCTAssertEqual(self.tracker.slowFramesCount, initialSlowFramesCount + 2); + XCTAssertEqual(self.tracker.frozenFramesCount, initialFrozenFramesCount + 1); + + FPRSwizzleMaxFPS(NO); +} + +/** Tests that the slow frame threshold correctly adapts to 120 FPS displays (ProMotion). + * At 120 FPS, slow budget is ~8.33ms (1/120). + */ +- (void)testSlowThreshold120FPS { + FPRSwizzleMaxFPS(YES); + [[UIScreen mainScreen] setFpr_testMaxFPS:120]; + + CFAbsoluteTime firstFrameRenderTimestamp = 1.0; + // 0.009s (9ms) should be slow for 120 FPS (budget ~8.33ms) + CFAbsoluteTime secondFrameRenderTimestamp = firstFrameRenderTimestamp + 0.009; + // 0.008s (8ms) should NOT be slow for 120 FPS + CFAbsoluteTime thirdFrameRenderTimestamp = secondFrameRenderTimestamp + 0.008; + // 0.701s should be frozen (unchanged threshold) + CFAbsoluteTime fourthFrameRenderTimestamp = thirdFrameRenderTimestamp + 0.701; + + id displayLinkMock = OCMClassMock([CADisplayLink class]); + [self.tracker.displayLink invalidate]; + self.tracker.displayLink = displayLinkMock; + + // Reset previousFrameTimestamp + OCMExpect([displayLinkMock timestamp]).andReturn(firstFrameRenderTimestamp); + [self.tracker displayLinkStep]; + int64_t initialSlowFramesCount = self.tracker.slowFramesCount; + int64_t initialFrozenFramesCount = self.tracker.frozenFramesCount; + + // Test 9ms frame (should be slow) + OCMExpect([displayLinkMock timestamp]).andReturn(secondFrameRenderTimestamp); + [self.tracker displayLinkStep]; + XCTAssertEqual(self.tracker.slowFramesCount, initialSlowFramesCount + 1); + XCTAssertEqual(self.tracker.frozenFramesCount, initialFrozenFramesCount); + + // Test 8ms frame (should NOT be slow) + OCMExpect([displayLinkMock timestamp]).andReturn(thirdFrameRenderTimestamp); + [self.tracker displayLinkStep]; + XCTAssertEqual(self.tracker.slowFramesCount, initialSlowFramesCount + 1); + XCTAssertEqual(self.tracker.frozenFramesCount, initialFrozenFramesCount); + + // Test 701ms frame (should be frozen and slow) + OCMExpect([displayLinkMock timestamp]).andReturn(fourthFrameRenderTimestamp); + [self.tracker displayLinkStep]; + XCTAssertEqual(self.tracker.slowFramesCount, initialSlowFramesCount + 2); + XCTAssertEqual(self.tracker.frozenFramesCount, initialFrozenFramesCount + 1); + + FPRSwizzleMaxFPS(NO); +} + +/** Tests that the slow frame threshold correctly adapts to 50 FPS displays (some tvOS devices). + * At 50 FPS, slow budget is 20ms (1/50). + */ +- (void)testSlowThreshold50FPS { + FPRSwizzleMaxFPS(YES); + [[UIScreen mainScreen] setFpr_testMaxFPS:50]; + + CFAbsoluteTime firstFrameRenderTimestamp = 1.0; + // 0.021s (21ms) should be slow for 50 FPS (budget 20ms) + CFAbsoluteTime secondFrameRenderTimestamp = firstFrameRenderTimestamp + 0.021; + // 0.019s (19ms) should NOT be slow for 50 FPS + CFAbsoluteTime thirdFrameRenderTimestamp = secondFrameRenderTimestamp + 0.019; + // 0.701s should be frozen (unchanged threshold) + CFAbsoluteTime fourthFrameRenderTimestamp = thirdFrameRenderTimestamp + 0.701; + + id displayLinkMock = OCMClassMock([CADisplayLink class]); + [self.tracker.displayLink invalidate]; + self.tracker.displayLink = displayLinkMock; + + // Reset previousFrameTimestamp + OCMExpect([displayLinkMock timestamp]).andReturn(firstFrameRenderTimestamp); + [self.tracker displayLinkStep]; + int64_t initialSlowFramesCount = self.tracker.slowFramesCount; + int64_t initialFrozenFramesCount = self.tracker.frozenFramesCount; + + // Test 21ms frame (should be slow) + OCMExpect([displayLinkMock timestamp]).andReturn(secondFrameRenderTimestamp); + [self.tracker displayLinkStep]; + XCTAssertEqual(self.tracker.slowFramesCount, initialSlowFramesCount + 1); + XCTAssertEqual(self.tracker.frozenFramesCount, initialFrozenFramesCount); + + // Test 19ms frame (should NOT be slow) + OCMExpect([displayLinkMock timestamp]).andReturn(thirdFrameRenderTimestamp); + [self.tracker displayLinkStep]; + XCTAssertEqual(self.tracker.slowFramesCount, initialSlowFramesCount + 1); + XCTAssertEqual(self.tracker.frozenFramesCount, initialFrozenFramesCount); + + // Test 701ms frame (should be frozen and slow) + OCMExpect([displayLinkMock timestamp]).andReturn(fourthFrameRenderTimestamp); + [self.tracker displayLinkStep]; + XCTAssertEqual(self.tracker.slowFramesCount, initialSlowFramesCount + 2); + XCTAssertEqual(self.tracker.frozenFramesCount, initialFrozenFramesCount + 1); + + FPRSwizzleMaxFPS(NO); +} + @end From 56dad5f4b02d0ac1ba06ac3e6fcaa0de1701d19b Mon Sep 17 00:00:00 2001 From: Jesus Rojas Date: Mon, 10 Nov 2025 19:14:19 -0600 Subject: [PATCH 02/26] Fix global static variable - Gemini Suggestion, moved some code around --- .../Tests/Unit/FPRScreenTraceTrackerTest.m | 126 +++++++++--------- 1 file changed, 65 insertions(+), 61 deletions(-) diff --git a/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m b/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m index f8011559d5e..92f872c3b01 100644 --- a/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m +++ b/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m @@ -68,6 +68,39 @@ @interface FPRTestPageViewController : UIPageViewController @implementation FPRTestPageViewController @end +#pragma mark - Test Swizzling Infrastructure + +/** Associated object key for test FPS override. */ +static const void *kFPRTestMaxFPSKey = &kFPRTestMaxFPSKey; + +/** Original IMP for UIScreen.maximumFramesPerSecond. */ +static IMP gOriginalMaxFPSIMP = NULL; + +/** Swizzled implementation of -[UIScreen maximumFramesPerSecond]. */ +static NSInteger FPRSwizzled_maximumFramesPerSecond(id self, SEL _cmd) { + NSNumber *override = objc_getAssociatedObject(self, kFPRTestMaxFPSKey); + if (override) { + return [override integerValue]; + } + // Call original implementation + NSInteger (*originalIMP)(id, SEL) = (NSInteger (*)(id, SEL))gOriginalMaxFPSIMP; + return originalIMP(self, _cmd); +} + +/** Helper to set test FPS override on main screen. */ +static inline void FPRSetTestMaxFPS(NSInteger fps) { + objc_setAssociatedObject([UIScreen mainScreen], kFPRTestMaxFPSKey, @(fps), + OBJC_ASSOCIATION_RETAIN_NONATOMIC); +} + +/** Helper to clear test FPS override on main screen. */ +static inline void FPRClearTestMaxFPS(void) { + objc_setAssociatedObject([UIScreen mainScreen], kFPRTestMaxFPSKey, nil, + OBJC_ASSOCIATION_RETAIN_NONATOMIC); +} + +#pragma mark - Test Class + @interface FPRScreenTraceTrackerTest : FPRTestCase /** The FPRScreenTraceTracker instance that's being used for a given test. */ @@ -80,8 +113,34 @@ @interface FPRScreenTraceTrackerTest : FPRTestCase @implementation FPRScreenTraceTrackerTest ++ (void)setUp { + [super setUp]; + + // Perform one-time swizzle of UIScreen.maximumFramesPerSecond + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + Class screenClass = [UIScreen class]; + SEL originalSelector = @selector(maximumFramesPerSecond); + Method originalMethod = class_getInstanceMethod(screenClass, originalSelector); + + // Store original IMP + gOriginalMaxFPSIMP = method_getImplementation(originalMethod); + + // Replace with swizzled implementation + method_setImplementation(originalMethod, (IMP)FPRSwizzled_maximumFramesPerSecond); + }); +} + - (void)setUp { [super setUp]; + + // Clear any existing override before test + FPRClearTestMaxFPS(); + + // Guarantee cleanup after test completes + [self addTeardownBlock:^{ + FPRClearTestMaxFPS(); + }]; FIRPerformance *performance = [FIRPerformance sharedInstance]; [performance setDataCollectionEnabled:YES]; @@ -91,6 +150,9 @@ - (void)setUp { } - (void)tearDown { + // Final guarantee to clear override + FPRClearTestMaxFPS(); + [super tearDown]; FIRPerformance *performance = [FIRPerformance sharedInstance]; @@ -903,61 +965,11 @@ + (NSString *)expectedTraceNameForViewController:(UIViewController *)viewControl #pragma mark - Dynamic FPS Threshold Tests -/** Category to swizzle UIScreen.maximumFramesPerSecond for testing. */ -@interface UIScreen (FPRTestSwizzle) -@property(nonatomic) NSInteger fpr_testMaxFPS; -@end - -static NSInteger gFPRTestMaxFPS = 0; - -@implementation UIScreen (FPRTestSwizzle) - -- (NSInteger)fpr_swizzled_maximumFramesPerSecond { - if (gFPRTestMaxFPS > 0) { - return gFPRTestMaxFPS; - } - return [self fpr_swizzled_maximumFramesPerSecond]; // Call original implementation -} - -- (void)setFpr_testMaxFPS:(NSInteger)fps { - gFPRTestMaxFPS = fps; -} - -- (NSInteger)fpr_testMaxFPS { - return gFPRTestMaxFPS; -} - -@end - -/** Helper method to swizzle UIScreen.maximumFramesPerSecond for testing. */ -static void FPRSwizzleMaxFPS(BOOL enable) { - static dispatch_once_t onceToken; - static Method originalMethod; - static Method swizzledMethod; - - dispatch_once(&onceToken, ^{ - Class class = [UIScreen class]; - SEL originalSelector = @selector(maximumFramesPerSecond); - SEL swizzledSelector = @selector(fpr_swizzled_maximumFramesPerSecond); - - originalMethod = class_getInstanceMethod(class, originalSelector); - swizzledMethod = class_getInstanceMethod(class, swizzledSelector); - }); - - if (enable) { - method_exchangeImplementations(originalMethod, swizzledMethod); - } else { - method_exchangeImplementations(swizzledMethod, originalMethod); - gFPRTestMaxFPS = 0; - } -} - /** Tests that the slow frame threshold correctly adapts to 60 FPS displays. * At 60 FPS, slow budget is ~16.67ms (1/60). */ - (void)testSlowThreshold60FPS { - FPRSwizzleMaxFPS(YES); - [[UIScreen mainScreen] setFpr_testMaxFPS:60]; + FPRSetTestMaxFPS(60); CFAbsoluteTime firstFrameRenderTimestamp = 1.0; // 0.017s (17ms) should be slow for 60 FPS (budget ~16.67ms) @@ -994,16 +1006,13 @@ - (void)testSlowThreshold60FPS { [self.tracker displayLinkStep]; XCTAssertEqual(self.tracker.slowFramesCount, initialSlowFramesCount + 2); XCTAssertEqual(self.tracker.frozenFramesCount, initialFrozenFramesCount + 1); - - FPRSwizzleMaxFPS(NO); } /** Tests that the slow frame threshold correctly adapts to 120 FPS displays (ProMotion). * At 120 FPS, slow budget is ~8.33ms (1/120). */ - (void)testSlowThreshold120FPS { - FPRSwizzleMaxFPS(YES); - [[UIScreen mainScreen] setFpr_testMaxFPS:120]; + FPRSetTestMaxFPS(120); CFAbsoluteTime firstFrameRenderTimestamp = 1.0; // 0.009s (9ms) should be slow for 120 FPS (budget ~8.33ms) @@ -1040,16 +1049,13 @@ - (void)testSlowThreshold120FPS { [self.tracker displayLinkStep]; XCTAssertEqual(self.tracker.slowFramesCount, initialSlowFramesCount + 2); XCTAssertEqual(self.tracker.frozenFramesCount, initialFrozenFramesCount + 1); - - FPRSwizzleMaxFPS(NO); } /** Tests that the slow frame threshold correctly adapts to 50 FPS displays (some tvOS devices). * At 50 FPS, slow budget is 20ms (1/50). */ - (void)testSlowThreshold50FPS { - FPRSwizzleMaxFPS(YES); - [[UIScreen mainScreen] setFpr_testMaxFPS:50]; + FPRSetTestMaxFPS(50); CFAbsoluteTime firstFrameRenderTimestamp = 1.0; // 0.021s (21ms) should be slow for 50 FPS (budget 20ms) @@ -1086,8 +1092,6 @@ - (void)testSlowThreshold50FPS { [self.tracker displayLinkStep]; XCTAssertEqual(self.tracker.slowFramesCount, initialSlowFramesCount + 2); XCTAssertEqual(self.tracker.frozenFramesCount, initialFrozenFramesCount + 1); - - FPRSwizzleMaxFPS(NO); } @end From 240174683970e811cd5d73b2835ef8db30f8c8a5 Mon Sep 17 00:00:00 2001 From: Jesus Rojas Date: Mon, 10 Nov 2025 19:24:05 -0600 Subject: [PATCH 03/26] Simplify tests and run style.sh --- .../Tests/Unit/FPRScreenTraceTrackerTest.m | 206 ++++++++---------- 1 file changed, 94 insertions(+), 112 deletions(-) diff --git a/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m b/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m index 92f872c3b01..96d4d6da61d 100644 --- a/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m +++ b/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m @@ -115,17 +115,17 @@ @implementation FPRScreenTraceTrackerTest + (void)setUp { [super setUp]; - + // Perform one-time swizzle of UIScreen.maximumFramesPerSecond static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ Class screenClass = [UIScreen class]; SEL originalSelector = @selector(maximumFramesPerSecond); Method originalMethod = class_getInstanceMethod(screenClass, originalSelector); - + // Store original IMP gOriginalMaxFPSIMP = method_getImplementation(originalMethod); - + // Replace with swizzled implementation method_setImplementation(originalMethod, (IMP)FPRSwizzled_maximumFramesPerSecond); }); @@ -133,10 +133,10 @@ + (void)setUp { - (void)setUp { [super setUp]; - + // Clear any existing override before test FPRClearTestMaxFPS(); - + // Guarantee cleanup after test completes [self addTeardownBlock:^{ FPRClearTestMaxFPS(); @@ -152,7 +152,7 @@ - (void)setUp { - (void)tearDown { // Final guarantee to clear override FPRClearTestMaxFPS(); - + [super tearDown]; FIRPerformance *performance = [FIRPerformance sharedInstance]; @@ -965,133 +965,115 @@ + (NSString *)expectedTraceNameForViewController:(UIViewController *)viewControl #pragma mark - Dynamic FPS Threshold Tests -/** Tests that the slow frame threshold correctly adapts to 60 FPS displays. - * At 60 FPS, slow budget is ~16.67ms (1/60). - */ -- (void)testSlowThreshold60FPS { - FPRSetTestMaxFPS(60); +/** Structure for table-driven FPS test cases. */ +typedef struct { + NSInteger fps; + CFTimeInterval slowCandidate; // seconds > budget should be slow + CFTimeInterval fastCandidate; // seconds < budget should NOT be slow +} FPRFpsCase; + +/** Helper to run a single FPS test case with the given tracker and expectations. */ +static inline void FPRRunFpsTestCase(FPRScreenTraceTrackerTest *testCase, FPRFpsCase fpsCase) { + FPRSetTestMaxFPS(fpsCase.fps); CFAbsoluteTime firstFrameRenderTimestamp = 1.0; - // 0.017s (17ms) should be slow for 60 FPS (budget ~16.67ms) - CFAbsoluteTime secondFrameRenderTimestamp = firstFrameRenderTimestamp + 0.017; - // 0.016s (16ms) should NOT be slow for 60 FPS - CFAbsoluteTime thirdFrameRenderTimestamp = secondFrameRenderTimestamp + 0.016; - // 0.701s should be frozen - CFAbsoluteTime fourthFrameRenderTimestamp = thirdFrameRenderTimestamp + 0.701; + // Frame that should be classified as slow (exceeds budget) + CFAbsoluteTime slowFrameTimestamp = firstFrameRenderTimestamp + fpsCase.slowCandidate; + // Frame that should NOT be classified as slow (within budget) + CFAbsoluteTime fastFrameTimestamp = slowFrameTimestamp + fpsCase.fastCandidate; + // Frame that should be classified as frozen (always 701ms) + CFAbsoluteTime frozenFrameTimestamp = fastFrameTimestamp + 0.701; + // Frame that should NOT be classified as frozen (always 699ms) + CFAbsoluteTime notFrozenFrameTimestamp = frozenFrameTimestamp + 0.699; id displayLinkMock = OCMClassMock([CADisplayLink class]); - [self.tracker.displayLink invalidate]; - self.tracker.displayLink = displayLinkMock; + [testCase.tracker.displayLink invalidate]; + testCase.tracker.displayLink = displayLinkMock; // Reset previousFrameTimestamp OCMExpect([displayLinkMock timestamp]).andReturn(firstFrameRenderTimestamp); - [self.tracker displayLinkStep]; - int64_t initialSlowFramesCount = self.tracker.slowFramesCount; - int64_t initialFrozenFramesCount = self.tracker.frozenFramesCount; - - // Test 17ms frame (should be slow) - OCMExpect([displayLinkMock timestamp]).andReturn(secondFrameRenderTimestamp); - [self.tracker displayLinkStep]; - XCTAssertEqual(self.tracker.slowFramesCount, initialSlowFramesCount + 1); - XCTAssertEqual(self.tracker.frozenFramesCount, initialFrozenFramesCount); + [testCase.tracker displayLinkStep]; + int64_t initialSlowFramesCount = testCase.tracker.slowFramesCount; + int64_t initialFrozenFramesCount = testCase.tracker.frozenFramesCount; + int64_t initialTotalFramesCount = testCase.tracker.totalFramesCount; + + // Test slow frame (should be classified as slow, not frozen) + OCMExpect([displayLinkMock timestamp]).andReturn(slowFrameTimestamp); + [testCase.tracker displayLinkStep]; + XCTAssertEqual(testCase.tracker.slowFramesCount, initialSlowFramesCount + 1, + @"Frame duration %.3fms should be slow at %ld FPS", fpsCase.slowCandidate * 1000.0, + (long)fpsCase.fps); + XCTAssertEqual(testCase.tracker.frozenFramesCount, initialFrozenFramesCount, + @"Frame duration %.3fms should not be frozen", fpsCase.slowCandidate * 1000.0); + XCTAssertEqual(testCase.tracker.totalFramesCount, initialTotalFramesCount + 1); + + // Test fast frame (should NOT be classified as slow or frozen) + OCMExpect([displayLinkMock timestamp]).andReturn(fastFrameTimestamp); + [testCase.tracker displayLinkStep]; + XCTAssertEqual(testCase.tracker.slowFramesCount, initialSlowFramesCount + 1, + @"Frame duration %.3fms should NOT be slow at %ld FPS", + fpsCase.fastCandidate * 1000.0, (long)fpsCase.fps); + XCTAssertEqual(testCase.tracker.frozenFramesCount, initialFrozenFramesCount, + @"Frame duration %.3fms should not be frozen", fpsCase.fastCandidate * 1000.0); + XCTAssertEqual(testCase.tracker.totalFramesCount, initialTotalFramesCount + 2); + + // Test frozen frame (should be classified as both slow and frozen) + OCMExpect([displayLinkMock timestamp]).andReturn(frozenFrameTimestamp); + [testCase.tracker displayLinkStep]; + XCTAssertEqual(testCase.tracker.slowFramesCount, initialSlowFramesCount + 2, + @"Frame duration 701ms should be slow at %ld FPS", (long)fpsCase.fps); + XCTAssertEqual(testCase.tracker.frozenFramesCount, initialFrozenFramesCount + 1, + @"Frame duration 701ms should be frozen at %ld FPS", (long)fpsCase.fps); + XCTAssertEqual(testCase.tracker.totalFramesCount, initialTotalFramesCount + 3); + + // Test not-frozen frame (should be classified as slow but not frozen) + OCMExpect([displayLinkMock timestamp]).andReturn(notFrozenFrameTimestamp); + [testCase.tracker displayLinkStep]; + XCTAssertEqual(testCase.tracker.slowFramesCount, initialSlowFramesCount + 3, + @"Frame duration 699ms should be slow at %ld FPS", (long)fpsCase.fps); + XCTAssertEqual(testCase.tracker.frozenFramesCount, initialFrozenFramesCount + 1, + @"Frame duration 699ms should NOT be frozen at %ld FPS", (long)fpsCase.fps); + XCTAssertEqual(testCase.tracker.totalFramesCount, initialTotalFramesCount + 4); +} - // Test 16ms frame (should NOT be slow) - OCMExpect([displayLinkMock timestamp]).andReturn(thirdFrameRenderTimestamp); - [self.tracker displayLinkStep]; - XCTAssertEqual(self.tracker.slowFramesCount, initialSlowFramesCount + 1); - XCTAssertEqual(self.tracker.frozenFramesCount, initialFrozenFramesCount); +/** Tests that the slow frame threshold correctly adapts to various FPS rates. + * Tests 50 FPS (tvOS), 60 FPS (standard), and 120 FPS (ProMotion). + */ +- (void)testSlowThresholdAdaptsToDifferentFPS { + FPRFpsCase testCases[] = { + {50, 0.021, 0.019}, // 50 FPS: budget 20ms (1/50) + {60, 0.017, 0.016}, // 60 FPS: budget ~16.67ms (1/60) + {120, 0.009, 0.008}, // 120 FPS: budget ~8.33ms (1/120) + }; + + NSInteger caseCount = sizeof(testCases) / sizeof(testCases[0]); + for (NSInteger i = 0; i < caseCount; i++) { + FPRRunFpsTestCase(self, testCases[i]); + } +} - // Test 701ms frame (should be frozen and slow) - OCMExpect([displayLinkMock timestamp]).andReturn(fourthFrameRenderTimestamp); - [self.tracker displayLinkStep]; - XCTAssertEqual(self.tracker.slowFramesCount, initialSlowFramesCount + 2); - XCTAssertEqual(self.tracker.frozenFramesCount, initialFrozenFramesCount + 1); +/** Tests that the slow frame threshold correctly adapts to 60 FPS displays. + * At 60 FPS, slow budget is ~16.67ms (1/60). + */ +- (void)testSlowThreshold60FPS { + FPRFpsCase testCase = {60, 0.017, 0.016}; + FPRRunFpsTestCase(self, testCase); } /** Tests that the slow frame threshold correctly adapts to 120 FPS displays (ProMotion). * At 120 FPS, slow budget is ~8.33ms (1/120). */ - (void)testSlowThreshold120FPS { - FPRSetTestMaxFPS(120); - - CFAbsoluteTime firstFrameRenderTimestamp = 1.0; - // 0.009s (9ms) should be slow for 120 FPS (budget ~8.33ms) - CFAbsoluteTime secondFrameRenderTimestamp = firstFrameRenderTimestamp + 0.009; - // 0.008s (8ms) should NOT be slow for 120 FPS - CFAbsoluteTime thirdFrameRenderTimestamp = secondFrameRenderTimestamp + 0.008; - // 0.701s should be frozen (unchanged threshold) - CFAbsoluteTime fourthFrameRenderTimestamp = thirdFrameRenderTimestamp + 0.701; - - id displayLinkMock = OCMClassMock([CADisplayLink class]); - [self.tracker.displayLink invalidate]; - self.tracker.displayLink = displayLinkMock; - - // Reset previousFrameTimestamp - OCMExpect([displayLinkMock timestamp]).andReturn(firstFrameRenderTimestamp); - [self.tracker displayLinkStep]; - int64_t initialSlowFramesCount = self.tracker.slowFramesCount; - int64_t initialFrozenFramesCount = self.tracker.frozenFramesCount; - - // Test 9ms frame (should be slow) - OCMExpect([displayLinkMock timestamp]).andReturn(secondFrameRenderTimestamp); - [self.tracker displayLinkStep]; - XCTAssertEqual(self.tracker.slowFramesCount, initialSlowFramesCount + 1); - XCTAssertEqual(self.tracker.frozenFramesCount, initialFrozenFramesCount); - - // Test 8ms frame (should NOT be slow) - OCMExpect([displayLinkMock timestamp]).andReturn(thirdFrameRenderTimestamp); - [self.tracker displayLinkStep]; - XCTAssertEqual(self.tracker.slowFramesCount, initialSlowFramesCount + 1); - XCTAssertEqual(self.tracker.frozenFramesCount, initialFrozenFramesCount); - - // Test 701ms frame (should be frozen and slow) - OCMExpect([displayLinkMock timestamp]).andReturn(fourthFrameRenderTimestamp); - [self.tracker displayLinkStep]; - XCTAssertEqual(self.tracker.slowFramesCount, initialSlowFramesCount + 2); - XCTAssertEqual(self.tracker.frozenFramesCount, initialFrozenFramesCount + 1); + FPRFpsCase testCase = {120, 0.009, 0.008}; + FPRRunFpsTestCase(self, testCase); } /** Tests that the slow frame threshold correctly adapts to 50 FPS displays (some tvOS devices). * At 50 FPS, slow budget is 20ms (1/50). */ - (void)testSlowThreshold50FPS { - FPRSetTestMaxFPS(50); - - CFAbsoluteTime firstFrameRenderTimestamp = 1.0; - // 0.021s (21ms) should be slow for 50 FPS (budget 20ms) - CFAbsoluteTime secondFrameRenderTimestamp = firstFrameRenderTimestamp + 0.021; - // 0.019s (19ms) should NOT be slow for 50 FPS - CFAbsoluteTime thirdFrameRenderTimestamp = secondFrameRenderTimestamp + 0.019; - // 0.701s should be frozen (unchanged threshold) - CFAbsoluteTime fourthFrameRenderTimestamp = thirdFrameRenderTimestamp + 0.701; - - id displayLinkMock = OCMClassMock([CADisplayLink class]); - [self.tracker.displayLink invalidate]; - self.tracker.displayLink = displayLinkMock; - - // Reset previousFrameTimestamp - OCMExpect([displayLinkMock timestamp]).andReturn(firstFrameRenderTimestamp); - [self.tracker displayLinkStep]; - int64_t initialSlowFramesCount = self.tracker.slowFramesCount; - int64_t initialFrozenFramesCount = self.tracker.frozenFramesCount; - - // Test 21ms frame (should be slow) - OCMExpect([displayLinkMock timestamp]).andReturn(secondFrameRenderTimestamp); - [self.tracker displayLinkStep]; - XCTAssertEqual(self.tracker.slowFramesCount, initialSlowFramesCount + 1); - XCTAssertEqual(self.tracker.frozenFramesCount, initialFrozenFramesCount); - - // Test 19ms frame (should NOT be slow) - OCMExpect([displayLinkMock timestamp]).andReturn(thirdFrameRenderTimestamp); - [self.tracker displayLinkStep]; - XCTAssertEqual(self.tracker.slowFramesCount, initialSlowFramesCount + 1); - XCTAssertEqual(self.tracker.frozenFramesCount, initialFrozenFramesCount); - - // Test 701ms frame (should be frozen and slow) - OCMExpect([displayLinkMock timestamp]).andReturn(fourthFrameRenderTimestamp); - [self.tracker displayLinkStep]; - XCTAssertEqual(self.tracker.slowFramesCount, initialSlowFramesCount + 2); - XCTAssertEqual(self.tracker.frozenFramesCount, initialFrozenFramesCount + 1); + FPRFpsCase testCase = {50, 0.021, 0.019}; + FPRRunFpsTestCase(self, testCase); } @end From 189948d17c2d52a0c2d3880a1dee14e585c47770 Mon Sep 17 00:00:00 2001 From: Jesus Rojas Date: Mon, 10 Nov 2025 19:33:01 -0600 Subject: [PATCH 04/26] Address Gemini comments - dynamic slowBudget --- .../Tests/Unit/FPRScreenTraceTrackerTest.m | 79 +++++++++++-------- 1 file changed, 48 insertions(+), 31 deletions(-) diff --git a/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m b/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m index 96d4d6da61d..8ae1843c840 100644 --- a/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m +++ b/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m @@ -73,30 +73,16 @@ @implementation FPRTestPageViewController /** Associated object key for test FPS override. */ static const void *kFPRTestMaxFPSKey = &kFPRTestMaxFPSKey; -/** Original IMP for UIScreen.maximumFramesPerSecond. */ -static IMP gOriginalMaxFPSIMP = NULL; - /** Swizzled implementation of -[UIScreen maximumFramesPerSecond]. */ static NSInteger FPRSwizzled_maximumFramesPerSecond(id self, SEL _cmd) { NSNumber *override = objc_getAssociatedObject(self, kFPRTestMaxFPSKey); if (override) { return [override integerValue]; } - // Call original implementation - NSInteger (*originalIMP)(id, SEL) = (NSInteger (*)(id, SEL))gOriginalMaxFPSIMP; - return originalIMP(self, _cmd); -} - -/** Helper to set test FPS override on main screen. */ -static inline void FPRSetTestMaxFPS(NSInteger fps) { - objc_setAssociatedObject([UIScreen mainScreen], kFPRTestMaxFPSKey, @(fps), - OBJC_ASSOCIATION_RETAIN_NONATOMIC); -} - -/** Helper to clear test FPS override on main screen. */ -static inline void FPRClearTestMaxFPS(void) { - objc_setAssociatedObject([UIScreen mainScreen], kFPRTestMaxFPSKey, nil, - OBJC_ASSOCIATION_RETAIN_NONATOMIC); + // If no override, call original implementation via the swizzled selector + // After method_exchangeImplementations, the original method is now at the swizzled selector + return ((NSInteger (*)(id, SEL))objc_msgSend)(self, + @selector(fpr_original_maximumFramesPerSecond)); } #pragma mark - Test Class @@ -109,6 +95,12 @@ @interface FPRScreenTraceTrackerTest : FPRTestCase /** The dispatch group a test should wait for completion on before asserting behavior under test. */ @property(nonatomic, nullable) dispatch_group_t dispatchGroupToWaitOn; +/** Per-test FPS override value. Set this in individual tests. */ +@property(nonatomic, assign) NSInteger testMaxFPS; + +/** Whether the current test has swizzled maximumFramesPerSecond. */ +@property(nonatomic, assign) BOOL hasSwizzled; + @end @implementation FPRScreenTraceTrackerTest @@ -119,27 +111,44 @@ + (void)setUp { // Perform one-time swizzle of UIScreen.maximumFramesPerSecond static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ - Class screenClass = [UIScreen class]; - SEL originalSelector = @selector(maximumFramesPerSecond); - Method originalMethod = class_getInstanceMethod(screenClass, originalSelector); - - // Store original IMP - gOriginalMaxFPSIMP = method_getImplementation(originalMethod); - - // Replace with swizzled implementation - method_setImplementation(originalMethod, (IMP)FPRSwizzled_maximumFramesPerSecond); + @synchronized([UIScreen class]) { + Class screenClass = [UIScreen class]; + SEL originalSelector = @selector(maximumFramesPerSecond); + SEL swizzledSelector = @selector(fpr_original_maximumFramesPerSecond); + + Method originalMethod = class_getInstanceMethod(screenClass, originalSelector); + + // Add swizzled method to the class + IMP swizzledIMP = (IMP)FPRSwizzled_maximumFramesPerSecond; + const char *typeEncoding = method_getTypeEncoding(originalMethod); + class_addMethod(screenClass, swizzledSelector, method_getImplementation(originalMethod), + typeEncoding); + + // Replace original with swizzled + class_replaceMethod(screenClass, originalSelector, swizzledIMP, typeEncoding); + } }); } - (void)setUp { [super setUp]; + // Initialize test state + self.testMaxFPS = 0; + self.hasSwizzled = NO; + // Clear any existing override before test - FPRClearTestMaxFPS(); + objc_setAssociatedObject([UIScreen mainScreen], kFPRTestMaxFPSKey, nil, + OBJC_ASSOCIATION_RETAIN_NONATOMIC); // Guarantee cleanup after test completes + __weak typeof(self) weakSelf = self; [self addTeardownBlock:^{ - FPRClearTestMaxFPS(); + __strong typeof(weakSelf) strongSelf = weakSelf; + if (strongSelf) { + objc_setAssociatedObject([UIScreen mainScreen], kFPRTestMaxFPSKey, nil, + OBJC_ASSOCIATION_RETAIN_NONATOMIC); + } }]; FIRPerformance *performance = [FIRPerformance sharedInstance]; @@ -151,7 +160,8 @@ - (void)setUp { - (void)tearDown { // Final guarantee to clear override - FPRClearTestMaxFPS(); + objc_setAssociatedObject([UIScreen mainScreen], kFPRTestMaxFPSKey, nil, + OBJC_ASSOCIATION_RETAIN_NONATOMIC); [super tearDown]; @@ -161,6 +171,13 @@ - (void)tearDown { self.dispatchGroupToWaitOn = nil; } +/** Helper method to set the test FPS override for the current test. */ +- (void)setTestMaxFPSOverride:(NSInteger)fps { + self.testMaxFPS = fps; + objc_setAssociatedObject([UIScreen mainScreen], kFPRTestMaxFPSKey, @(fps), + OBJC_ASSOCIATION_RETAIN_NONATOMIC); +} + /** Tests that shared instance returns the same instance. */ - (void)testSingleton { FPRScreenTraceTracker *trackerOne = [FPRScreenTraceTracker sharedInstance]; @@ -974,7 +991,7 @@ + (NSString *)expectedTraceNameForViewController:(UIViewController *)viewControl /** Helper to run a single FPS test case with the given tracker and expectations. */ static inline void FPRRunFpsTestCase(FPRScreenTraceTrackerTest *testCase, FPRFpsCase fpsCase) { - FPRSetTestMaxFPS(fpsCase.fps); + [testCase setTestMaxFPSOverride:fpsCase.fps]; CFAbsoluteTime firstFrameRenderTimestamp = 1.0; // Frame that should be classified as slow (exceeds budget) From c9a3d05a52f7d7e310644620f2b69cbfc1dcab14 Mon Sep 17 00:00:00 2001 From: Jesus Rojas Date: Mon, 10 Nov 2025 19:43:03 -0600 Subject: [PATCH 05/26] Removed redundant tests --- .../Tests/Unit/FPRScreenTraceTrackerTest.m | 24 ------------------- 1 file changed, 24 deletions(-) diff --git a/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m b/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m index 8ae1843c840..c0486f42e8b 100644 --- a/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m +++ b/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m @@ -1069,28 +1069,4 @@ - (void)testSlowThresholdAdaptsToDifferentFPS { } } -/** Tests that the slow frame threshold correctly adapts to 60 FPS displays. - * At 60 FPS, slow budget is ~16.67ms (1/60). - */ -- (void)testSlowThreshold60FPS { - FPRFpsCase testCase = {60, 0.017, 0.016}; - FPRRunFpsTestCase(self, testCase); -} - -/** Tests that the slow frame threshold correctly adapts to 120 FPS displays (ProMotion). - * At 120 FPS, slow budget is ~8.33ms (1/120). - */ -- (void)testSlowThreshold120FPS { - FPRFpsCase testCase = {120, 0.009, 0.008}; - FPRRunFpsTestCase(self, testCase); -} - -/** Tests that the slow frame threshold correctly adapts to 50 FPS displays (some tvOS devices). - * At 50 FPS, slow budget is 20ms (1/50). - */ -- (void)testSlowThreshold50FPS { - FPRFpsCase testCase = {50, 0.021, 0.019}; - FPRRunFpsTestCase(self, testCase); -} - @end From 6eabdb1ff8109d28a6f36037b413c499ee0282db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Rojas?= Date: Mon, 10 Nov 2025 19:45:07 -0600 Subject: [PATCH 06/26] Update FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m b/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m index c0486f42e8b..b267b193cc6 100644 --- a/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m +++ b/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m @@ -80,7 +80,7 @@ static NSInteger FPRSwizzled_maximumFramesPerSecond(id self, SEL _cmd) { return [override integerValue]; } // If no override, call original implementation via the swizzled selector - // After method_exchangeImplementations, the original method is now at the swizzled selector + // The original implementation was moved to the `fpr_original_maximumFramesPerSecond` selector. return ((NSInteger (*)(id, SEL))objc_msgSend)(self, @selector(fpr_original_maximumFramesPerSecond)); } From c608a110f5c8057ac6beafaa6afe6519d7c77d3b Mon Sep 17 00:00:00 2001 From: Jesus Rojas Date: Mon, 10 Nov 2025 19:48:29 -0600 Subject: [PATCH 07/26] Code Cleanup - Gemini Suggestion --- .../Tests/Unit/FPRScreenTraceTrackerTest.m | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m b/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m index b267b193cc6..bbe62a1f1cf 100644 --- a/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m +++ b/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m @@ -81,8 +81,10 @@ static NSInteger FPRSwizzled_maximumFramesPerSecond(id self, SEL _cmd) { } // If no override, call original implementation via the swizzled selector // The original implementation was moved to the `fpr_original_maximumFramesPerSecond` selector. - return ((NSInteger (*)(id, SEL))objc_msgSend)(self, - @selector(fpr_original_maximumFramesPerSecond)); + typedef NSInteger (*IMPType)(id, SEL); + IMPType originalIMP = + (IMPType)[self methodForSelector:@selector(fpr_original_maximumFramesPerSecond)]; + return originalIMP(self, @selector(fpr_original_maximumFramesPerSecond)); } #pragma mark - Test Class @@ -95,12 +97,6 @@ @interface FPRScreenTraceTrackerTest : FPRTestCase /** The dispatch group a test should wait for completion on before asserting behavior under test. */ @property(nonatomic, nullable) dispatch_group_t dispatchGroupToWaitOn; -/** Per-test FPS override value. Set this in individual tests. */ -@property(nonatomic, assign) NSInteger testMaxFPS; - -/** Whether the current test has swizzled maximumFramesPerSecond. */ -@property(nonatomic, assign) BOOL hasSwizzled; - @end @implementation FPRScreenTraceTrackerTest @@ -133,10 +129,6 @@ + (void)setUp { - (void)setUp { [super setUp]; - // Initialize test state - self.testMaxFPS = 0; - self.hasSwizzled = NO; - // Clear any existing override before test objc_setAssociatedObject([UIScreen mainScreen], kFPRTestMaxFPSKey, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC); @@ -173,7 +165,6 @@ - (void)tearDown { /** Helper method to set the test FPS override for the current test. */ - (void)setTestMaxFPSOverride:(NSInteger)fps { - self.testMaxFPS = fps; objc_setAssociatedObject([UIScreen mainScreen], kFPRTestMaxFPSKey, @(fps), OBJC_ASSOCIATION_RETAIN_NONATOMIC); } From 7b14e4c95cadf7d51a0110c8be2ad25dbd9985af Mon Sep 17 00:00:00 2001 From: Jesus Rojas Date: Mon, 10 Nov 2025 19:54:32 -0600 Subject: [PATCH 08/26] Optimize method swizzling and remove unused properties --- .../Tests/Unit/FPRScreenTraceTrackerTest.m | 31 +++++++++---------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m b/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m index bbe62a1f1cf..5d925559fdf 100644 --- a/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m +++ b/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m @@ -73,18 +73,23 @@ @implementation FPRTestPageViewController /** Associated object key for test FPS override. */ static const void *kFPRTestMaxFPSKey = &kFPRTestMaxFPSKey; +/** Original IMP for -[UIScreen maximumFramesPerSecond]. */ +static IMP gOriginal_maximumFramesPerSecond = NULL; + /** Swizzled implementation of -[UIScreen maximumFramesPerSecond]. */ static NSInteger FPRSwizzled_maximumFramesPerSecond(id self, SEL _cmd) { NSNumber *override = objc_getAssociatedObject(self, kFPRTestMaxFPSKey); if (override) { return [override integerValue]; } - // If no override, call original implementation via the swizzled selector - // The original implementation was moved to the `fpr_original_maximumFramesPerSecond` selector. - typedef NSInteger (*IMPType)(id, SEL); - IMPType originalIMP = - (IMPType)[self methodForSelector:@selector(fpr_original_maximumFramesPerSecond)]; - return originalIMP(self, @selector(fpr_original_maximumFramesPerSecond)); + + // Call the original implementation if available + if (gOriginal_maximumFramesPerSecond) { + return ((NSInteger (*)(id, SEL))gOriginal_maximumFramesPerSecond)(self, _cmd); + } + + // Fallback to 60 FPS if original implementation is unavailable + return 60; } #pragma mark - Test Class @@ -110,18 +115,12 @@ + (void)setUp { @synchronized([UIScreen class]) { Class screenClass = [UIScreen class]; SEL originalSelector = @selector(maximumFramesPerSecond); - SEL swizzledSelector = @selector(fpr_original_maximumFramesPerSecond); - Method originalMethod = class_getInstanceMethod(screenClass, originalSelector); - // Add swizzled method to the class - IMP swizzledIMP = (IMP)FPRSwizzled_maximumFramesPerSecond; - const char *typeEncoding = method_getTypeEncoding(originalMethod); - class_addMethod(screenClass, swizzledSelector, method_getImplementation(originalMethod), - typeEncoding); - - // Replace original with swizzled - class_replaceMethod(screenClass, originalSelector, swizzledIMP, typeEncoding); + // class_replaceMethod returns the original IMP, which we store for later use + gOriginal_maximumFramesPerSecond = class_replaceMethod( + screenClass, originalSelector, (IMP)FPRSwizzled_maximumFramesPerSecond, + method_getTypeEncoding(originalMethod)); } }); } From c464b2eaa5339296560ea9b3f2957580413e14f8 Mon Sep 17 00:00:00 2001 From: Jesus Rojas Date: Mon, 10 Nov 2025 19:58:44 -0600 Subject: [PATCH 09/26] Remove Redundant block of code --- FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m | 4 ---- 1 file changed, 4 deletions(-) diff --git a/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m b/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m index 5d925559fdf..e22744d516f 100644 --- a/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m +++ b/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m @@ -150,10 +150,6 @@ - (void)setUp { } - (void)tearDown { - // Final guarantee to clear override - objc_setAssociatedObject([UIScreen mainScreen], kFPRTestMaxFPSKey, nil, - OBJC_ASSOCIATION_RETAIN_NONATOMIC); - [super tearDown]; FIRPerformance *performance = [FIRPerformance sharedInstance]; From 4407c40e84f8d2bebdff4b3fe7a9b6300fcf9411 Mon Sep 17 00:00:00 2001 From: Jesus Rojas Date: Mon, 10 Nov 2025 20:29:52 -0600 Subject: [PATCH 10/26] Separate tests and clean stuff up --- .../Tests/Unit/FPRScreenTraceTrackerTest.m | 47 ++++++++----------- 1 file changed, 20 insertions(+), 27 deletions(-) diff --git a/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m b/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m index e22744d516f..68377aa8b3a 100644 --- a/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m +++ b/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m @@ -112,7 +112,7 @@ + (void)setUp { // Perform one-time swizzle of UIScreen.maximumFramesPerSecond static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ - @synchronized([UIScreen class]) { + { Class screenClass = [UIScreen class]; SEL originalSelector = @selector(maximumFramesPerSecond); Method originalMethod = class_getInstanceMethod(screenClass, originalSelector); @@ -121,8 +121,8 @@ + (void)setUp { gOriginal_maximumFramesPerSecond = class_replaceMethod( screenClass, originalSelector, (IMP)FPRSwizzled_maximumFramesPerSecond, method_getTypeEncoding(originalMethod)); - } - }); +} +}); } - (void)setUp { @@ -132,16 +132,6 @@ - (void)setUp { objc_setAssociatedObject([UIScreen mainScreen], kFPRTestMaxFPSKey, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC); - // Guarantee cleanup after test completes - __weak typeof(self) weakSelf = self; - [self addTeardownBlock:^{ - __strong typeof(weakSelf) strongSelf = weakSelf; - if (strongSelf) { - objc_setAssociatedObject([UIScreen mainScreen], kFPRTestMaxFPSKey, nil, - OBJC_ASSOCIATION_RETAIN_NONATOMIC); - } - }]; - FIRPerformance *performance = [FIRPerformance sharedInstance]; [performance setDataCollectionEnabled:YES]; self.tracker = [[FPRScreenTraceTracker alloc] init]; @@ -152,6 +142,9 @@ - (void)setUp { - (void)tearDown { [super tearDown]; + objc_setAssociatedObject([UIScreen mainScreen], kFPRTestMaxFPSKey, nil, + OBJC_ASSOCIATION_RETAIN_NONATOMIC); + FIRPerformance *performance = [FIRPerformance sharedInstance]; [performance setDataCollectionEnabled:NO]; self.tracker = nil; @@ -1039,20 +1032,20 @@ static inline void FPRRunFpsTestCase(FPRScreenTraceTrackerTest *testCase, FPRFps XCTAssertEqual(testCase.tracker.totalFramesCount, initialTotalFramesCount + 4); } -/** Tests that the slow frame threshold correctly adapts to various FPS rates. - * Tests 50 FPS (tvOS), 60 FPS (standard), and 120 FPS (ProMotion). - */ -- (void)testSlowThresholdAdaptsToDifferentFPS { - FPRFpsCase testCases[] = { - {50, 0.021, 0.019}, // 50 FPS: budget 20ms (1/50) - {60, 0.017, 0.016}, // 60 FPS: budget ~16.67ms (1/60) - {120, 0.009, 0.008}, // 120 FPS: budget ~8.33ms (1/120) - }; - - NSInteger caseCount = sizeof(testCases) / sizeof(testCases[0]); - for (NSInteger i = 0; i < caseCount; i++) { - FPRRunFpsTestCase(self, testCases[i]); - } +// FPS threshold adaptation tests +- (void)testSlowThresholdAdaptsTo50FPS { + FPRFpsCase testCase = (FPRFpsCase){50, 0.021, 0.019}; // 50 FPS: budget 20ms (1/50) + FPRRunFpsTestCase(self, testCase); +} + +- (void)testSlowThresholdAdaptsTo60FPS { + FPRFpsCase testCase = (FPRFpsCase){60, 0.017, 0.016}; // 60 FPS: budget ~16.67ms (1/60) + FPRRunFpsTestCase(self, testCase); +} + +- (void)testSlowThresholdAdaptsTo120FPS { + FPRFpsCase testCase = (FPRFpsCase){120, 0.009, 0.008}; // 120 FPS: budget ~8.33ms (1/120) + FPRRunFpsTestCase(self, testCase); } @end From fc10cc9486d3c641855d21e71907363804fd2ded Mon Sep 17 00:00:00 2001 From: Jesus Rojas Date: Mon, 10 Nov 2025 20:35:35 -0600 Subject: [PATCH 11/26] Fix curly braces --- .../Tests/Unit/FPRScreenTraceTrackerTest.m | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m b/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m index 68377aa8b3a..24b91d458e8 100644 --- a/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m +++ b/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m @@ -112,17 +112,15 @@ + (void)setUp { // Perform one-time swizzle of UIScreen.maximumFramesPerSecond static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ - { - Class screenClass = [UIScreen class]; - SEL originalSelector = @selector(maximumFramesPerSecond); - Method originalMethod = class_getInstanceMethod(screenClass, originalSelector); - - // class_replaceMethod returns the original IMP, which we store for later use - gOriginal_maximumFramesPerSecond = class_replaceMethod( - screenClass, originalSelector, (IMP)FPRSwizzled_maximumFramesPerSecond, - method_getTypeEncoding(originalMethod)); -} -}); + Class screenClass = [UIScreen class]; + SEL originalSelector = @selector(maximumFramesPerSecond); + Method originalMethod = class_getInstanceMethod(screenClass, originalSelector); + + // class_replaceMethod returns the original IMP, which we store for later use + gOriginal_maximumFramesPerSecond = + class_replaceMethod(screenClass, originalSelector, (IMP)FPRSwizzled_maximumFramesPerSecond, + method_getTypeEncoding(originalMethod)); + }); } - (void)setUp { From 04515e6d8e224c0bdf495ed1916a3b394418aa0c Mon Sep 17 00:00:00 2001 From: Jesus Rojas Date: Mon, 10 Nov 2025 21:57:30 -0600 Subject: [PATCH 12/26] Cache slow frame budget and implement a refresh on app active --- .../FPRScreenTraceTracker+Private.h | 5 ++- .../AppActivity/FPRScreenTraceTracker.m | 40 +++++++++++++++++-- .../Tests/Unit/FPRScreenTraceTrackerTest.m | 2 + 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker+Private.h b/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker+Private.h index 82151e12ffa..a7f4b301dfa 100644 --- a/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker+Private.h +++ b/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker+Private.h @@ -39,13 +39,14 @@ FOUNDATION_EXTERN NSString *const kFPRTotalFramesCounterName; /** Legacy slow frame threshold constant (formerly 1/59). * NOTE: This constant is deprecated and maintained only for test compatibility. - * The actual slow frame detection now uses UIScreen.maximumFramesPerSecond dynamically. + * The actual slow frame detection uses a cached value computed from + * UIScreen.maximumFramesPerSecond (slow threshold = 1000 / maxFPS ms). * New code should not rely on this value. */ FOUNDATION_EXTERN CFTimeInterval const kFPRSlowFrameThreshold; /** Frozen frame threshold (for time difference between current and previous frame render time) - * in sec. + * in sec. Frozen threshold = 700 ms (>700 ms). */ FOUNDATION_EXTERN CFTimeInterval const kFPRFrozenFrameThreshold; diff --git a/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m b/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m index d888c0c8840..321199d8c4c 100644 --- a/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m +++ b/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m @@ -27,11 +27,13 @@ // Note: The slow frame threshold is now dynamically computed based on UIScreen's // maximumFramesPerSecond to align with device capabilities (ProMotion, tvOS, etc.). +// Slow threshold = 1000 / UIScreen.maximumFramesPerSecond ms (or 1.0 / maxFPS seconds). // For devices reporting 60 FPS, the threshold is approximately 16.67ms (1/60). +// The threshold is cached and refreshed when the app becomes active. // TODO(b/73498642): Make these configurable. // Legacy constant maintained for test compatibility. The actual slow frame detection -// uses FPRSlowBudgetSeconds() which queries UIScreen.maximumFramesPerSecond dynamically. +// uses a cached value computed from UIScreen.maximumFramesPerSecond. CFTimeInterval const kFPRSlowFrameThreshold = 1.0 / 60.0; CFTimeInterval const kFPRFrozenFrameThreshold = 700.0 / 1000.0; @@ -91,6 +93,14 @@ static inline CFTimeInterval FPRSlowBudgetSeconds(void) { } } +@interface FPRScreenTraceTracker () + +@property(nonatomic) NSInteger fpr_cachedMaxFPS; + +@property(nonatomic) CFTimeInterval fpr_cachedSlowBudget; + +@end + @implementation FPRScreenTraceTracker { /** Instance variable storing the total frames observed so far. */ atomic_int_fast64_t _totalFramesCount; @@ -146,6 +156,17 @@ - (instancetype)init { selector:@selector(appWillResignActiveNotification:) name:UIApplicationWillResignActiveNotification object:[UIApplication sharedApplication]]; + + // Initialize cached FPS and slow budget + NSInteger __fps = UIScreen.mainScreen.maximumFramesPerSecond ?: 60; + self.fpr_cachedMaxFPS = __fps; + self.fpr_cachedSlowBudget = 1.0 / (double)__fps; + + // Observe app becoming active to refresh FPS cache + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(appDidBecomeActive:) + name:UIApplicationDidBecomeActiveNotification + object:nil]; } return self; } @@ -156,6 +177,9 @@ - (void)dealloc { [[NSNotificationCenter defaultCenter] removeObserver:self name:UIApplicationDidBecomeActiveNotification object:[UIApplication sharedApplication]]; + [[NSNotificationCenter defaultCenter] removeObserver:self + name:UIApplicationDidBecomeActiveNotification + object:nil]; [[NSNotificationCenter defaultCenter] removeObserver:self name:UIApplicationWillResignActiveNotification object:[UIApplication sharedApplication]]; @@ -179,6 +203,12 @@ - (void)appDidBecomeActiveNotification:(NSNotification *)notification { }); } +- (void)appDidBecomeActive:(NSNotification *)note { + NSInteger __fps = UIScreen.mainScreen.maximumFramesPerSecond ?: 60; + self.fpr_cachedMaxFPS = __fps; + self.fpr_cachedSlowBudget = 1.0 / (double)__fps; +} + - (void)appWillResignActiveNotification:(NSNotification *)notification { // To get the most accurate numbers of total, frozen and slow frames, we need to capture them as // soon as we're notified of an event. @@ -208,8 +238,9 @@ - (void)appWillResignActiveNotification:(NSNotification *)notification { - (void)displayLinkStep { static CFAbsoluteTime previousTimestamp = kFPRInvalidTime; CFAbsoluteTime currentTimestamp = self.displayLink.timestamp; - RecordFrameType(currentTimestamp, previousTimestamp, &_slowFramesCount, &_frozenFramesCount, - &_totalFramesCount); + CFTimeInterval slowBudget = self.fpr_cachedSlowBudget; + RecordFrameType(currentTimestamp, previousTimestamp, slowBudget, &_slowFramesCount, + &_frozenFramesCount, &_totalFramesCount); previousTimestamp = currentTimestamp; } @@ -218,6 +249,7 @@ - (void)displayLinkStep { * * @param currentTimestamp The current timestamp of the displayLink. * @param previousTimestamp The previous timestamp of the displayLink. + * @param slowBudget The cached slow frame budget in seconds (1.0 / maxFPS). * @param slowFramesCounter The value of the slowFramesCount before this function was called. * @param frozenFramesCounter The value of the frozenFramesCount before this function was called. * @param totalFramesCounter The value of the totalFramesCount before this function was called. @@ -225,6 +257,7 @@ - (void)displayLinkStep { FOUNDATION_STATIC_INLINE void RecordFrameType(CFAbsoluteTime currentTimestamp, CFAbsoluteTime previousTimestamp, + CFTimeInterval slowBudget, atomic_int_fast64_t *slowFramesCounter, atomic_int_fast64_t *frozenFramesCounter, atomic_int_fast64_t *totalFramesCounter) { @@ -232,7 +265,6 @@ void RecordFrameType(CFAbsoluteTime currentTimestamp, if (previousTimestamp == kFPRInvalidTime) { return; } - CFTimeInterval slowBudget = FPRSlowBudgetSeconds(); if (frameDuration > slowBudget) { atomic_fetch_add_explicit(slowFramesCounter, 1, memory_order_relaxed); } diff --git a/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m b/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m index 24b91d458e8..43b46dca1cc 100644 --- a/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m +++ b/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m @@ -969,6 +969,8 @@ + (NSString *)expectedTraceNameForViewController:(UIViewController *)viewControl /** Helper to run a single FPS test case with the given tracker and expectations. */ static inline void FPRRunFpsTestCase(FPRScreenTraceTrackerTest *testCase, FPRFpsCase fpsCase) { [testCase setTestMaxFPSOverride:fpsCase.fps]; + // Force the tracker to recompute the cache with the overridden FPS + [testCase.tracker appDidBecomeActive:nil]; CFAbsoluteTime firstFrameRenderTimestamp = 1.0; // Frame that should be classified as slow (exceeds budget) From d1056fc47551790b312235360548e1a3b0b3e0be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Rojas?= Date: Mon, 10 Nov 2025 21:59:26 -0600 Subject: [PATCH 13/26] Update FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../Sources/AppActivity/FPRScreenTraceTracker.m | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m b/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m index 321199d8c4c..a376c876ae3 100644 --- a/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m +++ b/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m @@ -158,9 +158,8 @@ - (instancetype)init { object:[UIApplication sharedApplication]]; // Initialize cached FPS and slow budget - NSInteger __fps = UIScreen.mainScreen.maximumFramesPerSecond ?: 60; - self.fpr_cachedMaxFPS = __fps; - self.fpr_cachedSlowBudget = 1.0 / (double)__fps; + self.fpr_cachedMaxFPS = FPRMaxFPS(); + self.fpr_cachedSlowBudget = FPRSlowBudgetSeconds(); // Observe app becoming active to refresh FPS cache [[NSNotificationCenter defaultCenter] addObserver:self From 3c876a51d4abeadee518b4464c7f2777c51e583c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Rojas?= Date: Mon, 10 Nov 2025 21:59:36 -0600 Subject: [PATCH 14/26] Update FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../Sources/AppActivity/FPRScreenTraceTracker.m | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m b/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m index a376c876ae3..7bbb1acee7f 100644 --- a/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m +++ b/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m @@ -203,9 +203,8 @@ - (void)appDidBecomeActiveNotification:(NSNotification *)notification { } - (void)appDidBecomeActive:(NSNotification *)note { - NSInteger __fps = UIScreen.mainScreen.maximumFramesPerSecond ?: 60; - self.fpr_cachedMaxFPS = __fps; - self.fpr_cachedSlowBudget = 1.0 / (double)__fps; + self.fpr_cachedMaxFPS = FPRMaxFPS(); + self.fpr_cachedSlowBudget = FPRSlowBudgetSeconds(); } - (void)appWillResignActiveNotification:(NSNotification *)notification { From a0d52d2c7898db05ea27455c5230ff904d9c41f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Rojas?= Date: Mon, 10 Nov 2025 21:59:44 -0600 Subject: [PATCH 15/26] Update FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m b/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m index 7bbb1acee7f..3d631290a85 100644 --- a/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m +++ b/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m @@ -53,7 +53,7 @@ static inline NSInteger FPRMaxFPS(void) { * A frame is considered slow if it takes longer than this duration to render. */ static inline CFTimeInterval FPRSlowBudgetSeconds(void) { - return 1.0 / (double)FPRMaxFPS(); + return 1.0 / FPRMaxFPS(); } /** Returns the class name without the prefixed module name present in Swift classes From 0f204df253a739cef5fdb4854185fe504ffcc4f5 Mon Sep 17 00:00:00 2001 From: Jesus Rojas Date: Mon, 10 Nov 2025 22:04:40 -0600 Subject: [PATCH 16/26] Consolidate all app did become active logic into one place --- .../AppActivity/FPRScreenTraceTracker.m | 18 ++++-------------- .../Tests/Unit/FPRScreenTraceTrackerTest.m | 6 ++++-- 2 files changed, 8 insertions(+), 16 deletions(-) diff --git a/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m b/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m index 3d631290a85..d0ec106ee76 100644 --- a/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m +++ b/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m @@ -160,12 +160,6 @@ - (instancetype)init { // Initialize cached FPS and slow budget self.fpr_cachedMaxFPS = FPRMaxFPS(); self.fpr_cachedSlowBudget = FPRSlowBudgetSeconds(); - - // Observe app becoming active to refresh FPS cache - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(appDidBecomeActive:) - name:UIApplicationDidBecomeActiveNotification - object:nil]; } return self; } @@ -176,15 +170,16 @@ - (void)dealloc { [[NSNotificationCenter defaultCenter] removeObserver:self name:UIApplicationDidBecomeActiveNotification object:[UIApplication sharedApplication]]; - [[NSNotificationCenter defaultCenter] removeObserver:self - name:UIApplicationDidBecomeActiveNotification - object:nil]; [[NSNotificationCenter defaultCenter] removeObserver:self name:UIApplicationWillResignActiveNotification object:[UIApplication sharedApplication]]; } - (void)appDidBecomeActiveNotification:(NSNotification *)notification { + // Refresh cached FPS and slow budget when app becomes active + self.fpr_cachedMaxFPS = FPRMaxFPS(); + self.fpr_cachedSlowBudget = FPRSlowBudgetSeconds(); + // To get the most accurate numbers of total, frozen and slow frames, we need to capture them as // soon as we're notified of an event. int64_t currentTotalFrames = atomic_load_explicit(&_totalFramesCount, memory_order_relaxed); @@ -202,11 +197,6 @@ - (void)appDidBecomeActiveNotification:(NSNotification *)notification { }); } -- (void)appDidBecomeActive:(NSNotification *)note { - self.fpr_cachedMaxFPS = FPRMaxFPS(); - self.fpr_cachedSlowBudget = FPRSlowBudgetSeconds(); -} - - (void)appWillResignActiveNotification:(NSNotification *)notification { // To get the most accurate numbers of total, frozen and slow frames, we need to capture them as // soon as we're notified of an event. diff --git a/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m b/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m index 43b46dca1cc..994d2c556b0 100644 --- a/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m +++ b/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m @@ -969,8 +969,10 @@ + (NSString *)expectedTraceNameForViewController:(UIViewController *)viewControl /** Helper to run a single FPS test case with the given tracker and expectations. */ static inline void FPRRunFpsTestCase(FPRScreenTraceTrackerTest *testCase, FPRFpsCase fpsCase) { [testCase setTestMaxFPSOverride:fpsCase.fps]; - // Force the tracker to recompute the cache with the overridden FPS - [testCase.tracker appDidBecomeActive:nil]; + // Force the tracker to recompute the cache with the overridden FPS by posting notification + NSNotification *notification = + [NSNotification notificationWithName:UIApplicationDidBecomeActiveNotification object:nil]; + [testCase.tracker appDidBecomeActiveNotification:notification]; CFAbsoluteTime firstFrameRenderTimestamp = 1.0; // Frame that should be classified as slow (exceeds budget) From e172aad7894ec6baf062c6e41834b49e8fa17c1c Mon Sep 17 00:00:00 2001 From: Jesus Rojas Date: Mon, 10 Nov 2025 22:10:55 -0600 Subject: [PATCH 17/26] Added descriptive messages to the XCTAssertEqual --- .../Tests/Unit/FPRScreenTraceTrackerTest.m | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m b/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m index 994d2c556b0..753be5b24b9 100644 --- a/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m +++ b/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m @@ -1003,7 +1003,8 @@ static inline void FPRRunFpsTestCase(FPRScreenTraceTrackerTest *testCase, FPRFps (long)fpsCase.fps); XCTAssertEqual(testCase.tracker.frozenFramesCount, initialFrozenFramesCount, @"Frame duration %.3fms should not be frozen", fpsCase.slowCandidate * 1000.0); - XCTAssertEqual(testCase.tracker.totalFramesCount, initialTotalFramesCount + 1); + XCTAssertEqual(testCase.tracker.totalFramesCount, initialTotalFramesCount + 1, + @"Total frames should increment after a slow frame."); // Test fast frame (should NOT be classified as slow or frozen) OCMExpect([displayLinkMock timestamp]).andReturn(fastFrameTimestamp); @@ -1013,7 +1014,8 @@ static inline void FPRRunFpsTestCase(FPRScreenTraceTrackerTest *testCase, FPRFps fpsCase.fastCandidate * 1000.0, (long)fpsCase.fps); XCTAssertEqual(testCase.tracker.frozenFramesCount, initialFrozenFramesCount, @"Frame duration %.3fms should not be frozen", fpsCase.fastCandidate * 1000.0); - XCTAssertEqual(testCase.tracker.totalFramesCount, initialTotalFramesCount + 2); + XCTAssertEqual(testCase.tracker.totalFramesCount, initialTotalFramesCount + 2, + @"Total frames should increment after a fast frame."); // Test frozen frame (should be classified as both slow and frozen) OCMExpect([displayLinkMock timestamp]).andReturn(frozenFrameTimestamp); @@ -1022,7 +1024,8 @@ static inline void FPRRunFpsTestCase(FPRScreenTraceTrackerTest *testCase, FPRFps @"Frame duration 701ms should be slow at %ld FPS", (long)fpsCase.fps); XCTAssertEqual(testCase.tracker.frozenFramesCount, initialFrozenFramesCount + 1, @"Frame duration 701ms should be frozen at %ld FPS", (long)fpsCase.fps); - XCTAssertEqual(testCase.tracker.totalFramesCount, initialTotalFramesCount + 3); + XCTAssertEqual(testCase.tracker.totalFramesCount, initialTotalFramesCount + 3, + @"Total frames should increment after a frozen frame."); // Test not-frozen frame (should be classified as slow but not frozen) OCMExpect([displayLinkMock timestamp]).andReturn(notFrozenFrameTimestamp); @@ -1031,7 +1034,8 @@ static inline void FPRRunFpsTestCase(FPRScreenTraceTrackerTest *testCase, FPRFps @"Frame duration 699ms should be slow at %ld FPS", (long)fpsCase.fps); XCTAssertEqual(testCase.tracker.frozenFramesCount, initialFrozenFramesCount + 1, @"Frame duration 699ms should NOT be frozen at %ld FPS", (long)fpsCase.fps); - XCTAssertEqual(testCase.tracker.totalFramesCount, initialTotalFramesCount + 4); + XCTAssertEqual(testCase.tracker.totalFramesCount, initialTotalFramesCount + 4, + @"Total frames should increment after a not-frozen frame."); } // FPS threshold adaptation tests From 2a0d6cd5251557de7dcd3aee89bfcc4eb361fdee Mon Sep 17 00:00:00 2001 From: Jesus Rojas Date: Mon, 10 Nov 2025 22:18:27 -0600 Subject: [PATCH 18/26] add a tearDown method to ensure test isolation --- .../Tests/Unit/FPRScreenTraceTrackerTest.m | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m b/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m index 753be5b24b9..e6c8840ac88 100644 --- a/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m +++ b/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m @@ -123,6 +123,22 @@ + (void)setUp { }); } ++ (void)tearDown { + [super tearDown]; + + // Restore original implementation to prevent test pollution + if (gOriginal_maximumFramesPerSecond) { + Class screenClass = [UIScreen class]; + SEL originalSelector = @selector(maximumFramesPerSecond); + Method originalMethod = class_getInstanceMethod(screenClass, originalSelector); + if (originalMethod) { + class_replaceMethod(screenClass, originalSelector, gOriginal_maximumFramesPerSecond, + method_getTypeEncoding(originalMethod)); + gOriginal_maximumFramesPerSecond = NULL; + } + } +} + - (void)setUp { [super setUp]; From 222bbf9a9376511c81fb98d3e146285b82519138 Mon Sep 17 00:00:00 2001 From: Jesus Rojas Date: Mon, 10 Nov 2025 22:25:56 -0600 Subject: [PATCH 19/26] Refactor previousTimestamp into an instance variable --- .../Tests/Unit/FPRScreenTraceTrackerTest.m | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m b/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m index e6c8840ac88..3136471b8e4 100644 --- a/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m +++ b/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m @@ -683,7 +683,7 @@ - (void)testSlowFrameIsRecorded { [self.tracker.displayLink invalidate]; self.tracker.displayLink = displayLinkMock; - // Set/Reset the previousFrameTimestamp if it has been set by a previous test. + // Initialize previousTimestamp with first frame OCMExpect([displayLinkMock timestamp]).andReturn(firstFrameRenderTimestamp); [self.tracker displayLinkStep]; int64_t initialSlowFramesCount = self.tracker.slowFramesCount; @@ -705,7 +705,7 @@ - (void)testSlowAndFrozenFrameIsNotRecordedInCaseOfGoodFrame { [self.tracker.displayLink invalidate]; self.tracker.displayLink = displayLinkMock; - // Set/Reset the previousFrameTimestamp if it has been set by a previous test. + // Initialize previousTimestamp with first frame OCMExpect([displayLinkMock timestamp]).andReturn(firstFrameRenderTimestamp); [self.tracker displayLinkStep]; int64_t initialFrozenFramesCount = self.tracker.frozenFramesCount; @@ -731,7 +731,7 @@ - (void)testFrozenFrameIsNotRecordedInCaseOfSlowFrame { [self.tracker.displayLink invalidate]; self.tracker.displayLink = displayLinkMock; - // Set/Reset the previousFrameTimestamp if it has been set by a previous test. + // Initialize previousTimestamp with first frame OCMExpect([displayLinkMock timestamp]).andReturn(firstFrameRenderTimestamp); [self.tracker displayLinkStep]; int64_t initialFrozenFramesCount = self.tracker.frozenFramesCount; @@ -759,7 +759,7 @@ - (void)testTotalFramesAreAlwaysRecorded { [self.tracker.displayLink invalidate]; self.tracker.displayLink = displayLinkMock; - // Set/Reset the previousFrameTimestamp if it has been set by a previous test. + // Initialize previousTimestamp with first frame OCMExpect([displayLinkMock timestamp]).andReturn(firstFrameRenderTimestamp); [self.tracker displayLinkStep]; int64_t initialTotalFramesCount = self.tracker.totalFramesCount; @@ -792,7 +792,7 @@ - (void)testFrozenFrameAndSlowFrameIsRecorded { [self.tracker.displayLink invalidate]; self.tracker.displayLink = displayLinkMock; - // Set/Reset the previousFrameTimestamp if it has been set by a previous test. + // Initialize previousTimestamp with first frame OCMExpect([displayLinkMock timestamp]).andReturn(firstFrameRenderTimestamp); [self.tracker displayLinkStep]; int64_t initialSlowFramesCount = self.tracker.slowFramesCount; @@ -1004,7 +1004,7 @@ static inline void FPRRunFpsTestCase(FPRScreenTraceTrackerTest *testCase, FPRFps [testCase.tracker.displayLink invalidate]; testCase.tracker.displayLink = displayLinkMock; - // Reset previousFrameTimestamp + // Initialize previousTimestamp with first frame OCMExpect([displayLinkMock timestamp]).andReturn(firstFrameRenderTimestamp); [testCase.tracker displayLinkStep]; int64_t initialSlowFramesCount = testCase.tracker.slowFramesCount; From 47d04c9d78b26a41d4c76b7ea56e188ef2167b2e Mon Sep 17 00:00:00 2001 From: Jesus Rojas Date: Mon, 10 Nov 2025 22:26:50 -0600 Subject: [PATCH 20/26] refactor previousTimestamp into an instance variable --- .../Sources/AppActivity/FPRScreenTraceTracker+Private.h | 3 +++ .../Sources/AppActivity/FPRScreenTraceTracker.m | 6 +++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker+Private.h b/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker+Private.h index a7f4b301dfa..46b5c3c38e7 100644 --- a/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker+Private.h +++ b/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker+Private.h @@ -84,6 +84,9 @@ FOUNDATION_EXTERN CFTimeInterval const kFPRFrozenFrameThreshold; /** The slow frames counter. */ @property(atomic) int_fast64_t slowFramesCount; +/** The previous frame timestamp from the display link. Used to calculate frame duration. */ +@property(nonatomic) CFAbsoluteTime previousTimestamp; + /** Handles the appDidBecomeActive notification. Restores the screen traces that were active before * the app was backgrounded. * diff --git a/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m b/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m index d0ec106ee76..3ff671a9c95 100644 --- a/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m +++ b/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m @@ -142,6 +142,7 @@ - (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); + _previousTimestamp = kFPRInvalidTime; _displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(displayLinkStep)]; [_displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes]; @@ -224,12 +225,11 @@ - (void)appWillResignActiveNotification:(NSNotification *)notification { #pragma mark - Frozen, slow and good frames - (void)displayLinkStep { - static CFAbsoluteTime previousTimestamp = kFPRInvalidTime; CFAbsoluteTime currentTimestamp = self.displayLink.timestamp; CFTimeInterval slowBudget = self.fpr_cachedSlowBudget; - RecordFrameType(currentTimestamp, previousTimestamp, slowBudget, &_slowFramesCount, + RecordFrameType(currentTimestamp, self.previousTimestamp, slowBudget, &_slowFramesCount, &_frozenFramesCount, &_totalFramesCount); - previousTimestamp = currentTimestamp; + self.previousTimestamp = currentTimestamp; } /** This function increments the relevant frame counters based on the current and previous From a2131ebb34d80ee980fd7442afeafa526cf1e74c Mon Sep 17 00:00:00 2001 From: Jesus Rojas Date: Mon, 10 Nov 2025 22:31:39 -0600 Subject: [PATCH 21/26] tidy up! --- .../Tests/Unit/FPRScreenTraceTrackerTest.m | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m b/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m index 3136471b8e4..87f6460834f 100644 --- a/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m +++ b/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m @@ -683,7 +683,7 @@ - (void)testSlowFrameIsRecorded { [self.tracker.displayLink invalidate]; self.tracker.displayLink = displayLinkMock; - // Initialize previousTimestamp with first frame + // Set/Reset the previousFrameTimestamp if it has been set by a previous test. OCMExpect([displayLinkMock timestamp]).andReturn(firstFrameRenderTimestamp); [self.tracker displayLinkStep]; int64_t initialSlowFramesCount = self.tracker.slowFramesCount; @@ -705,7 +705,7 @@ - (void)testSlowAndFrozenFrameIsNotRecordedInCaseOfGoodFrame { [self.tracker.displayLink invalidate]; self.tracker.displayLink = displayLinkMock; - // Initialize previousTimestamp with first frame + // Set/Reset the previousFrameTimestamp if it has been set by a previous test. OCMExpect([displayLinkMock timestamp]).andReturn(firstFrameRenderTimestamp); [self.tracker displayLinkStep]; int64_t initialFrozenFramesCount = self.tracker.frozenFramesCount; @@ -731,7 +731,7 @@ - (void)testFrozenFrameIsNotRecordedInCaseOfSlowFrame { [self.tracker.displayLink invalidate]; self.tracker.displayLink = displayLinkMock; - // Initialize previousTimestamp with first frame + // Set/Reset the previousFrameTimestamp if it has been set by a previous test. OCMExpect([displayLinkMock timestamp]).andReturn(firstFrameRenderTimestamp); [self.tracker displayLinkStep]; int64_t initialFrozenFramesCount = self.tracker.frozenFramesCount; @@ -759,7 +759,7 @@ - (void)testTotalFramesAreAlwaysRecorded { [self.tracker.displayLink invalidate]; self.tracker.displayLink = displayLinkMock; - // Initialize previousTimestamp with first frame + // Set/Reset the previousFrameTimestamp if it has been set by a previous test. OCMExpect([displayLinkMock timestamp]).andReturn(firstFrameRenderTimestamp); [self.tracker displayLinkStep]; int64_t initialTotalFramesCount = self.tracker.totalFramesCount; @@ -792,7 +792,7 @@ - (void)testFrozenFrameAndSlowFrameIsRecorded { [self.tracker.displayLink invalidate]; self.tracker.displayLink = displayLinkMock; - // Initialize previousTimestamp with first frame + // Set/Reset the previousFrameTimestamp if it has been set by a previous test. OCMExpect([displayLinkMock timestamp]).andReturn(firstFrameRenderTimestamp); [self.tracker displayLinkStep]; int64_t initialSlowFramesCount = self.tracker.slowFramesCount; @@ -1004,7 +1004,7 @@ static inline void FPRRunFpsTestCase(FPRScreenTraceTrackerTest *testCase, FPRFps [testCase.tracker.displayLink invalidate]; testCase.tracker.displayLink = displayLinkMock; - // Initialize previousTimestamp with first frame + // Set/Reset the previousFrameTimestamp if it has been set by a previous test. OCMExpect([displayLinkMock timestamp]).andReturn(firstFrameRenderTimestamp); [testCase.tracker displayLinkStep]; int64_t initialSlowFramesCount = testCase.tracker.slowFramesCount; From 98048a393419b03478a4626f011344988fdff551 Mon Sep 17 00:00:00 2001 From: Jesus Rojas Date: Mon, 10 Nov 2025 22:35:46 -0600 Subject: [PATCH 22/26] added fpr_refreshFrameRateCache method - gemini suggestion --- .../AppActivity/FPRScreenTraceTracker+Private.h | 3 +++ .../Sources/AppActivity/FPRScreenTraceTracker.m | 11 +++++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker+Private.h b/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker+Private.h index 46b5c3c38e7..243682cf508 100644 --- a/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker+Private.h +++ b/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker+Private.h @@ -87,6 +87,9 @@ FOUNDATION_EXTERN CFTimeInterval const kFPRFrozenFrameThreshold; /** The previous frame timestamp from the display link. Used to calculate frame duration. */ @property(nonatomic) CFAbsoluteTime previousTimestamp; +/** Refreshes the cached maximum FPS and slow frame budget from UIScreen. */ +- (void)fpr_refreshFrameRateCache; + /** Handles the appDidBecomeActive notification. Restores the screen traces that were active before * the app was backgrounded. * diff --git a/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m b/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m index 3ff671a9c95..6ac72251f5e 100644 --- a/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m +++ b/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m @@ -159,8 +159,7 @@ - (instancetype)init { object:[UIApplication sharedApplication]]; // Initialize cached FPS and slow budget - self.fpr_cachedMaxFPS = FPRMaxFPS(); - self.fpr_cachedSlowBudget = FPRSlowBudgetSeconds(); + [self fpr_refreshFrameRateCache]; } return self; } @@ -176,10 +175,14 @@ - (void)dealloc { object:[UIApplication sharedApplication]]; } -- (void)appDidBecomeActiveNotification:(NSNotification *)notification { - // Refresh cached FPS and slow budget when app becomes active +- (void)fpr_refreshFrameRateCache { self.fpr_cachedMaxFPS = FPRMaxFPS(); self.fpr_cachedSlowBudget = FPRSlowBudgetSeconds(); +} + +- (void)appDidBecomeActiveNotification:(NSNotification *)notification { + // Refresh cached FPS and slow budget when app becomes active + [self fpr_refreshFrameRateCache]; // To get the most accurate numbers of total, frozen and slow frames, we need to capture them as // soon as we're notified of an event. From 0a7b38e68bfd2df05544f13bfa78e2e34be2adfb Mon Sep 17 00:00:00 2001 From: Jesus Rojas Date: Mon, 10 Nov 2025 22:38:37 -0600 Subject: [PATCH 23/26] Make the fpr_refreshFrameRateCache logic more explicit - gemini suggests --- .../Sources/AppActivity/FPRScreenTraceTracker.m | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m b/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m index 6ac72251f5e..4e9d07f6461 100644 --- a/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m +++ b/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m @@ -49,13 +49,6 @@ static inline NSInteger FPRMaxFPS(void) { return maxFPS > 0 ? maxFPS : 60; } -/** Returns the slow frame budget in seconds based on the device's maximum FPS. - * A frame is considered slow if it takes longer than this duration to render. - */ -static inline CFTimeInterval FPRSlowBudgetSeconds(void) { - return 1.0 / FPRMaxFPS(); -} - /** Returns the class name without the prefixed module name present in Swift classes * (e.g. MyModule.MyViewController -> MyViewController). */ @@ -176,8 +169,9 @@ - (void)dealloc { } - (void)fpr_refreshFrameRateCache { - self.fpr_cachedMaxFPS = FPRMaxFPS(); - self.fpr_cachedSlowBudget = FPRSlowBudgetSeconds(); + NSInteger maxFPS = FPRMaxFPS(); + self.fpr_cachedMaxFPS = maxFPS; + self.fpr_cachedSlowBudget = 1.0 / (double)maxFPS; } - (void)appDidBecomeActiveNotification:(NSNotification *)notification { From 67804cea5b5eb69995843d9b37e87f179ced303b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Rojas?= Date: Mon, 10 Nov 2025 22:42:21 -0600 Subject: [PATCH 24/26] Update FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m b/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m index 87f6460834f..e44b456cc81 100644 --- a/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m +++ b/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m @@ -1004,7 +1004,7 @@ static inline void FPRRunFpsTestCase(FPRScreenTraceTrackerTest *testCase, FPRFps [testCase.tracker.displayLink invalidate]; testCase.tracker.displayLink = displayLinkMock; - // Set/Reset the previousFrameTimestamp if it has been set by a previous test. + // Process an initial frame to set the starting timestamp for duration calculations. OCMExpect([displayLinkMock timestamp]).andReturn(firstFrameRenderTimestamp); [testCase.tracker displayLinkStep]; int64_t initialSlowFramesCount = testCase.tracker.slowFramesCount; From 368c52866e1da94ac716a5cfbd10884a0a28d3ff Mon Sep 17 00:00:00 2001 From: Jesus Rojas Date: Thu, 13 Nov 2025 12:36:39 -0600 Subject: [PATCH 25/26] Address Comments - tvOS-only refresh and round up amd add tvOS tests --- .../AppActivity/FPRScreenTraceTracker.m | 36 ++++++++++++++----- .../Tests/Unit/FPRScreenTraceTrackerTest.m | 30 ++++++++++++++++ 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m b/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m index 4e9d07f6461..bc3470cc15a 100644 --- a/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m +++ b/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m @@ -88,6 +88,11 @@ static inline NSInteger FPRMaxFPS(void) { @interface FPRScreenTraceTracker () +// fpr_cachedMaxFPS and fpr_cachedSlowBudget are initialized at startup. +// We update them only on tvOS during appDidBecomeActive because the output +// mode can change with user settings. iOS ProMotion dynamic changes are +// intentionally left for a future follow-up (see TODO in the notification +// handler). @property(nonatomic) NSInteger fpr_cachedMaxFPS; @property(nonatomic) CFTimeInterval fpr_cachedSlowBudget; @@ -152,7 +157,15 @@ - (instancetype)init { object:[UIApplication sharedApplication]]; // Initialize cached FPS and slow budget - [self fpr_refreshFrameRateCache]; + NSInteger __fps = UIScreen.mainScreen.maximumFramesPerSecond ?: 60; +#if TARGET_OS_TV + // tvOS may report 59 for ~60Hz outputs. Normalize to 60. + if (__fps == 59) { + __fps = 60; + } +#endif + self.fpr_cachedMaxFPS = __fps; + self.fpr_cachedSlowBudget = 1.0 / (double)__fps; } return self; } @@ -168,15 +181,20 @@ - (void)dealloc { object:[UIApplication sharedApplication]]; } -- (void)fpr_refreshFrameRateCache { - NSInteger maxFPS = FPRMaxFPS(); - self.fpr_cachedMaxFPS = maxFPS; - self.fpr_cachedSlowBudget = 1.0 / (double)maxFPS; -} - - (void)appDidBecomeActiveNotification:(NSNotification *)notification { - // Refresh cached FPS and slow budget when app becomes active - [self fpr_refreshFrameRateCache]; +#if TARGET_OS_TV + NSInteger __fps = UIScreen.mainScreen.maximumFramesPerSecond ?: 60; + if (__fps == 59) { + __fps = 60; // normalize tvOS 59 -> 60 + } + if (__fps != self.fpr_cachedMaxFPS) { + self.fpr_cachedMaxFPS = __fps; + self.fpr_cachedSlowBudget = 1.0 / (double)__fps; + } +#else + // TODO: Support dynamic ProMotion changes on iOS in a future follow-up. + // For now, do not refresh here to avoid incorrect assumptions about timing. +#endif // To get the most accurate numbers of total, frozen and slow frames, we need to capture them as // soon as we're notified of an event. diff --git a/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m b/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m index e44b456cc81..443f07f423b 100644 --- a/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m +++ b/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m @@ -695,6 +695,36 @@ - (void)testSlowFrameIsRecorded { XCTAssertEqual(newSlowFramesCount, initialSlowFramesCount + 1); } +#if TARGET_OS_TV +- (void)testTvOS59FpsIsTreatedAs60AndSlowFrameIsRecorded { + // Stub tvOS max FPS to 59, which should be normalized to 60. + [self setTestMaxFPSOverride:59]; + + // Create a new tracker so it initializes with the overridden FPS value. + FPRScreenTraceTracker *tracker = [[FPRScreenTraceTracker alloc] init]; + tracker.displayLink.paused = YES; + + CFAbsoluteTime first = 1.0; + // 17ms is slower than 1/60 (~16.67ms) and should count as slow. + CFAbsoluteTime second = first + 0.017; + + id displayLinkMock = OCMClassMock([CADisplayLink class]); + [tracker.displayLink invalidate]; + tracker.displayLink = displayLinkMock; + + // Prime previous timestamp. + OCMExpect([displayLinkMock timestamp]).andReturn(first); + [tracker displayLinkStep]; + int64_t initialSlow = tracker.slowFramesCount; + + // Emit a slow frame at ~17ms. + OCMExpect([displayLinkMock timestamp]).andReturn(second); + [tracker displayLinkStep]; + + XCTAssertEqual(tracker.slowFramesCount, initialSlow + 1); +} +#endif + /** Tests that the slow and frozen frame counter is not incremented in the case of a good frame. */ - (void)testSlowAndFrozenFrameIsNotRecordedInCaseOfGoodFrame { CFAbsoluteTime firstFrameRenderTimestamp = 1.0; From e14574859b84801255b7baaa9d5c56d1ede32c7b Mon Sep 17 00:00:00 2001 From: Jesus Rojas Date: Thu, 13 Nov 2025 16:50:29 -0600 Subject: [PATCH 26/26] Needed test adaptations --- .../Tests/Unit/FPRScreenTraceTrackerTest.m | 133 ++++++++++-------- 1 file changed, 77 insertions(+), 56 deletions(-) diff --git a/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m b/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m index 443f07f423b..9308f337f4c 100644 --- a/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m +++ b/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m @@ -675,23 +675,28 @@ - (void)testAppDidBecomeActiveWillNotRestoreTracesOfNilledViewControllers { * slow frame counter of the screen trace tracker is incremented. */ - (void)testSlowFrameIsRecorded { + // Set FPS to 60 to match kFPRSlowFrameThreshold constant (1/60) + [self setTestMaxFPSOverride:60]; + FPRScreenTraceTracker *tracker = [[FPRScreenTraceTracker alloc] init]; + tracker.displayLink.paused = YES; + CFAbsoluteTime firstFrameRenderTimestamp = 1.0; CFAbsoluteTime secondFrameRenderTimestamp = firstFrameRenderTimestamp + kFPRSlowFrameThreshold + 0.005; // Buffer for float comparison. id displayLinkMock = OCMClassMock([CADisplayLink class]); - [self.tracker.displayLink invalidate]; - self.tracker.displayLink = displayLinkMock; + [tracker.displayLink invalidate]; + tracker.displayLink = displayLinkMock; // Set/Reset the previousFrameTimestamp if it has been set by a previous test. OCMExpect([displayLinkMock timestamp]).andReturn(firstFrameRenderTimestamp); - [self.tracker displayLinkStep]; - int64_t initialSlowFramesCount = self.tracker.slowFramesCount; + [tracker displayLinkStep]; + int64_t initialSlowFramesCount = tracker.slowFramesCount; OCMExpect([displayLinkMock timestamp]).andReturn(secondFrameRenderTimestamp); - [self.tracker displayLinkStep]; + [tracker displayLinkStep]; - int64_t newSlowFramesCount = self.tracker.slowFramesCount; + int64_t newSlowFramesCount = tracker.slowFramesCount; XCTAssertEqual(newSlowFramesCount, initialSlowFramesCount + 1); } @@ -727,25 +732,30 @@ - (void)testTvOS59FpsIsTreatedAs60AndSlowFrameIsRecorded { /** Tests that the slow and frozen frame counter is not incremented in the case of a good frame. */ - (void)testSlowAndFrozenFrameIsNotRecordedInCaseOfGoodFrame { + // Set FPS to 60 to match kFPRSlowFrameThreshold constant (1/60) + [self setTestMaxFPSOverride:60]; + FPRScreenTraceTracker *tracker = [[FPRScreenTraceTracker alloc] init]; + tracker.displayLink.paused = YES; + CFAbsoluteTime firstFrameRenderTimestamp = 1.0; CFAbsoluteTime secondFrameRenderTimestamp = firstFrameRenderTimestamp + kFPRSlowFrameThreshold - 0.005; // Good frame. id displayLinkMock = OCMClassMock([CADisplayLink class]); - [self.tracker.displayLink invalidate]; - self.tracker.displayLink = displayLinkMock; + [tracker.displayLink invalidate]; + tracker.displayLink = displayLinkMock; // Set/Reset the previousFrameTimestamp if it has been set by a previous test. OCMExpect([displayLinkMock timestamp]).andReturn(firstFrameRenderTimestamp); - [self.tracker displayLinkStep]; - int64_t initialFrozenFramesCount = self.tracker.frozenFramesCount; - int64_t initialSlowFramesCount = self.tracker.slowFramesCount; + [tracker displayLinkStep]; + int64_t initialFrozenFramesCount = tracker.frozenFramesCount; + int64_t initialSlowFramesCount = tracker.slowFramesCount; OCMExpect([displayLinkMock timestamp]).andReturn(secondFrameRenderTimestamp); - [self.tracker displayLinkStep]; + [tracker displayLinkStep]; - int64_t newSlowFramesCount = self.tracker.slowFramesCount; - int64_t newFrozenFramesCount = self.tracker.frozenFramesCount; + int64_t newSlowFramesCount = tracker.slowFramesCount; + int64_t newFrozenFramesCount = tracker.frozenFramesCount; XCTAssertEqual(newSlowFramesCount, initialSlowFramesCount); XCTAssertEqual(newFrozenFramesCount, initialFrozenFramesCount); @@ -753,23 +763,28 @@ - (void)testSlowAndFrozenFrameIsNotRecordedInCaseOfGoodFrame { /* Tests that the frozen frame counter is not incremented in case of a slow frame. */ - (void)testFrozenFrameIsNotRecordedInCaseOfSlowFrame { + // Set FPS to 60 to match kFPRSlowFrameThreshold constant (1/60) + [self setTestMaxFPSOverride:60]; + FPRScreenTraceTracker *tracker = [[FPRScreenTraceTracker alloc] init]; + tracker.displayLink.paused = YES; + CFAbsoluteTime firstFrameRenderTimestamp = 1.0; CFAbsoluteTime secondFrameRenderTimestamp = firstFrameRenderTimestamp + kFPRSlowFrameThreshold + 0.005; // Slow frame. id displayLinkMock = OCMClassMock([CADisplayLink class]); - [self.tracker.displayLink invalidate]; - self.tracker.displayLink = displayLinkMock; + [tracker.displayLink invalidate]; + tracker.displayLink = displayLinkMock; // Set/Reset the previousFrameTimestamp if it has been set by a previous test. OCMExpect([displayLinkMock timestamp]).andReturn(firstFrameRenderTimestamp); - [self.tracker displayLinkStep]; - int64_t initialFrozenFramesCount = self.tracker.frozenFramesCount; + [tracker displayLinkStep]; + int64_t initialFrozenFramesCount = tracker.frozenFramesCount; OCMExpect([displayLinkMock timestamp]).andReturn(secondFrameRenderTimestamp); - [self.tracker displayLinkStep]; + [tracker displayLinkStep]; - int64_t newFrozenFramesCount = self.tracker.frozenFramesCount; + int64_t newFrozenFramesCount = tracker.frozenFramesCount; XCTAssertEqual(newFrozenFramesCount, initialFrozenFramesCount); } @@ -777,6 +792,11 @@ - (void)testFrozenFrameIsNotRecordedInCaseOfSlowFrame { * frames. */ - (void)testTotalFramesAreAlwaysRecorded { + // Set FPS to 60 to match kFPRSlowFrameThreshold constant (1/60) + [self setTestMaxFPSOverride:60]; + FPRScreenTraceTracker *tracker = [[FPRScreenTraceTracker alloc] init]; + tracker.displayLink.paused = YES; + CFAbsoluteTime firstFrameRenderTimestamp = 1.0; CFAbsoluteTime secondFrameRenderTimestamp = firstFrameRenderTimestamp + kFPRSlowFrameThreshold - 0.005; // Good frame. @@ -786,27 +806,27 @@ - (void)testTotalFramesAreAlwaysRecorded { thirdFrameRenderTimestamp + kFPRFrozenFrameThreshold + 0.005; // Frozen frame. id displayLinkMock = OCMClassMock([CADisplayLink class]); - [self.tracker.displayLink invalidate]; - self.tracker.displayLink = displayLinkMock; + [tracker.displayLink invalidate]; + tracker.displayLink = displayLinkMock; // Set/Reset the previousFrameTimestamp if it has been set by a previous test. OCMExpect([displayLinkMock timestamp]).andReturn(firstFrameRenderTimestamp); - [self.tracker displayLinkStep]; - int64_t initialTotalFramesCount = self.tracker.totalFramesCount; + [tracker displayLinkStep]; + int64_t initialTotalFramesCount = tracker.totalFramesCount; OCMExpect([displayLinkMock timestamp]).andReturn(secondFrameRenderTimestamp); - [self.tracker displayLinkStep]; - int64_t newTotalFramesCount = self.tracker.totalFramesCount; + [tracker displayLinkStep]; + int64_t newTotalFramesCount = tracker.totalFramesCount; XCTAssertEqual(newTotalFramesCount, initialTotalFramesCount + 1); OCMExpect([displayLinkMock timestamp]).andReturn(thirdFrameRenderTimestamp); - [self.tracker displayLinkStep]; - newTotalFramesCount = self.tracker.totalFramesCount; + [tracker displayLinkStep]; + newTotalFramesCount = tracker.totalFramesCount; XCTAssertEqual(newTotalFramesCount, initialTotalFramesCount + 2); OCMExpect([displayLinkMock timestamp]).andReturn(fourthFrameRenderTimestamp); - [self.tracker displayLinkStep]; - newTotalFramesCount = self.tracker.totalFramesCount; + [tracker displayLinkStep]; + newTotalFramesCount = tracker.totalFramesCount; XCTAssertEqual(newTotalFramesCount, initialTotalFramesCount + 3); } @@ -1015,10 +1035,11 @@ + (NSString *)expectedTraceNameForViewController:(UIViewController *)viewControl /** Helper to run a single FPS test case with the given tracker and expectations. */ static inline void FPRRunFpsTestCase(FPRScreenTraceTrackerTest *testCase, FPRFpsCase fpsCase) { [testCase setTestMaxFPSOverride:fpsCase.fps]; - // Force the tracker to recompute the cache with the overridden FPS by posting notification - NSNotification *notification = - [NSNotification notificationWithName:UIApplicationDidBecomeActiveNotification object:nil]; - [testCase.tracker appDidBecomeActiveNotification:notification]; + // Create a new tracker so it initializes with the overridden FPS value. + // This works on both iOS and tvOS since initialization always reads maxFPS. + FPRScreenTraceTracker *tracker = [[FPRScreenTraceTracker alloc] init]; + tracker.displayLink.paused = YES; + testCase.tracker = tracker; CFAbsoluteTime firstFrameRenderTimestamp = 1.0; // Frame that should be classified as slow (exceeds budget) @@ -1031,56 +1052,56 @@ static inline void FPRRunFpsTestCase(FPRScreenTraceTrackerTest *testCase, FPRFps CFAbsoluteTime notFrozenFrameTimestamp = frozenFrameTimestamp + 0.699; id displayLinkMock = OCMClassMock([CADisplayLink class]); - [testCase.tracker.displayLink invalidate]; - testCase.tracker.displayLink = displayLinkMock; + [tracker.displayLink invalidate]; + tracker.displayLink = displayLinkMock; // Process an initial frame to set the starting timestamp for duration calculations. OCMExpect([displayLinkMock timestamp]).andReturn(firstFrameRenderTimestamp); - [testCase.tracker displayLinkStep]; - int64_t initialSlowFramesCount = testCase.tracker.slowFramesCount; - int64_t initialFrozenFramesCount = testCase.tracker.frozenFramesCount; - int64_t initialTotalFramesCount = testCase.tracker.totalFramesCount; + [tracker displayLinkStep]; + int64_t initialSlowFramesCount = tracker.slowFramesCount; + int64_t initialFrozenFramesCount = tracker.frozenFramesCount; + int64_t initialTotalFramesCount = tracker.totalFramesCount; // Test slow frame (should be classified as slow, not frozen) OCMExpect([displayLinkMock timestamp]).andReturn(slowFrameTimestamp); - [testCase.tracker displayLinkStep]; - XCTAssertEqual(testCase.tracker.slowFramesCount, initialSlowFramesCount + 1, + [tracker displayLinkStep]; + XCTAssertEqual(tracker.slowFramesCount, initialSlowFramesCount + 1, @"Frame duration %.3fms should be slow at %ld FPS", fpsCase.slowCandidate * 1000.0, (long)fpsCase.fps); - XCTAssertEqual(testCase.tracker.frozenFramesCount, initialFrozenFramesCount, + XCTAssertEqual(tracker.frozenFramesCount, initialFrozenFramesCount, @"Frame duration %.3fms should not be frozen", fpsCase.slowCandidate * 1000.0); - XCTAssertEqual(testCase.tracker.totalFramesCount, initialTotalFramesCount + 1, + XCTAssertEqual(tracker.totalFramesCount, initialTotalFramesCount + 1, @"Total frames should increment after a slow frame."); // Test fast frame (should NOT be classified as slow or frozen) OCMExpect([displayLinkMock timestamp]).andReturn(fastFrameTimestamp); - [testCase.tracker displayLinkStep]; - XCTAssertEqual(testCase.tracker.slowFramesCount, initialSlowFramesCount + 1, + [tracker displayLinkStep]; + XCTAssertEqual(tracker.slowFramesCount, initialSlowFramesCount + 1, @"Frame duration %.3fms should NOT be slow at %ld FPS", fpsCase.fastCandidate * 1000.0, (long)fpsCase.fps); - XCTAssertEqual(testCase.tracker.frozenFramesCount, initialFrozenFramesCount, + XCTAssertEqual(tracker.frozenFramesCount, initialFrozenFramesCount, @"Frame duration %.3fms should not be frozen", fpsCase.fastCandidate * 1000.0); - XCTAssertEqual(testCase.tracker.totalFramesCount, initialTotalFramesCount + 2, + XCTAssertEqual(tracker.totalFramesCount, initialTotalFramesCount + 2, @"Total frames should increment after a fast frame."); // Test frozen frame (should be classified as both slow and frozen) OCMExpect([displayLinkMock timestamp]).andReturn(frozenFrameTimestamp); - [testCase.tracker displayLinkStep]; - XCTAssertEqual(testCase.tracker.slowFramesCount, initialSlowFramesCount + 2, + [tracker displayLinkStep]; + XCTAssertEqual(tracker.slowFramesCount, initialSlowFramesCount + 2, @"Frame duration 701ms should be slow at %ld FPS", (long)fpsCase.fps); - XCTAssertEqual(testCase.tracker.frozenFramesCount, initialFrozenFramesCount + 1, + XCTAssertEqual(tracker.frozenFramesCount, initialFrozenFramesCount + 1, @"Frame duration 701ms should be frozen at %ld FPS", (long)fpsCase.fps); - XCTAssertEqual(testCase.tracker.totalFramesCount, initialTotalFramesCount + 3, + XCTAssertEqual(tracker.totalFramesCount, initialTotalFramesCount + 3, @"Total frames should increment after a frozen frame."); // Test not-frozen frame (should be classified as slow but not frozen) OCMExpect([displayLinkMock timestamp]).andReturn(notFrozenFrameTimestamp); - [testCase.tracker displayLinkStep]; - XCTAssertEqual(testCase.tracker.slowFramesCount, initialSlowFramesCount + 3, + [tracker displayLinkStep]; + XCTAssertEqual(tracker.slowFramesCount, initialSlowFramesCount + 3, @"Frame duration 699ms should be slow at %ld FPS", (long)fpsCase.fps); - XCTAssertEqual(testCase.tracker.frozenFramesCount, initialFrozenFramesCount + 1, + XCTAssertEqual(tracker.frozenFramesCount, initialFrozenFramesCount + 1, @"Frame duration 699ms should NOT be frozen at %ld FPS", (long)fpsCase.fps); - XCTAssertEqual(testCase.tracker.totalFramesCount, initialTotalFramesCount + 4, + XCTAssertEqual(tracker.totalFramesCount, initialTotalFramesCount + 4, @"Total frames should increment after a not-frozen frame."); }