Skip to content

Migrate VSyncClientTest to Swift#185893

Merged
cbracken merged 3 commits into
flutter:masterfrom
cbracken:migrate-vsyncclienttest
May 2, 2026
Merged

Migrate VSyncClientTest to Swift#185893
cbracken merged 3 commits into
flutter:masterfrom
cbracken:migrate-vsyncclienttest

Conversation

@cbracken

@cbracken cbracken commented May 1, 2026

Copy link
Copy Markdown
Member

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-assist bot 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.

@cbracken cbracken requested a review from a team as a code owner May 1, 2026 12:00
@flutter-dashboard flutter-dashboard Bot added the CICD Run CI/CD label May 1, 2026
@github-actions github-actions Bot added platform-ios iOS applications specifically engine flutter/engine related. See also e: labels. team-ios Owned by iOS platform team labels May 1, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
Double(link.preferredFrameRateRange.preferred ?? 0), maxFrameRate, accuracy: 0.1)
Double(link.preferredFrameRateRange.preferred), maxFrameRate, accuracy: 0.1)

@cbracken cbracken May 1, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
@cbracken cbracken force-pushed the migrate-vsyncclienttest branch from 59a5e21 to 2ef0ffc Compare May 1, 2026 12:46
@github-actions github-actions Bot removed the CICD Run CI/CD label May 1, 2026
@cbracken cbracken added the CICD Run CI/CD label May 1, 2026
return [self initWithTaskRunner:taskRunner
isVariableRefreshRateEnabled:FlutterDisplayLinkManager.maxRefreshRateEnabledOnIPhone
maxRefreshRate:FlutterDisplayLinkManager.displayRefreshRate
callback:callback];

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See the note in the implementation file on the rationale for the new initialiser.

@hellohuanlin hellohuanlin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left a few questions

callback:(flutter::VsyncWaiter::Callback)callback {
return [self initWithTaskRunnerPtr:task_runner
isVariableRefreshRateEnabled:FlutterDisplayLinkManager.maxRefreshRateEnabledOnIPhone
maxRefreshRate:FlutterDisplayLinkManager.displayRefreshRate

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this part is a bit confusing that it passes FlutterDisplayLinkManager.displayRefreshRate into maxRefreshRate?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?)

@cbracken cbracken May 1, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this test identical to previous one?

@cbracken cbracken May 1, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry i meant are these tests identical:

  • testDoNotSetVariableRefreshRatesIfCADisableMinimumFrameDurationOnPhoneIsNotOn
  • testDoNotSetVariableRefreshRatesIfCADisableMinimumFrameDurationOnPhoneIsNotSet

@cbracken cbracken May 1, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@github-actions github-actions Bot removed the CICD Run CI/CD label May 1, 2026
@flutter-dashboard flutter-dashboard Bot added the CICD Run CI/CD label May 1, 2026

@hellohuanlin hellohuanlin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CICD Run CI/CD engine flutter/engine related. See also e: labels. platform-ios iOS applications specifically team-ios Owned by iOS platform team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants