Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
d352bc8
Merge remote-tracking branch 'origin/main' into JesusRojass/#10220Fix
JesusRojass Nov 11, 2025
cf41b42
Use maximumFramesPerSecond for slow-frame threshold
JesusRojass Nov 11, 2025
56dad5f
Fix global static variable - Gemini Suggestion, moved some code around
JesusRojass Nov 11, 2025
2401746
Simplify tests and run style.sh
JesusRojass Nov 11, 2025
189948d
Address Gemini comments - dynamic slowBudget
JesusRojass Nov 11, 2025
c9a3d05
Removed redundant tests
JesusRojass Nov 11, 2025
6eabdb1
Update FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m
JesusRojass Nov 11, 2025
c608a11
Code Cleanup - Gemini Suggestion
JesusRojass Nov 11, 2025
7b14e4c
Optimize method swizzling and remove unused properties
JesusRojass Nov 11, 2025
c464b2e
Remove Redundant block of code
JesusRojass Nov 11, 2025
4407c40
Separate tests and clean stuff up
JesusRojass Nov 11, 2025
fc10cc9
Fix curly braces
JesusRojass Nov 11, 2025
04515e6
Cache slow frame budget and implement a refresh on app active
JesusRojass Nov 11, 2025
d1056fc
Update FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m
JesusRojass Nov 11, 2025
3c876a5
Update FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m
JesusRojass Nov 11, 2025
a0d52d2
Update FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m
JesusRojass Nov 11, 2025
0f204df
Consolidate all app did become active logic into one place
JesusRojass Nov 11, 2025
e172aad
Added descriptive messages to the XCTAssertEqual
JesusRojass Nov 11, 2025
2a0d6cd
add a tearDown method to ensure test isolation
JesusRojass Nov 11, 2025
222bbf9
Refactor previousTimestamp into an instance variable
JesusRojass Nov 11, 2025
47d04c9
refactor previousTimestamp into an instance variable
JesusRojass Nov 11, 2025
a2131eb
tidy up!
JesusRojass Nov 11, 2025
98048a3
added fpr_refreshFrameRateCache method - gemini suggestion
JesusRojass Nov 11, 2025
0a7b38e
Make the fpr_refreshFrameRateCache logic more explicit - gemini suggests
JesusRojass Nov 11, 2025
67804ce
Update FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m
JesusRojass Nov 11, 2025
368c528
Address Comments - tvOS-only refresh and round up amd add tvOS tests
JesusRojass Nov 13, 2025
e145748
Needed test adaptations
JesusRojass Nov 13, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
29 changes: 25 additions & 4 deletions FirebasePerformance/Sources/AppActivity/FPRScreenTraceTracker.m
Original file line number Diff line number Diff line change
Expand Up @@ -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).
*/
Expand Down Expand Up @@ -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) {
Expand Down
158 changes: 158 additions & 0 deletions FirebasePerformance/Tests/Unit/FPRScreenTraceTrackerTest.m
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,32 @@ @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 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];
}

// 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

@interface FPRScreenTraceTrackerTest : FPRTestCase

/** The FPRScreenTraceTracker instance that's being used for a given test. */
Expand All @@ -80,9 +106,42 @@ @interface FPRScreenTraceTrackerTest : FPRTestCase

@implementation FPRScreenTraceTrackerTest

+ (void)setUp {
[super 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);

// 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 {
[super setUp];

// Clear any existing override before test
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];
Expand All @@ -91,6 +150,10 @@ - (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];
Expand All @@ -99,6 +162,12 @@ - (void)tearDown {
self.dispatchGroupToWaitOn = nil;
}

/** Helper method to set the test FPS override for the current test. */
- (void)setTestMaxFPSOverride:(NSInteger)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];
Expand Down Expand Up @@ -901,4 +970,93 @@ + (NSString *)expectedTraceNameForViewController:(UIViewController *)viewControl
return [@"_st_" stringByAppendingString:NSStringFromClass([viewController class])];
}

#pragma mark - Dynamic FPS Threshold Tests

/** 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) {
[testCase setTestMaxFPSOverride:fpsCase.fps];

CFAbsoluteTime firstFrameRenderTimestamp = 1.0;
// 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]);
[testCase.tracker.displayLink invalidate];
testCase.tracker.displayLink = displayLinkMock;

// Reset previousFrameTimestamp
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;

// 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);
}

/** 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]);
}
}

@end