diff --git a/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker+Private.h b/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker+Private.h index 9cbb868d799..f13100cd340 100644 --- a/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker+Private.h +++ b/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker+Private.h @@ -37,16 +37,6 @@ 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. - */ -FOUNDATION_EXTERN CFTimeInterval const kFPRSlowFrameThreshold; - -/** Frozen frame threshold (for time difference between current and previous frame render time) - * in sec. - */ -FOUNDATION_EXTERN CFTimeInterval const kFPRFrozenFrameThreshold; - @interface FPRScreenTraceTracker () /** A map table of that has the viewControllers as the keys and their associated trace as the value. diff --git a/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m b/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m index 5137776fb5f..9b67b18e086 100644 --- a/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m +++ b/FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m @@ -25,11 +25,21 @@ 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. -// TODO(b/73498642): Make these configurable. -CFTimeInterval const kFPRSlowFrameThreshold = 1.0 / 59.0; // Anything less than 59 FPS is slow. -CFTimeInterval const kFPRFrozenFrameThreshold = 700.0 / 1000.0; +/** Frozen frame multiplier: A frozen frame is one that takes longer than approximately 42 times + * the current frame duration. This maintains backward compatibility with the old 700ms threshold + * at 60Hz (700ms ÷ 16.67ms ≈ 42 frames). + * + * Note: A "frozen" frame represents missing 42 consecutive frame opportunities, + * which looks and feels equally bad to users regardless of refresh rate. + * + * Formula: frozenThreshold = kFPRFrozenFrameMultiplier * actualFrameDuration + * + * Examples (all represent missing 42 frame opportunities): + * - 60Hz: 42 × 16.67ms = 700ms (same as original threshold) + * - 120Hz: 42 × 8.33ms = 350ms (missing 42 frames at higher refresh rate) + * - 50Hz: 42 × 20ms = 840ms (missing 42 frames at lower refresh rate) + */ +static const CFTimeInterval kFPRFrozenFrameMultiplier = 42.0; /** Constant that indicates an invalid time. */ CFAbsoluteTime const kFPRInvalidTime = -1.0; @@ -80,6 +90,8 @@ @implementation FPRScreenTraceTracker { /** Instance variable storing the frozen frames observed so far. */ atomic_int_fast64_t _frozenFramesCount; + + CFAbsoluteTime _previousTimestamp; } @dynamic totalFramesCount; @@ -114,6 +126,7 @@ - (instancetype)init { atomic_store_explicit(&_slowFramesCount, 0, memory_order_relaxed); _displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(displayLinkStep)]; [_displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes]; + _previousTimestamp = kFPRInvalidTime; // We don't receive background and foreground events from analytics and so we have to listen to // them ourselves. @@ -142,6 +155,10 @@ - (void)dealloc { } - (void)appDidBecomeActiveNotification:(NSNotification *)notification { + // Resume the display link when the app becomes active + _displayLink.paused = NO; + _previousTimestamp = kFPRInvalidTime; + // 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); @@ -160,6 +177,10 @@ - (void)appDidBecomeActiveNotification:(NSNotification *)notification { } - (void)appWillResignActiveNotification:(NSNotification *)notification { + // Pause the display link when the app goes to background to conserve battery + _displayLink.paused = YES; + _previousTimestamp = kFPRInvalidTime; + // 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); @@ -186,11 +207,23 @@ - (void)appWillResignActiveNotification:(NSNotification *)notification { #pragma mark - Frozen, slow and good frames - (void)displayLinkStep { - static CFAbsoluteTime previousTimestamp = kFPRInvalidTime; CFAbsoluteTime currentTimestamp = self.displayLink.timestamp; - RecordFrameType(currentTimestamp, previousTimestamp, &_slowFramesCount, &_frozenFramesCount, - &_totalFramesCount); - previousTimestamp = currentTimestamp; + + CFTimeInterval actualFrameDuration = + self.displayLink.targetTimestamp - self.displayLink.timestamp; + + // Defensive: skip classification when frame budget is zero/negative (e.g., lifecycle/VRR edges) + if (actualFrameDuration <= 0) { + _previousTimestamp = currentTimestamp; + return; + } + + // Dynamic thresholds: slow if frameDuration > frameBudget; frozen if frameDuration > 42 * + // frameBudget. frameBudget is derived from CADisplayLink targetTimestamp - timestamp and adapts + // to VRR/50/60/120 Hz. + RecordFrameType(currentTimestamp, _previousTimestamp, actualFrameDuration, &_slowFramesCount, + &_frozenFramesCount, &_totalFramesCount); + _previousTimestamp = currentTimestamp; } /** This function increments the relevant frame counters based on the current and previous @@ -198,6 +231,8 @@ - (void)displayLinkStep { * * @param currentTimestamp The current timestamp of the displayLink. * @param previousTimestamp The previous timestamp of the displayLink. + * @param actualFrameDuration The actual frame duration calculated from CADisplayLink's + * targetTimestamp and timestamp. * @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. @@ -205,19 +240,26 @@ - (void)displayLinkStep { FOUNDATION_STATIC_INLINE void RecordFrameType(CFAbsoluteTime currentTimestamp, CFAbsoluteTime previousTimestamp, + CFTimeInterval actualFrameDuration, atomic_int_fast64_t *slowFramesCounter, atomic_int_fast64_t *frozenFramesCounter, atomic_int_fast64_t *totalFramesCounter) { CFTimeInterval frameDuration = currentTimestamp - previousTimestamp; - if (previousTimestamp == kFPRInvalidTime) { + if (previousTimestamp == kFPRInvalidTime || actualFrameDuration <= 0 || frameDuration <= 0) { return; } - if (frameDuration > kFPRSlowFrameThreshold) { + + // Dynamic thresholds: classify against the runtime-derived frame budget. + // Slow: frameDuration > actualFrameDuration; Frozen: frameDuration > (42 * actualFrameDuration). + if (frameDuration > actualFrameDuration) { atomic_fetch_add_explicit(slowFramesCounter, 1, memory_order_relaxed); } - if (frameDuration > kFPRFrozenFrameThreshold) { + + CFTimeInterval frozenThreshold = kFPRFrozenFrameMultiplier * actualFrameDuration; + if (frameDuration > frozenThreshold) { atomic_fetch_add_explicit(frozenFramesCounter, 1, memory_order_relaxed); } + atomic_fetch_add_explicit(totalFramesCounter, 1, memory_order_relaxed); } diff --git a/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m b/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m index 2f5cbf40c61..36d639bad53 100644 --- a/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m +++ b/FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m @@ -23,6 +23,13 @@ #import #import "FirebasePerformance/Tests/Unit/FPRTestCase.h" +static inline CFTimeInterval FPRFrameBudgetForHz(double hz) { + return 1.0 / hz; +} +static inline CFTimeInterval FPRFrozenThresholdForBudget(CFTimeInterval budget) { + return 42.0 * budget; +} + /** Registers and returns an instance of a custom subclass of UIViewController. */ static UIViewController *FPRCustomViewController(NSString *className, BOOL isViewLoaded) { Class customClass = NSClassFromString(className); @@ -603,20 +610,38 @@ - (void)testAppDidBecomeActiveWillNotRestoreTracesOfNilledViewControllers { * slow frame counter of the screen trace tracker is incremented. */ - (void)testSlowFrameIsRecorded { + CFTimeInterval frameBudget = FPRFrameBudgetForHz(60.0); CFAbsoluteTime firstFrameRenderTimestamp = 1.0; - CFAbsoluteTime secondFrameRenderTimestamp = - firstFrameRenderTimestamp + kFPRSlowFrameThreshold + 0.005; // Buffer for float comparison. + CFAbsoluteTime secondFrameRenderTimestamp = firstFrameRenderTimestamp + frameBudget + 0.005; id displayLinkMock = OCMClassMock([CADisplayLink class]); [self.tracker.displayLink invalidate]; self.tracker.displayLink = displayLinkMock; - // Set/Reset the previousFrameTimestamp if it has been set by a previous test. - OCMExpect([displayLinkMock timestamp]).andReturn(firstFrameRenderTimestamp); + // Drive CADisplayLink with matched timestamp/targetTimestamp pairs per tick. + __block NSInteger tick = 0; + NSArray *timestamps = + @[ @(firstFrameRenderTimestamp), @(secondFrameRenderTimestamp) ]; + NSArray *targets = + @[ @(firstFrameRenderTimestamp + frameBudget), @(secondFrameRenderTimestamp + frameBudget) ]; + + OCMStub([displayLinkMock isPaused]).andReturn(NO); + + OCMStub([displayLinkMock timestamp]).andDo(^(NSInvocation *inv) { + CFTimeInterval v = timestamps[(NSUInteger)tick].doubleValue; + [inv setReturnValue:&v]; + }); + OCMStub([displayLinkMock targetTimestamp]).andDo(^(NSInvocation *inv) { + CFTimeInterval v = targets[(NSUInteger)tick].doubleValue; + [inv setReturnValue:&v]; + }); + + // Tick 1 - prime previous timestamp (no counts expected). [self.tracker displayLinkStep]; int64_t initialSlowFramesCount = self.tracker.slowFramesCount; - OCMExpect([displayLinkMock timestamp]).andReturn(secondFrameRenderTimestamp); + // Tick 2 - slow frame: duration > budget. + tick++; [self.tracker displayLinkStep]; int64_t newSlowFramesCount = self.tracker.slowFramesCount; @@ -625,13 +650,16 @@ - (void)testSlowFrameIsRecorded { /** Tests that the slow and frozen frame counter is not incremented in the case of a good frame. */ - (void)testSlowAndFrozenFrameIsNotRecordedInCaseOfGoodFrame { + CFTimeInterval frameBudget = FPRFrameBudgetForHz(60.0); CFAbsoluteTime firstFrameRenderTimestamp = 1.0; - CFAbsoluteTime secondFrameRenderTimestamp = - firstFrameRenderTimestamp + kFPRSlowFrameThreshold - 0.005; // Good frame. + CFAbsoluteTime secondFrameRenderTimestamp = firstFrameRenderTimestamp + frameBudget - 0.005; id displayLinkMock = OCMClassMock([CADisplayLink class]); [self.tracker.displayLink invalidate]; self.tracker.displayLink = displayLinkMock; + OCMStub([displayLinkMock targetTimestamp]) + .andReturn(firstFrameRenderTimestamp + frameBudget) + .andReturn(secondFrameRenderTimestamp + frameBudget); // Set/Reset the previousFrameTimestamp if it has been set by a previous test. OCMExpect([displayLinkMock timestamp]).andReturn(firstFrameRenderTimestamp); @@ -651,13 +679,16 @@ - (void)testSlowAndFrozenFrameIsNotRecordedInCaseOfGoodFrame { /* Tests that the frozen frame counter is not incremented in case of a slow frame. */ - (void)testFrozenFrameIsNotRecordedInCaseOfSlowFrame { + CFTimeInterval frameBudget = FPRFrameBudgetForHz(60.0); CFAbsoluteTime firstFrameRenderTimestamp = 1.0; - CFAbsoluteTime secondFrameRenderTimestamp = - firstFrameRenderTimestamp + kFPRSlowFrameThreshold + 0.005; // Slow frame. + CFAbsoluteTime secondFrameRenderTimestamp = firstFrameRenderTimestamp + frameBudget + 0.005; id displayLinkMock = OCMClassMock([CADisplayLink class]); [self.tracker.displayLink invalidate]; self.tracker.displayLink = displayLinkMock; + OCMStub([displayLinkMock targetTimestamp]) + .andReturn(firstFrameRenderTimestamp + frameBudget) + .andReturn(secondFrameRenderTimestamp + frameBudget); // Set/Reset the previousFrameTimestamp if it has been set by a previous test. OCMExpect([displayLinkMock timestamp]).andReturn(firstFrameRenderTimestamp); @@ -675,17 +706,24 @@ - (void)testFrozenFrameIsNotRecordedInCaseOfSlowFrame { * frames. */ - (void)testTotalFramesAreAlwaysRecorded { + CFTimeInterval frameBudget = FPRFrameBudgetForHz(60.0); + CFTimeInterval frozenThreshold = FPRFrozenThresholdForBudget(frameBudget); CFAbsoluteTime firstFrameRenderTimestamp = 1.0; CFAbsoluteTime secondFrameRenderTimestamp = - firstFrameRenderTimestamp + kFPRSlowFrameThreshold - 0.005; // Good frame. + firstFrameRenderTimestamp + frameBudget - 0.005; // Good frame. CFAbsoluteTime thirdFrameRenderTimestamp = - secondFrameRenderTimestamp + kFPRSlowFrameThreshold + 0.005; // Slow frame. + secondFrameRenderTimestamp + frameBudget + 0.005; // Slow frame. CFAbsoluteTime fourthFrameRenderTimestamp = - thirdFrameRenderTimestamp + kFPRFrozenFrameThreshold + 0.005; // Frozen frame. + thirdFrameRenderTimestamp + frozenThreshold + 0.005; // Frozen frame. id displayLinkMock = OCMClassMock([CADisplayLink class]); [self.tracker.displayLink invalidate]; self.tracker.displayLink = displayLinkMock; + OCMStub([displayLinkMock targetTimestamp]) + .andReturn(firstFrameRenderTimestamp + frameBudget) + .andReturn(secondFrameRenderTimestamp + frameBudget) + .andReturn(thirdFrameRenderTimestamp + frameBudget) + .andReturn(fourthFrameRenderTimestamp + frameBudget); // Set/Reset the previousFrameTimestamp if it has been set by a previous test. OCMExpect([displayLinkMock timestamp]).andReturn(firstFrameRenderTimestamp); @@ -712,21 +750,41 @@ - (void)testTotalFramesAreAlwaysRecorded { * frozen frame counter and slow frame counter of the screen trace tracker is incremented. */ - (void)testFrozenFrameAndSlowFrameIsRecorded { + CFTimeInterval frameBudget = FPRFrameBudgetForHz(60.0); + CFTimeInterval frozenThreshold = FPRFrozenThresholdForBudget(frameBudget); CFAbsoluteTime firstFrameRenderTimestamp = 1.0; CFAbsoluteTime secondFrameRenderTimestamp = - firstFrameRenderTimestamp + kFPRFrozenFrameThreshold + 0.005; // Buffer for float comparison. + firstFrameRenderTimestamp + frozenThreshold + 0.005; // Buffer for float comparison. id displayLinkMock = OCMClassMock([CADisplayLink class]); [self.tracker.displayLink invalidate]; self.tracker.displayLink = displayLinkMock; - // Set/Reset the previousFrameTimestamp if it has been set by a previous test. - OCMExpect([displayLinkMock timestamp]).andReturn(firstFrameRenderTimestamp); + // Drive CADisplayLink with matched timestamp/targetTimestamp pairs per tick. + __block NSInteger tick = 0; + NSArray *timestamps = + @[ @(firstFrameRenderTimestamp), @(secondFrameRenderTimestamp) ]; + NSArray *targets = + @[ @(firstFrameRenderTimestamp + frameBudget), @(secondFrameRenderTimestamp + frameBudget) ]; + + OCMStub([displayLinkMock isPaused]).andReturn(NO); + + OCMStub([displayLinkMock timestamp]).andDo(^(NSInvocation *inv) { + CFTimeInterval v = timestamps[(NSUInteger)tick].doubleValue; + [inv setReturnValue:&v]; + }); + OCMStub([displayLinkMock targetTimestamp]).andDo(^(NSInvocation *inv) { + CFTimeInterval v = targets[(NSUInteger)tick].doubleValue; + [inv setReturnValue:&v]; + }); + + // Tick 1 - prime previous timestamp (no counts expected). [self.tracker displayLinkStep]; int64_t initialSlowFramesCount = self.tracker.slowFramesCount; int64_t initialFrozenFramesCount = self.tracker.frozenFramesCount; - OCMExpect([displayLinkMock timestamp]).andReturn(secondFrameRenderTimestamp); + // Tick 2 - frozen (also slow): duration > 42 * budget. + tick++; [self.tracker displayLinkStep]; int64_t newSlowFramesCount = self.tracker.slowFramesCount; int64_t newFrozenFramesCount = self.tracker.frozenFramesCount;