Migrate VSyncClientTest to Swift#185893
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces new initializers for FlutterVSyncClient to explicitly manage variable refresh rate settings and migrates the corresponding unit tests from Objective-C to Swift. The review feedback points out the use of unnecessary nil-coalescing operators on non-optional properties within the new Swift test file.
| if #available(iOS 15.0, *) { | ||
| XCTAssertEqual(Double(link.preferredFrameRateRange.maximum), maxFrameRate, accuracy: 0.1) | ||
| XCTAssertEqual( | ||
| Double(link.preferredFrameRateRange.preferred ?? 0), maxFrameRate, accuracy: 0.1) |
There was a problem hiding this comment.
The preferred property of CAFrameRateRange is a Float and is not optional. The nil-coalescing operator ?? 0 is unnecessary and should be removed to improve code clarity.
| Double(link.preferredFrameRateRange.preferred ?? 0), maxFrameRate, accuracy: 0.1) | |
| Double(link.preferredFrameRateRange.preferred), maxFrameRate, accuracy: 0.1) |
There was a problem hiding this comment.
That's incorrect. This property is optional and eliminating the null-coalescing operator causes a compile error:
./flutter/shell/platform/darwin/ios/framework/Source/VSyncClientTest.swift:46:58: error: value of optional type 'Float?' must be unwrapped to a value of type 'Float'
44 | if #available(iOS 15.0, *) {
45 | XCTAssertEqual(Double(link.preferredFrameRateRange.maximum), maxFrameRate, accuracy: 0.1)
46 | XCTAssertEqual(Double(link.preferredFrameRateRange.preferred), maxFrameRate, accuracy: 0.1)
| |- error: value of optional type 'Float?' must be unwrapped to a value of type 'Float'
| |- note: coalesce using '??' to provide a default when the optional value contains 'nil'
| `- note: force-unwrap using '!' to abort execution if the optional value contains 'nil'
47 | XCTAssertEqual(Double(link.preferredFrameRateRange.minimum), maxFrameRate / 2, accuracy: 0.1)
48 | } else {
Now that we've migrated VSyncClient to have a pure Obj-C API, this migrates its test to Swift. This is part of a series of refactorings that aim to place a thin, lightweight layer of abstraction between the core C++ engine and the iOS embedder. Core to this effort are VSyncWaiterIOS, PlatformViewIOS, and IOSExternalViewEmbedder. Issue: flutter#112232
59a5e21 to
2ef0ffc
Compare
| return [self initWithTaskRunner:taskRunner | ||
| isVariableRefreshRateEnabled:FlutterDisplayLinkManager.maxRefreshRateEnabledOnIPhone | ||
| maxRefreshRate:FlutterDisplayLinkManager.displayRefreshRate | ||
| callback:callback]; |
There was a problem hiding this comment.
This initialiser used to hardcode class method calls on FlutterDisplayLinkManager, which necessitated mocking them using OCMock in tests.
I've added the new designated initialiser above with additional parameters that we can inject directly in tests and avoid OCMock and kept this one for backward compatibility for now.
I have a followup that deletes this and injects the values explicitly at each of the call sites, which is clearer/more readable.
| isVariableRefreshRateEnabled:(BOOL)isVariableRefreshRateEnabled | ||
| maxRefreshRate:(double)maxRefreshRate | ||
| callback:(flutter::VsyncWaiter::Callback)callback; | ||
|
|
There was a problem hiding this comment.
See the note in the implementation file on the rationale for the new initialiser.
hellohuanlin
left a comment
There was a problem hiding this comment.
Left a few questions
| callback:(flutter::VsyncWaiter::Callback)callback { | ||
| return [self initWithTaskRunnerPtr:task_runner | ||
| isVariableRefreshRateEnabled:FlutterDisplayLinkManager.maxRefreshRateEnabledOnIPhone | ||
| maxRefreshRate:FlutterDisplayLinkManager.displayRefreshRate |
There was a problem hiding this comment.
this part is a bit confusing that it passes FlutterDisplayLinkManager.displayRefreshRate into maxRefreshRate?
There was a problem hiding this comment.
I've named it that because that's effectively what this parameter does: it sets the maximum frame rate.
If you take a look at the implementation, it serves two purposes:
- We set the initial refreshRate.
- We immediately call setMaxRefreshRate to set the maximum refresh rate on the CADisplayLink under the hood in FlutterDisplayLinkManager.
Separate issue is that we're making a self call in the initializer but that's an existing issue; this just makes it possible to inject the param.
|
|
||
| - (void)setMaxRefreshRate:(double)refreshRate { | ||
| if (!FlutterDisplayLinkManager.maxRefreshRateEnabledOnIPhone) { | ||
| if (!_isVariableRefreshRateEnabled) { |
There was a problem hiding this comment.
does _isVariableRefreshRateEnabled mean it will use 60hz by default, but may increase to 120hz if needed? or does it mean the opposite (use 120hz by default, but decrease to 60hz if needed?)
There was a problem hiding this comment.
This is a future refactoring leaking through. FlutterDisplayLink.maxRefreshRateEnabledOnIPhone is a badly named API. If you take a look at the implementation, it checks if CADisableMinimumFrameDurationOnPhone is set. If it is, the frame rate is locked to 60 Hz. If not, ProMotion is enabled (i.e. variable refresh rate up to 120 Hz).
There's a followup patch that renames FlutterDisplayLinkManager.maxRefreshRateEnabledOnIPhone to FlutterDisplayManager.isVariableRefreshRateEnabled since that's the explicitly documented behaviour.
From Apple's docs:
A Boolean value that allows your app to access frame rates higher than the system’s default.
Devices with ProMotion displays allow apps to dynamically request a frame rate they prefer. If you set this key to YES, your app can request any frame rate the display supports. If you set this key to NO, frame rates higher than the system default are unavailable.
For more information on refresh rates, see Optimizing iPhone and iPad apps to support ProMotion displays.
There's a deep dive on variable refresh rate support at: WWDC 2021: Optimize for variable refresh rate displays
| XCTAssertEqual(Double(link.preferredFrameRateRange.preferred ?? 0), 0, accuracy: 0.1) | ||
| XCTAssertEqual(Double(link.preferredFrameRateRange.minimum), 0, accuracy: 0.1) | ||
| } else { | ||
| XCTAssertEqual(Double(link.preferredFramesPerSecond), 0, accuracy: 0.1) |
There was a problem hiding this comment.
sanity check - these being 0 doesn't mean it's not refreshing right? it simply means it will refresh at 120hz always? (or 60hz always?)
There was a problem hiding this comment.
Correct, from Apple's docs, this preferredFramesPerSecond/preferredFrameRateRange.preferred being 0 just means max display rate:
The property defaults to 0, which is equivalent to the display’s maximum refresh rate, such as a UIScreen instance’s maximumFramesPerSecond property.
These are just a rewrite of the existing tests with no change.
| } | ||
| } | ||
|
|
||
| func testDoNotSetVariableRefreshRatesIfCADisableMinimumFrameDurationOnPhoneIsNotSet() { |
There was a problem hiding this comment.
is this test identical to previous one?
There was a problem hiding this comment.
Yep this is identical. I assume you mean the setup of the client at the top?
Here's the old code:
void (^callback)(CFTimeInterval, CFTimeInterval) =
^(CFTimeInterval startTime, CFTimeInterval targetTime) {
};
id mockDisplayLinkManager = [OCMockObject mockForClass:[FlutterDisplayLinkManager class]];
double maxFrameRate = 120;
[[[mockDisplayLinkManager stub] andReturnValue:@(maxFrameRate)] displayRefreshRate];
// VSyncClient with empty block and max frame rate of 120 Hz (via the OCMock).
FlutterVSyncClient* vsyncClient =
[[FlutterVSyncClient alloc] initWithTaskRunner:self.threadTaskRunner callback:callback];And the new code:
// VSyncClient with empty block and max frame rate of 120 Hz.
let maxFrameRate: Double = 120.0
let vsyncClient = VSyncClient(
taskRunner: threadTaskRunner,
isVariableRefreshRateEnabled: false,
maxRefreshRate: maxFrameRate
) { _, _ in }!The old code relied on the fact that we hardcoded a static FlutterDisplayLinkManager.displayRefreshRate call in it. Our fallback initialiser still does that, but the point of adding this new one was so we can just inject directly (I have a followup that eliminates the old one altogether because it's pretty confusing.
The tests at the bottom are identical as well.
There was a problem hiding this comment.
Sorry i meant are these tests identical:
testDoNotSetVariableRefreshRatesIfCADisableMinimumFrameDurationOnPhoneIsNotOntestDoNotSetVariableRefreshRatesIfCADisableMinimumFrameDurationOnPhoneIsNotSet
There was a problem hiding this comment.
Ah good catch they are now.
With the previous version of the code that hardcoded the lookup in the initializer there were two ways it could be set to NO:
- the IsNotOn test: mocked the NSBundle to return NO for the CADisableMinimumFrameDurationOnPhone key.
- the IsNotSet test: simulated the case where the key is completely missing from the info.plist
Since we now inject the bool we only need one. Removed the other.
Now that we've migrated VSyncClient to have a pure Obj-C API, this migrates its test to Swift.
This is part of a series of refactorings that aim to place a thin, lightweight layer of abstraction between the core C++ engine and the iOS embedder. Core to this effort are VSyncWaiterIOS, PlatformViewIOS, and IOSExternalViewEmbedder.
Issue: #112232
Pre-launch Checklist
///).If you need help, consider asking for advice on the #hackers-new channel on Discord.
If this change needs to override an active code freeze, provide a comment explaining why. The code freeze workflow can be overridden by code reviewers. See pinned issues for any active code freezes with guidance.
Note: The Flutter team is currently trialing the use of Gemini Code Assist for GitHub. Comments from the
gemini-code-assistbot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed.