[iOS] Migrate DisplayLinkManager to a shared instance#189492
Conversation
There was a problem hiding this comment.
Code Review
This pull request refactors DisplayLinkManager into a thread-safe singleton (shared) that caches the display refresh rate and dynamically updates it by observing system notifications. It updates references across the iOS platform code to use this shared instance and introduces comprehensive unit tests. The review feedback suggests minor documentation corrections, using a more idiomatic Swift guard let self pattern in the notification handler, and utilizing ProcessInfo.powerStateDidChangeNotification for consistency in both the source and test files.
Previously, `DisplayLinkManager` was a stateless static utility class that queried `Bundle.main` and `UIScreen.main` directly on every access. Under Swift 6 strict concurrency this produces data-race warnings, and nothing guaranteed these accessors weren't being called from a background engine thread, which would be a UIKit thread-safety violation. This migrates `DisplayLinkManager` to a `final class` shared singleton conforming to `@unchecked Sendable`. All callers now go through `FlutterDisplayLinkManager.shared`. ### Max refresh rate `maxRefreshRateEnabledOnIPhone` is plist-derived and doesn't change during a process's lifetime, so we cache it once at initialisation in an immutable field. ### Current refresh rate `displayRefreshRate` is a bit more complex: `VsyncWaiterIOS::AwaitVSync` polls it on every vsync specifically to detect refresh-rate changes at runtime (e.g. Low Power Mode or thermal throttling limiting refresh rate to 60 Hz even on ProMotion devices), so caching it would break that detection. Instead, `displayRefreshRate` is a computed property backed by a lock-protected cache, following the same pattern already used in `ResizeSynchronizer`/`FlutterRunLoop` on macOS. There's no documented, exhaustive list of everything that can change the device's effective max refresh rate, so we keep the cached value up to date in two ways: * by observing the specific triggers we know about (Low Power Mode via `NSProcessInfoPowerStateDidChange`, thermal throttling via `ProcessInfo.thermalStateDidChangeNotification`, and screen mode changes via `UIScreen.modeDidChangeNotification`); and * by re-checking on `UIApplication.didBecomeActiveNotification` as a sort of forcing function in case there's any other trigger we don't explicitly know about. Background threads now get a cheap, lock-guarded read instead of touching `UIScreen.main` directly, while the cached value still tracks reality within a bounded staleness window. The cache-update code lives in `updateCachedDisplayRefreshRate(_:)`. This is `internal` since it's used both by the real notification handlers as well as in a unit test I've added to verify the locking/storage behavior directly rather than requiring us to post real notifications and hope `UIScreen.main`'s reported value happens to change in the test host. ### Test updates `FlutterViewControllerTest` previously stubbed `displayRefreshRate` as a class method via `OCMockObject mockForClass:`. Since it's now a property on the shared instance, those tests now use `OCMPartialMock([FlutterDisplayLinkManager shared])` instead. ### Preferred frame rate probing cleanup Finally, this deletes the `CADisplayLink` target-selector probing in the old `displayRefreshRate`. That code created a throwaway `CADisplayLink(target:selector:)`, set `isPaused = true`, then read back `preferredFramesPerSecond` before discarding it. `preferredFramesPerSecond` is a property you set to request a rate; its documented default is `0` until something explicitly assigns it, and this link was never added to a run loop, so nothing ever could. The `if preferredFPS != 0` branch was therefore unreachable, and the function always fell through to `UIScreen.main.maximumFramesPerSecond` regardless. As a result, the display link was constructed, queried, and thrown away on every single call for no effect on the result. It's worth noting that this is a different property from `CADisplayLink.timestamp`/`targetTimestamp`, which do have documented first-frame-is-zero semantics and are already guarded independently in `VSyncClient.onDisplayLink` (see flutter#186457) and again in the C++ code in `VsyncWaiterIOS::SnapDuration`. `preferredFramesPerSecond`'s zero-ness here had nothing to do with frame timing, only with the fact that nobody had ever set it. Since this was on the read path for a property that's polled up to ~120 times/second by `VsyncWaiterIOS::AwaitVSync`, it meant allocating and discarding a `QuartzCore` object on every vsync for a value that could never differ from a plain `UIScreen.main.maximumFramesPerSecond` read. The new code does a single read, at construction, and lets the caching/observation machinery described above keep it current. Configurable preferred-FPS support is tracked separately in flutter#185759. Issue: flutter#175879 Issue: flutter#181684
ae59254 to
504df89
Compare
|
Review feedback applied for the cases where it made sense. |
|
Video of manual test on simulator: Will also upload a video of manual testing on ProMotion device. |
LongCatIsLooong
left a comment
There was a problem hiding this comment.
LGTM with nits. Once we turn on swift 6 language mode I'd assume this is going to stop compiling since the initializer isn't marked @MainActor but it's synchronously accessing UIScreen.main, but this class should be simple enough to migrate.
There was a problem hiding this comment.
Any chance these can be turned into swift testing tests? A few tests can be parameterized it looks like.
There was a problem hiding this comment.
Mind if I do that in a followup (in which I also want to do the tests for VSyncClient)?
f5f193c to
57a1b81
Compare
|
Here's the physical device ProMotion On/Off recording I promised. ProMotionOn_ProMotionOff.mov |
Follow-up to flutter#189492, which migrated `DisplayLinkManager` to a shared singleton instance. `VsyncWaiterIOS` picked up `.shared` directly in three places: once in the constructor when seeding the `FlutterVSyncClient` and `max_refresh_rate_`, and again on every `AwaitVSync()` poll. That's a hidden global dependency, and it meant the only way to exercise `VsyncWaiterIOS` against a non-default refresh rate was to mutate the real shared instance out from under whatever else might be touching it. This threads a `FlutterDisplayLinkManager*` through the constructor instead and stores it on `display_link_manager_`, so all three read sites go through the injected instance. `PlatformViewIOS::CreateVSyncWaiter()` is the only production callsite, and now passes `FlutterDisplayLinkManager.shared` explicitly rather than leaving it implicit. That callsite runs synchronously on the platform thread during `Shell::CreateShellOnPlatformThread`, which is the main thread in standard embedder usage, so this doesn't change anything about `DisplayLinkManager.init()`'s main-thread assertion on first access. While adding tests against the injected manager, found that `AwaitVSync()` had been made `private` by mistake in 722be07 — a stray `private:` label landed right above it while `SnapDuration` was being added, silently undoing the "Made public for testing" it's commented with (and had been since c05f179). Restored it to public. Added two tests to `VsyncWaiterIOSTest.mm` that construct a `VsyncWaiterIOS` against an `OCMPartialMock` of `FlutterDisplayLinkManager.shared`, matching the mocking pattern already used in `FlutterViewControllerTest.mm`. Both assert against a new `GetMaxRefreshRateForTesting()` accessor rather than the pre-existing `GetRefreshRate()`: the latter forwards to `client_.refreshRate`, which per `VariableRefreshRateReporter`'s contract reports the live *measured* refresh rate from actual vsync ticks, not the polled ceiling `AwaitVSync()` tracks. It's seeded from the constructor's `maxRefreshRate` param (which is why the naive version of the constructor test happened to pass), but `AwaitVSync()` never touches it — `[client_ setMaxRefreshRate:]` only reconfigures the display link's requested rate for future frames, so asserting against `GetRefreshRate()` after `AwaitVSync()` was asserting against a value nothing in this path updates. `GetMaxRefreshRateForTesting()` exposes `max_refresh_rate_` directly instead, following the same `GetXForTesting()` convention already used in `android_shell_holder.h`. One test confirms the constructor seeds `max_refresh_rate_` from the injected manager, the other confirms `AwaitVSync()` picks up a refresh-rate change on it. Issue: flutter#175879 Issue: flutter#181684
Follow-up to flutter#189492, which migrated `DisplayLinkManager` to a shared singleton instance. `VsyncWaiterIOS` picked up `.shared` directly in three places: once in the constructor when seeding the `FlutterVSyncClient` and `max_refresh_rate_`, and again on every `AwaitVSync()` poll. That's a hidden global dependency, and it meant the only way to exercise `VsyncWaiterIOS` against a non-default refresh rate was to mutate the real shared instance out from under whatever else might be touching it. This threads a `FlutterDisplayLinkManager*` through the constructor instead and stores it on `display_link_manager_`, so all three read sites go through the injected instance. `PlatformViewIOS::CreateVSyncWaiter()` is the only production callsite, and now passes `FlutterDisplayLinkManager.shared` explicitly rather than leaving it implicit. That callsite runs synchronously on the platform thread during `Shell::CreateShellOnPlatformThread`, which is the main thread in standard embedder usage, so this doesn't change anything about `DisplayLinkManager.init()`'s main-thread assertion on first access. While adding tests against the injected manager, found that `AwaitVSync()` had been made `private` by mistake in 722be07 — a stray `private:` label landed right above it while `SnapDuration` was being added, silently undoing the "Made public for testing" it's commented with (and had been since c05f179). Restored it to public. Added two tests to `VsyncWaiterIOSTest.mm` that construct a `VsyncWaiterIOS` against an `OCMPartialMock` of `FlutterDisplayLinkManager.shared`, matching the mocking pattern already used in `FlutterViewControllerTest.mm`. Both assert against a new `GetMaxRefreshRateForTesting()` accessor rather than the pre-existing `GetRefreshRate()`: the latter forwards to `client_.refreshRate`, which per `VariableRefreshRateReporter`'s contract reports the live *measured* refresh rate from actual vsync ticks, not the polled ceiling `AwaitVSync()` tracks. It's seeded from the constructor's `maxRefreshRate` param (which is why the naive version of the constructor test happened to pass), but `AwaitVSync()` never touches it — `[client_ setMaxRefreshRate:]` only reconfigures the display link's requested rate for future frames, so asserting against `GetRefreshRate()` after `AwaitVSync()` was asserting against a value nothing in this path updates. `GetMaxRefreshRateForTesting()` exposes `max_refresh_rate_` directly instead, following the same `GetXForTesting()` convention already used in `android_shell_holder.h`. One test confirms the constructor seeds `max_refresh_rate_` from the injected manager, the other confirms `AwaitVSync()` picks up a refresh-rate change on it. `testAwaitVSyncPicksUpRefreshRateChangesFromInjectedDisplayLinkManager` still failed on a real device run even after the above fix, because of a second, more subtle issue: re-stubbing `displayRefreshRate` on the same OCMock partial mock (once with the initial rate, again with the updated rate before calling `AwaitVSync()`) doesn't override the first stub. OCMock keeps every recorder registered against a mock and answers with the first one that matches, so the second `andReturnValue:` call was silently ignored and `displayRefreshRate` kept returning the initial value. This is made worse by the constructor itself reading `displayRefreshRate` twice (once for `FlutterVSyncClient`'s `maxRefreshRate:` param, once more for `max_refresh_rate_`), which also rules out modeling this with paired `expect` calls instead of `stub`. Replaced the two `stub`/`andReturnValue:` calls with a single `OCMStub(...).andDo(...)` that reads a `__block double` variable on each invocation, matching the pattern already used for dynamic return values elsewhere in this codebase (e.g. `FlutterKeyboardManagerTest.mm`). The test now mutates that variable directly between the "before" and "after" assertions instead of re-stubbing, which correctly reflects a value that changes over time. Issue: flutter#175879 Issue: flutter#181684
Follow-up to the same DisplayLinkManager singleton migration (flutter#189492) as the previous VsyncWaiterIOS change: FlutterKeyboardInsetManager read DisplayLinkManager.shared directly in setUpKeyboardAnimationVsyncClient when seeding its VSyncClient's isVariableRefreshRateEnabled and maxRefreshRate, the same hidden-global pattern. This threads a DisplayLinkManager through the initializer instead and stores it on a new displayLinkManager property, so that read goes through the injected instance. FlutterViewController.mm's performCommonViewControllerInitialization is the only production callsite, and now passes FlutterDisplayLinkManager.shared explicitly. FlutterKeyboardInsetManager isn't part of the public framework headers, so this is a self-contained change: the six construction sites in FlutterViewControllerTest.mm (none of which stub a specific refresh rate; they only assert on inset math or vsync-client presence) now pass FlutterDisplayLinkManager.shared too, preserving existing behaviour. FakeFlutterKeyboardInsetManager is untouched — it's an independent NSObject conforming to FlutterKeyboardInsetManagerProtocol rather than a subclass, so it never depended on this initializer. Issue: flutter#175879 Issue: flutter#181684
Follow-up to the same DisplayLinkManager singleton migration (flutter#189492) as the two previous changes in this series: createTouchRateCorrectionVSyncClientIfNeeded read FlutterDisplayLinkManager.shared directly in three places when deciding whether to create the touch-rate-correction VSyncClient and seeding its isVariableRefreshRateEnabled/maxRefreshRate. FlutterViewController has too many public initializers to add a constructor parameter without breaking API, unlike VsyncWaiterIOS and FlutterKeyboardInsetManager. It already had the answer, though: keyboardInsetManager and accessibilityFeatures are declared as internal strong properties in FlutterViewController_Internal.h and assigned once in performCommonViewControllerInitialization, the single method every public initializer funnels through. This adds displayLinkManager following that same pattern, seeded from FlutterDisplayLinkManager.shared at init, and switches the three reads in createTouchRateCorrectionVSyncClientIfNeeded to self.displayLinkManager. _keyboardInsetManager's own construction now reuses the same captured _displayLinkManager ivar instead of a second .shared read. No test changes needed. The five existing tests that OCMPartialMock([FlutterDisplayLinkManager shared]) construct that mock before constructing the view controller, and OCMPartialMock swizzles the live singleton's dispatch table in place rather than replacing the object, so self.displayLinkManager — captured at construction time — still points at the same (now-mocked) instance those tests stub. Issue: flutter#175879 Issue: flutter#181684
Follow-up to the same DisplayLinkManager singleton migration (flutter#189492) as the previous three changes in this series: FlutterMetalLayer read FlutterDisplayLinkManager.shared directly in four places when seeding and re-seeding its own CADisplayLink's preferred frame rate range. FlutterMetalLayer can't take a constructor parameter at all: it's a CALayer subclass, and every instance is created implicitly by UIKit via +layerClass returning [FlutterMetalLayer class] (see rendering_api_selection.mm and the various +layerClass overrides), which always calls the parameterless -init. There's no callsite anywhere in the tree that does [[FlutterMetalLayer alloc] init] directly, so unlike VsyncWaiterIOS/FlutterKeyboardInsetManager there's no initializer to extend, and unlike FlutterViewController there's no common-initialization method to fall back on either — just -init itself. This adds a _displayLinkManager ivar, seeded from FlutterDisplayLinkManager.shared at the top of -init, and switches the four reads (the initial setMaxRefreshRate:forceMax: call, the maxRefreshRateEnabledOnIPhone guard, and the two re-seeds in onDisplayLink: and presentOnMainThread:) to read from it instead. Deliberately did not add this as a property on the class, unlike displayLinkManager on FlutterViewController. FlutterMetalLayer.h's header comment states properties and methods "must exactly match those in the CAMetalLayer interface declaration" — it deliberately masquerades as CAMetalLayer (see -isKindOfClass:) for drop-in compatibility, so growing its public-ish surface with an unrelated property would violate that. The ivar lives in the private class extension in the .mm instead, matching _preferredDevice and _drawableSize alongside it. No test coverage previously existed for the DisplayLinkManager-derived refresh rate logic in FlutterMetalLayerTest.mm, so there's nothing to update there. Issue: flutter#175879 Issue: flutter#181684
Follow-up to the same DisplayLinkManager singleton migration (flutter#189492) as the previous VsyncWaiterIOS change: FlutterKeyboardInsetManager read DisplayLinkManager.shared directly in setUpKeyboardAnimationVsyncClient when seeding its VSyncClient's isVariableRefreshRateEnabled and maxRefreshRate, the same hidden-global pattern. This threads a DisplayLinkManager through the initializer instead and stores it on a new displayLinkManager property, so that read goes through the injected instance. FlutterViewController.mm's performCommonViewControllerInitialization is the only production callsite, and now passes FlutterDisplayLinkManager.shared explicitly. FlutterKeyboardInsetManager isn't part of the public framework headers, so this is a self-contained change: the six construction sites in FlutterViewControllerTest.mm (none of which stub a specific refresh rate; they only assert on inset math or vsync-client presence) now pass FlutterDisplayLinkManager.shared too, preserving existing behaviour. FakeFlutterKeyboardInsetManager is untouched — it's an independent NSObject conforming to FlutterKeyboardInsetManagerProtocol rather than a subclass, so it never depended on this initializer. Issue: flutter#175879 Issue: flutter#181684
Follow-up to the same DisplayLinkManager singleton migration (flutter#189492) as the two previous changes in this series: createTouchRateCorrectionVSyncClientIfNeeded read FlutterDisplayLinkManager.shared directly in three places when deciding whether to create the touch-rate-correction VSyncClient and seeding its isVariableRefreshRateEnabled/maxRefreshRate. FlutterViewController has too many public initializers to add a constructor parameter without breaking API, unlike VsyncWaiterIOS and FlutterKeyboardInsetManager. It already had the answer, though: keyboardInsetManager and accessibilityFeatures are declared as internal strong properties in FlutterViewController_Internal.h and assigned once in performCommonViewControllerInitialization, the single method every public initializer funnels through. This adds displayLinkManager following that same pattern, seeded from FlutterDisplayLinkManager.shared at init, and switches the three reads in createTouchRateCorrectionVSyncClientIfNeeded to self.displayLinkManager. _keyboardInsetManager's own construction now reuses the same captured _displayLinkManager ivar instead of a second .shared read. No test changes needed. The five existing tests that OCMPartialMock([FlutterDisplayLinkManager shared]) construct that mock before constructing the view controller, and OCMPartialMock swizzles the live singleton's dispatch table in place rather than replacing the object, so self.displayLinkManager — captured at construction time — still points at the same (now-mocked) instance those tests stub. Issue: flutter#175879 Issue: flutter#181684
Follow-up to the same DisplayLinkManager singleton migration (flutter#189492) as the previous three changes in this series: FlutterMetalLayer read FlutterDisplayLinkManager.shared directly in four places when seeding and re-seeding its own CADisplayLink's preferred frame rate range. FlutterMetalLayer can't take a constructor parameter at all: it's a CALayer subclass, and every instance is created implicitly by UIKit via +layerClass returning [FlutterMetalLayer class] (see rendering_api_selection.mm and the various +layerClass overrides), which always calls the parameterless -init. There's no callsite anywhere in the tree that does [[FlutterMetalLayer alloc] init] directly, so unlike VsyncWaiterIOS/FlutterKeyboardInsetManager there's no initializer to extend, and unlike FlutterViewController there's no common-initialization method to fall back on either — just -init itself. This adds a _displayLinkManager ivar, seeded from FlutterDisplayLinkManager.shared at the top of -init, and switches the four reads (the initial setMaxRefreshRate:forceMax: call, the maxRefreshRateEnabledOnIPhone guard, and the two re-seeds in onDisplayLink: and presentOnMainThread:) to read from it instead. Deliberately did not add this as a property on the class, unlike displayLinkManager on FlutterViewController. FlutterMetalLayer.h's header comment states properties and methods "must exactly match those in the CAMetalLayer interface declaration" — it deliberately masquerades as CAMetalLayer (see -isKindOfClass:) for drop-in compatibility, so growing its public-ish surface with an unrelated property would violate that. The ivar lives in the private class extension in the .mm instead, matching _preferredDevice and _drawableSize alongside it. No test coverage previously existed for the DisplayLinkManager-derived refresh rate logic in FlutterMetalLayerTest.mm, so there's nothing to update there. Issue: flutter#175879 Issue: flutter#181684
Roll Flutter from fc1ad955f164 to 8005793c3562 (40 revisions) flutter/flutter@fc1ad95...8005793 2026-07-18 [email protected] Remove `LineContents ` experimental AA line shader that is no longer used (flutter/flutter#189619) 2026-07-18 [email protected] Roll Skia from 9468e96cc40f to ba90f98535de (8 revisions) (flutter/flutter#189680) 2026-07-17 [email protected] Roll Dart SDK from 33e4b71e984b to 666e1e2133b7 (2 revisions) (flutter/flutter#189673) 2026-07-17 [email protected] [iOS] Fix potential use-after-free in a11y bridge channel handler (flutter/flutter#189637) 2026-07-17 [email protected] Migrate dev/tools skill validation to new dart_skills_lint API (flutter/flutter#189626) 2026-07-17 [email protected] Add validation for required fields during xcodebuild (flutter/flutter#187772) 2026-07-17 [email protected] Upgrade android_hardware_smoke_test CI to run instrumented tests (flutter/flutter#189390) 2026-07-17 [email protected] Roll Dart SDK from 0867cb1897b5 to 33e4b71e984b (1 revision) (flutter/flutter#189661) 2026-07-17 [email protected] Roll Skia from 702c3e790232 to 9468e96cc40f (1 revision) (flutter/flutter#189660) 2026-07-17 [email protected] Roll Packages from 9f95026 to 4fdc766 (11 revisions) (flutter/flutter#189659) 2026-07-17 [email protected] Roll Skia from 2e4a3ae035cd to 702c3e790232 (1 revision) (flutter/flutter#189655) 2026-07-17 [email protected] [macOS] Add FlutterPluginRegistrar.valuePublished(byPlugin:) just like iOS (flutter/flutter#189614) 2026-07-17 [email protected] Roll Dart SDK from c69d138c9646 to 0867cb1897b5 (2 revisions) (flutter/flutter#189649) 2026-07-17 [email protected] Roll Skia from 4f5aca109c87 to 2e4a3ae035cd (3 revisions) (flutter/flutter#189646) 2026-07-17 [email protected] [iOS] Fix missing nil checks and improve SemanticsObject bridge API (flutter/flutter#189630) 2026-07-17 [email protected] Roll Skia from 37c5e6b26aee to 4f5aca109c87 (19 revisions) (flutter/flutter#189638) 2026-07-17 [email protected] Roll Fuchsia Linux SDK from lLFbh5kFWbUGgC9Ek... to NL8xtzr8cxr5E8r8E... (flutter/flutter#189632) 2026-07-17 [email protected] Add blendMode parameter to RawImage and RenderImage (flutter/flutter#185938) 2026-07-17 [email protected] [web] Cache WASM network requests in flutter test (flutter/flutter#189623) 2026-07-17 [email protected] android_hardware_smoke_tests: Apply semantic line breaks to readme (flutter/flutter#189613) 2026-07-16 [email protected] ci(github-actions): resolve zizmor github-env findings in composite flutter actions (flutter/flutter#189602) 2026-07-16 [email protected] Remove obsolete packages analysis flag (flutter/flutter#189525) 2026-07-16 [email protected] Roll Dart SDK from e24870ff15bc to c69d138c9646 (2 revisions) (flutter/flutter#189597) 2026-07-16 [email protected] [fuchsia][iwyu] Remove transitive include of fuchsia.input.report. (flutter/flutter#188891) 2026-07-16 [email protected] Remove unnecessary value key hack in `key.dart` (flutter/flutter#189291) 2026-07-16 [email protected] Roll Dart SDK from 81306a2ed317 to e24870ff15bc (1 revision) (flutter/flutter#189569) 2026-07-16 [email protected] [Impeller] Fix atlas growth test (flutter/flutter#189533) 2026-07-16 [email protected] Roll Dart SDK from d402ff7c9c84 to 81306a2ed317 (4 revisions) (flutter/flutter#189558) 2026-07-16 [email protected] Move shared darwin plugin tests into own builder (flutter/flutter#189411) 2026-07-16 [email protected] Roll Skia from ab2410bc857c to 37c5e6b26aee (19 revisions) (flutter/flutter#189549) 2026-07-16 [email protected] [ios] Fix //flutter:unittests build for physical devices (flutter/flutter#189543) 2026-07-16 [email protected] Fix lower DragTarget not being recognized in overlapping targets (flutter/flutter#188979) 2026-07-16 [email protected] Relocate cupertino samples that were under widgets/ (flutter/flutter#188876) 2026-07-15 [email protected] [iOS] Migrate DisplayLinkManager to a shared instance (flutter/flutter#189492) 2026-07-15 [email protected] [Impeller] Call glfwTerminate during global test environment teardown if a playground test called glfwInit (flutter/flutter#189523) 2026-07-15 [email protected] Roll pub packages (flutter/flutter#189515) 2026-07-15 [email protected] Roll Packages from ad2eab1 to 9f95026 (8 revisions) (flutter/flutter#189509) 2026-07-15 [email protected] add `@nonVirtual` to `RenderObject.attached`, fix `WidgetTester.hasRunningAnimations` (flutter/flutter#186832) 2026-07-15 [email protected] Roll vulkan-deps to 0582f446e54a (flutter/flutter#188524) 2026-07-15 [email protected] Roll Dart SDK from 0c408ff6dce9 to d402ff7c9c84 (1 revision) (flutter/flutter#189497) If this roll has caused a breakage, revert this CL and stop the roller using the controls here: https://autoroll.skia.org/r/flutter-packages Please CC [email protected],[email protected] on the revert to ensure that a human is aware of the problem. ...
Follow-up to flutter#189492, which migrated `DisplayLinkManager` to a shared singleton instance. `VsyncWaiterIOS` picked up `.shared` directly in three places: once in the constructor when seeding the `FlutterVSyncClient` and `max_refresh_rate_`, and again on every `AwaitVSync()` poll. That's a hidden global dependency, and it meant the only way to exercise `VsyncWaiterIOS` against a non-default refresh rate was to mutate the real shared instance out from under whatever else might be touching it. This threads a `FlutterDisplayLinkManager*` through the constructor instead and stores it on `display_link_manager_`, so all three read sites go through the injected instance. `PlatformViewIOS::CreateVSyncWaiter()` is the only production callsite, and now passes `FlutterDisplayLinkManager.shared` explicitly rather than leaving it implicit. That callsite runs synchronously on the platform thread during `Shell::CreateShellOnPlatformThread`, which is the main thread in standard embedder usage, so this doesn't change anything about `DisplayLinkManager.init()`'s main-thread assertion on first access. While adding tests against the injected manager, found that `AwaitVSync()` had been made `private` by mistake in 722be07 — a stray `private:` label landed right above it while `SnapDuration` was being added, silently undoing the "Made public for testing" it's commented with (and had been since c05f179). Restored it to public. Added two tests to `VsyncWaiterIOSTest.mm` that construct a `VsyncWaiterIOS` against an `OCMPartialMock` of `FlutterDisplayLinkManager.shared`, matching the mocking pattern already used in `FlutterViewControllerTest.mm`. Both assert against a new `GetMaxRefreshRateForTesting()` accessor rather than the pre-existing `GetRefreshRate()`: the latter forwards to `client_.refreshRate`, which per `VariableRefreshRateReporter`'s contract reports the live *measured* refresh rate from actual vsync ticks, not the polled ceiling `AwaitVSync()` tracks. It's seeded from the constructor's `maxRefreshRate` param (which is why the naive version of the constructor test happened to pass), but `AwaitVSync()` never touches it — `[client_ setMaxRefreshRate:]` only reconfigures the display link's requested rate for future frames, so asserting against `GetRefreshRate()` after `AwaitVSync()` was asserting against a value nothing in this path updates. `GetMaxRefreshRateForTesting()` exposes `max_refresh_rate_` directly instead, following the same `GetXForTesting()` convention already used in `android_shell_holder.h`. One test confirms the constructor seeds `max_refresh_rate_` from the injected manager, the other confirms `AwaitVSync()` picks up a refresh-rate change on it. `testAwaitVSyncPicksUpRefreshRateChangesFromInjectedDisplayLinkManager` still failed on a real device run even after the above fix, because of a second, more subtle issue: re-stubbing `displayRefreshRate` on the same OCMock partial mock (once with the initial rate, again with the updated rate before calling `AwaitVSync()`) doesn't override the first stub. OCMock keeps every recorder registered against a mock and answers with the first one that matches, so the second `andReturnValue:` call was silently ignored and `displayRefreshRate` kept returning the initial value. This is made worse by the constructor itself reading `displayRefreshRate` twice (once for `FlutterVSyncClient`'s `maxRefreshRate:` param, once more for `max_refresh_rate_`), which also rules out modeling this with paired `expect` calls instead of `stub`. Replaced the two `stub`/`andReturnValue:` calls with a single `OCMStub(...).andDo(...)` that reads a `__block double` variable on each invocation, matching the pattern already used for dynamic return values elsewhere in this codebase (e.g. `FlutterKeyboardManagerTest.mm`). The test now mutates that variable directly between the "before" and "after" assertions instead of re-stubbing, which correctly reflects a value that changes over time. Issue: flutter#175879 Issue: flutter#181684
Follow-up to the same DisplayLinkManager singleton migration (flutter#189492) as the previous VsyncWaiterIOS change: FlutterKeyboardInsetManager read DisplayLinkManager.shared directly in setUpKeyboardAnimationVsyncClient when seeding its VSyncClient's isVariableRefreshRateEnabled and maxRefreshRate, the same hidden-global pattern. This threads a DisplayLinkManager through the initializer instead and stores it on a new displayLinkManager property, so that read goes through the injected instance. FlutterViewController.mm's performCommonViewControllerInitialization is the only production callsite, and now passes FlutterDisplayLinkManager.shared explicitly. FlutterKeyboardInsetManager isn't part of the public framework headers, so this is a self-contained change: the six construction sites in FlutterViewControllerTest.mm (none of which stub a specific refresh rate; they only assert on inset math or vsync-client presence) now pass FlutterDisplayLinkManager.shared too, preserving existing behaviour. FakeFlutterKeyboardInsetManager is untouched — it's an independent NSObject conforming to FlutterKeyboardInsetManagerProtocol rather than a subclass, so it never depended on this initializer. Issue: flutter#175879 Issue: flutter#181684
Follow-up to the same DisplayLinkManager singleton migration (flutter#189492) as the previous three changes in this series: FlutterMetalLayer read FlutterDisplayLinkManager.shared directly in four places when seeding and re-seeding its own CADisplayLink's preferred frame rate range. FlutterMetalLayer can't take a constructor parameter at all: it's a CALayer subclass, and every instance is created implicitly by UIKit via +layerClass returning [FlutterMetalLayer class] (see rendering_api_selection.mm and the various +layerClass overrides), which always calls the parameterless -init. There's no callsite anywhere in the tree that does [[FlutterMetalLayer alloc] init] directly, so unlike VsyncWaiterIOS/FlutterKeyboardInsetManager there's no initializer to extend, and unlike FlutterViewController there's no common-initialization method to fall back on either — just -init itself. This adds a _displayLinkManager ivar, seeded from FlutterDisplayLinkManager.shared at the top of -init, and switches the four reads (the initial setMaxRefreshRate:forceMax: call, the maxRefreshRateEnabledOnIPhone guard, and the two re-seeds in onDisplayLink: and presentOnMainThread:) to read from it instead. Deliberately did not add this as a property on the class, unlike displayLinkManager on FlutterViewController. FlutterMetalLayer.h's header comment states properties and methods "must exactly match those in the CAMetalLayer interface declaration" — it deliberately masquerades as CAMetalLayer (see -isKindOfClass:) for drop-in compatibility, so growing its public-ish surface with an unrelated property would violate that. The ivar lives in the private class extension in the .mm instead, matching _preferredDevice and _drawableSize alongside it. No test coverage previously existed for the DisplayLinkManager-derived refresh rate logic in FlutterMetalLayerTest.mm, so there's nothing to update there. Issue: flutter#175879 Issue: flutter#181684
Follow-up to the same DisplayLinkManager singleton migration (flutter#189492) as the two previous changes in this series: createTouchRateCorrectionVSyncClientIfNeeded read FlutterDisplayLinkManager.shared directly in three places when deciding whether to create the touch-rate-correction VSyncClient and seeding its isVariableRefreshRateEnabled/maxRefreshRate. FlutterViewController has too many public initializers to add a constructor parameter without breaking API, unlike VsyncWaiterIOS and FlutterKeyboardInsetManager. It already had the answer, though: keyboardInsetManager and accessibilityFeatures are declared as internal strong properties in FlutterViewController_Internal.h and assigned once in performCommonViewControllerInitialization, the single method every public initializer funnels through. This adds displayLinkManager following that same pattern, seeded from FlutterDisplayLinkManager.shared at init, and switches the three reads in createTouchRateCorrectionVSyncClientIfNeeded to self.displayLinkManager. _keyboardInsetManager's own construction now reuses the same captured _displayLinkManager ivar instead of a second .shared read. No test changes needed. The five existing tests that OCMPartialMock([FlutterDisplayLinkManager shared]) construct that mock before constructing the view controller, and OCMPartialMock swizzles the live singleton's dispatch table in place rather than replacing the object, so self.displayLinkManager — captured at construction time — still points at the same (now-mocked) instance those tests stub. Issue: flutter#175879 Issue: flutter#181684
This is a follow-up to flutter#189492, which migrated `DisplayLinkManager` to a shared singleton instance. `VsyncWaiterIOS` was using `.shared` directly in three places internally, which is effectively using a hidden global, and it means the only way to exercise `VsyncWaiterIOS` against a non-default refresh rate was to mutate the real shared instance out from under whatever else might be touching it. This wires a `FlutterDisplayLinkManager*` through the constructor instead and stores a private reference. I've also added two tests, which required making `AwaitVSync()` accessible again; I accidentally marked it private in flutter#186935 (722be07) when adding SnapDuration (despite my comment saying it was made public for testing), so restored it to public. Issue: flutter#175879 Issue: flutter#181684
This is a follow-up to flutter#189492, which migrated `DisplayLinkManager` to a shared singleton instance. `VsyncWaiterIOS` was using `.shared` directly in three places internally, which is effectively using a hidden global, and it means the only way to exercise `VsyncWaiterIOS` against a non-default refresh rate was to mutate the real shared instance out from under whatever else might be touching it. This wires a `FlutterDisplayLinkManager*` through the constructor instead and stores a private reference. I've also added two tests, which required making `AwaitVSync()` accessible again; I accidentally marked it private in flutter#186935 (722be07) when adding SnapDuration (despite my comment saying it was made public for testing), so restored it to public. Issue: flutter#175879 Issue: flutter#181684
This is a follow-up to the DisplayLinkManager singleton migration (flutter#189492). `FlutterKeyboardInsetManager` was reading `DisplayLinkManager.shared` directly in `setUpKeyboardAnimationVsyncClient` when passing its VSyncClient's isVariableRefreshRateEnabled and maxRefreshRate, which is effectively using a global. This wires a DisplayLinkManager through the initializer instead and stores it in an instance var, so that read goes through the injected instance. I didn't need to touch FakeFlutterKeyboardInsetManager. It's an independent NSObject that conforms to FlutterKeyboardInsetManagerProtocol and not a subclass, so it never depended on this initializer. Issue: flutter#175879 Issue: flutter#181684
Follow-up to the DisplayLinkManager singleton migration (flutter#189492). FlutterMetalLayer read FlutterDisplayLinkManager.shared directly in four places when seeding and re-seeding its own CADisplayLink's preferred frame rate range. FlutterMetalLayer can't take a constructor parameter since it's a CALayer subclass, and every instance is created implicitly by UIKit via `+layerClass` returning `[FlutterMetalLayer class]` (see rendering_api_selection.mm and the various +layerClass overrides), which always calls the parameterless `-init`. There's no callsite anywhere in the tree that does `[[FlutterMetalLayer alloc] init]` directly, so unlike VsyncWaiterIOS/FlutterKeyboardInsetManager there's no initializer to extend, and no common-initialization method to fall back on; just -init itself. This adds a _displayLinkManager ivar, which we wire up from FlutterDisplayLinkManager.shared at the top of -init, and switches the four reads (the initial setMaxRefreshRate:forceMax: call, the maxRefreshRateEnabledOnIPhone guard, and the two re-seeds in onDisplayLink: and presentOnMainThread:) to read from it instead. I deliberately didn't add this as a property on the class: FlutterMetalLayer.h's header comment states properties and methods "must exactly match those in the CAMetalLayer interface declaration" — it deliberately masquerades as CAMetalLayer (which you can see in -isKindOfClass:), so there's no point in increasing its API surface with this. Added the ivar in a private class extension alongside the existing _preferredDevice and _drawableSize. No semantic change, just a refactoring, so no change to the tests. Issue: flutter#175879 Issue: flutter#181684
This is a follow-up to flutter#189492, which migrated `DisplayLinkManager` to a shared singleton instance. `VsyncWaiterIOS` was using `.shared` directly in three places internally, which is effectively using a hidden global, and it means the only way to exercise `VsyncWaiterIOS` against a non-default refresh rate was to mutate the real shared instance out from under whatever else might be touching it. This wires a `FlutterDisplayLinkManager*` through the constructor instead and stores a private reference. I've also added two tests, which required making `AwaitVSync()` accessible again; I accidentally marked it private in flutter#186935 (722be07) when adding SnapDuration (despite my comment saying it was made public for testing), so restored it to public. Issue: flutter#175879 Issue: flutter#181684 <!-- Thanks for filing a pull request! Reviewers are typically assigned within a week of filing a request. To learn more about code review, see our documentation on Tree Hygiene: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md --> ## Pre-launch Checklist - [X] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [X] I read the [AI contribution guidelines] and understand my responsibilities, or I am not using AI tools. - [X] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [X] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [X] I signed the [CLA]. - [X] I listed at least one issue that this PR fixes in the description above. - [X] I updated/added relevant documentation (doc comments with `///`). - [X] I added new tests to check the change I am making, or this PR is [test-exempt]. - [X] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [X] All existing and new tests are passing. 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](https://developers.google.com/gemini-code-assist/docs/review-github-code). 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. <!-- Links --> [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [AI contribution guidelines]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#ai-contribution-guidelines [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
…tter#189751) This is a follow-up to the DisplayLinkManager singleton migration (flutter#189492). `FlutterKeyboardInsetManager` was reading `DisplayLinkManager.shared` directly in `setUpKeyboardAnimationVsyncClient` when passing its VSyncClient's isVariableRefreshRateEnabled and maxRefreshRate, which is effectively using a global. This wires a DisplayLinkManager through the initializer instead and stores it in an instance var, so that read goes through the injected instance. I didn't need to touch FakeFlutterKeyboardInsetManager. It's an independent NSObject that conforms to FlutterKeyboardInsetManagerProtocol and not a subclass, so it never depended on this initializer. This is a refactoring so introduces no semantic changes. I've updated tests to use the new pattern but there's no new functionality to test. Issue: flutter#175879 Issue: flutter#181684 <!-- Thanks for filing a pull request! Reviewers are typically assigned within a week of filing a request. To learn more about code review, see our documentation on Tree Hygiene: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md --> ## Pre-launch Checklist - [X] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [X] I read the [AI contribution guidelines] and understand my responsibilities, or I am not using AI tools. - [X] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [X] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [X] I signed the [CLA]. - [X] I listed at least one issue that this PR fixes in the description above. - [X] I updated/added relevant documentation (doc comments with `///`). - [X] I added new tests to check the change I am making, or this PR is [test-exempt]. - [X] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [X] All existing and new tests are passing. 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](https://developers.google.com/gemini-code-assist/docs/review-github-code). 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. <!-- Links --> [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [AI contribution guidelines]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#ai-contribution-guidelines [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
Follow-up to DisplayLinkManager singleton migration (flutter#189492). createTouchRateCorrectionVSyncClientIfNeeded read FlutterDisplayLinkManager.shared directly in three places when deciding whether to create the touch-rate-correction VSyncClient and seeding its isVariableRefreshRateEnabled/maxRefreshRate. FlutterViewController has too many public initializers to add a constructor parameter without breaking API. However, we can wire this up similarly to how we declare keyboardInsetManager and accessibilityFeatures as internal strong properties in FlutterViewController_Internal.h and assign in performCommonViewControllerInitialization. We now set FlutterDisplayLinkManager.shared at init, and migrate the existing uses of the static shared singleton to reads of this property. No changes to tests because there are no semantic changes; this is covered by existing tests. Issue: flutter#175879 Issue: flutter#181684
Follow-up to the DisplayLinkManager singleton migration (flutter#189492). FlutterMetalLayer read FlutterDisplayLinkManager.shared directly in four places when seeding and re-seeding its own CADisplayLink's preferred frame rate range. FlutterMetalLayer can't take a constructor parameter since it's a CALayer subclass, and every instance is created implicitly by UIKit via `+layerClass` returning `[FlutterMetalLayer class]` (see rendering_api_selection.mm and the various +layerClass overrides), which always calls the parameterless `-init`. There's no callsite anywhere in the tree that does `[[FlutterMetalLayer alloc] init]` directly, so unlike VsyncWaiterIOS/FlutterKeyboardInsetManager there's no initializer to extend, and no common-initialization method to fall back on; just -init itself. This adds a _displayLinkManager ivar, which we wire up from FlutterDisplayLinkManager.shared at the top of -init, and switches the four reads (the initial setMaxRefreshRate:forceMax: call, the maxRefreshRateEnabledOnIPhone guard, and the two re-seeds in onDisplayLink: and presentOnMainThread:) to read from it instead. I deliberately didn't add this as a property on the class: FlutterMetalLayer.h's header comment states properties and methods "must exactly match those in the CAMetalLayer interface declaration" — it deliberately masquerades as CAMetalLayer (which you can see in -isKindOfClass:), so there's no point in increasing its API surface with this. Added the ivar in a private class extension alongside the existing _preferredDevice and _drawableSize. No semantic change, just a refactoring, so no change to the tests. Issue: flutter#175879 Issue: flutter#181684 <!-- Thanks for filing a pull request! Reviewers are typically assigned within a week of filing a request. To learn more about code review, see our documentation on Tree Hygiene: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md --> ## Pre-launch Checklist - [X] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [X] I read the [AI contribution guidelines] and understand my responsibilities, or I am not using AI tools. - [X] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [X] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [X] I signed the [CLA]. - [X] I listed at least one issue that this PR fixes in the description above. - [X] I updated/added relevant documentation (doc comments with `///`). - [X] I added new tests to check the change I am making, or this PR is [test-exempt]. - [X] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [X] All existing and new tests are passing. 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](https://developers.google.com/gemini-code-assist/docs/review-github-code). 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. <!-- Links --> [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [AI contribution guidelines]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#ai-contribution-guidelines [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
…89764) Follow-up to DisplayLinkManager singleton migration (flutter#189492). createTouchRateCorrectionVSyncClientIfNeeded read FlutterDisplayLinkManager.shared directly in three places when deciding whether to create the touch-rate-correction VSyncClient and seeding its isVariableRefreshRateEnabled/maxRefreshRate. FlutterViewController has too many public initializers to add a constructor parameter without breaking API. However, we can wire this up similarly to how we declare keyboardInsetManager and accessibilityFeatures as internal strong properties in FlutterViewController_Internal.h and assign in performCommonViewControllerInitialization. We now set FlutterDisplayLinkManager.shared at init, and migrate the existing uses of the static shared singleton to reads of this property. No changes to tests because there are no semantic changes; this is covered by existing tests. Issue: flutter#175879 Issue: flutter#181684 <!-- Thanks for filing a pull request! Reviewers are typically assigned within a week of filing a request. To learn more about code review, see our documentation on Tree Hygiene: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md --> ## Pre-launch Checklist - [X] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [X] I read the [AI contribution guidelines] and understand my responsibilities, or I am not using AI tools. - [X] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [X] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [X] I signed the [CLA]. - [X] I listed at least one issue that this PR fixes in the description above. - [X] I updated/added relevant documentation (doc comments with `///`). - [X] I added new tests to check the change I am making, or this PR is [test-exempt]. - [X] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [X] All existing and new tests are passing. 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](https://developers.google.com/gemini-code-assist/docs/review-github-code). 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. <!-- Links --> [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [AI contribution guidelines]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#ai-contribution-guidelines [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
Previously,
DisplayLinkManagerwas a stateless static utility class that queriedBundle.mainandUIScreen.maindirectly on every access. Under Swift 6 strict concurrency this produces data-race warnings, and nothing guaranteed these accessors weren't being called from a background engine thread, which would be a UIKit thread-safety violation.This migrates
DisplayLinkManagerto afinal classshared singleton conforming to@unchecked Sendable. All callers now go throughFlutterDisplayLinkManager.shared.Max refresh rate
maxRefreshRateEnabledOnIPhoneis plist-derived and doesn't change during a process's lifetime, so we cache it once at initialisation in an immutable field.Current refresh rate
displayRefreshRateis a bit more complex:VsyncWaiterIOS::AwaitVSyncpolls it on every vsync specifically to detect refresh-rate changes at runtime (e.g. Low Power Mode or thermal throttling limiting refresh rate to 60 Hz even on ProMotion devices), so caching it would break that detection. Instead,displayRefreshRateis a computed property backed by a lock-protected cache, following the same pattern already used inResizeSynchronizer/FlutterRunLoopon macOS.There's no documented, exhaustive list of everything that can change the device's effective max refresh rate, so we keep the cached value up to date in two ways:
NSProcessInfoPowerStateDidChange, thermal throttling viaProcessInfo.thermalStateDidChangeNotification, and screen mode changes viaUIScreen.modeDidChangeNotification); andUIApplication.didBecomeActiveNotificationas a sort of forcing function in case there's any other trigger we don't explicitly know about.Background threads now get a cheap, lock-guarded read instead of touching
UIScreen.maindirectly, while the cached value is kept up to date in a couple different ways.The cache-update code lives in
updateCachedDisplayRefreshRate(_:). This isinternalsince it's used both by the real notification handlers as well as in a unit test I've added to verify the locking/storage behaviour directly rather than requiring us to post real notifications and hopeUIScreen.main's reported value happens to change in the test host.Test updates
FlutterViewControllerTestpreviously stubbeddisplayRefreshRateas a class method viaOCMockObject mockForClass:. Since it's now a property on the shared instance, those tests now useOCMPartialMock([FlutterDisplayLinkManager shared])instead.Preferred frame rate probing cleanup
Finally, this deletes the
CADisplayLinktarget-selector probing in the olddisplayRefreshRate.That code created a throwaway
CADisplayLink(target:selector:), setisPaused = true, then read backpreferredFramesPerSecondbefore discarding it.preferredFramesPerSecondis a property you set to request a rate; its documented default is0until something explicitly assigns it, and this link was never added to a run loop, so nothing ever could. Theif preferredFPS != 0branch was therefore unreachable, and the function always fell through toUIScreen.main.maximumFramesPerSecondregardless. As a result, the display link was constructed, queried, and thrown away on every single call for no effect on the result.It's worth noting that this is a different property from
CADisplayLink.timestamp/targetTimestamp, which do have documented first-frame-is-zero semantics and are already guarded independently inVSyncClient.onDisplayLink(see #186457) and again in the C++ code inVsyncWaiterIOS::SnapDuration.preferredFramesPerSecond's zero-ness here had nothing to do with frame timing, only with the fact that nobody had ever set it. Since this was on the read path for a property that's polled up to ~120 times/second byVsyncWaiterIOS::AwaitVSync, it meant allocating and discarding aQuartzCoreobject on every vsync for a value that could never differ from a plainUIScreen.main.maximumFramesPerSecondread.The new code does a single read, at construction, and lets the caching/observation machinery described above keep it current.
Configurable preferred-FPS support is tracked separately in #185759.
Swift exported symbols check
Also adds support for Swift static
letproperties (prefix_$s) to the(__DATA,__common)section of exported binaries.Issue: #175879
Issue: #181684
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.