From 939e756b6b7f5d3f257ea434c241b551281243c9 Mon Sep 17 00:00:00 2001 From: Chris Bracken Date: Fri, 22 May 2026 17:17:01 +0900 Subject: [PATCH 1/2] iOS] Migrate VSyncClient to a pure Obj-C implementation (#186166) Migrates `FlutterVSyncClient` from a hybrid C++/Obj-C implementation to a pure Objective-C implementation by removing C++ types from its interface and implementation. Previously, `FlutterVSyncClient` stored a C++ `flutter::VsyncWaiter::Callback` and used a temporary `std::unique_ptr` to propagate vsync timestamps. This was redundant: * `FlutterVSyncClient` allocated a temporary `FrameTimingsRecorder` and recorded vsync times. * It passed this to a C++ lambda wrapper. * The lambda extracted the start/target times as seconds and passed them to the Obj-C block callback. * The temporary recorder was immediately discarded. * `VsyncWaiterIOS` converted the seconds back to `fml::TimePoint`. * `VsyncWaiter::FireCallback` allocated *another* `FrameTimingsRecorder` and recorded the same times again. We now: * manage the callback as pure Obj-C block that accepts raw `CFTimeInterval` (double seconds) directly from `CADisplayLink`. * Remove the redundant `FrameTimingsRecorder` and C++ lambda wrapper. * Rely solely on `VsyncWaiter::FireCallback` to handle the final `FrameTimingsRecorder` creation and recording, which it already does. No test changes because this introduces no semantic change and is covered by existing tests recently added in: https://github.com/flutter/flutter/pull/186457 Issue: https://github.com/flutter/flutter/issues/112232 --- .../Source/FlutterKeyboardInsetManager.mm | 2 +- .../framework/Source/FlutterVSyncClient.mm | 36 ++++++------------- .../ios/framework/Source/vsync_waiter_ios.mm | 21 +++++++++-- 3 files changed, 30 insertions(+), 29 deletions(-) diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterKeyboardInsetManager.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterKeyboardInsetManager.mm index 999c9f82ae79b..951cce857fe3e 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterKeyboardInsetManager.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterKeyboardInsetManager.mm @@ -273,7 +273,7 @@ - (void)startKeyBoardAnimation:(NSTimeInterval)duration { // Set animation begin value and DisplayLink tracking values. CGFloat currentInset = delegate.physicalViewInsetBottom; self.keyboardAnimationView.frame = CGRectMake(0, currentInset, 0, 0); - self.keyboardAnimationStartTime = fml::TimePoint::Now().ToEpochDelta().ToSecondsF(); + self.keyboardAnimationStartTime = CACurrentMediaTime(); self.originalViewInsetBottom = currentInset; // Invalidate old vsync client if old animation is not completed. diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterVSyncClient.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterVSyncClient.mm index ddd48e8f86aa1..d267984e1a3ee 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterVSyncClient.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterVSyncClient.mm @@ -3,13 +3,12 @@ // found in the LICENSE file. #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterVSyncClient.h" -#include "flutter/shell/common/vsync_waiter.h" #import #import "flutter/shell/platform/darwin/common/framework/Headers/FlutterMacros.h" #import "flutter/shell/platform/darwin/common/framework/Source/FlutterTracing.h" -#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterFMLTaskRunner+FML.h" +#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterFMLTaskRunner.h" FLUTTER_ASSERT_ARC @@ -17,7 +16,7 @@ static const double kDefaultRefreshRate = 60.0; @implementation FlutterVSyncClient { - flutter::VsyncWaiter::Callback _callback; + void (^_callback)(CFTimeInterval startTime, CFTimeInterval targetTime); CADisplayLink* _displayLink; BOOL _isVariableRefreshRateEnabled; } @@ -27,20 +26,14 @@ - (instancetype)initWithTaskRunner:(FlutterFMLTaskRunner*)taskRunner maxRefreshRate:(double)maxRefreshRate callback:(void (^)(CFTimeInterval startTime, CFTimeInterval targetTime))callback { - FML_DCHECK(callback); - FML_DCHECK(taskRunner); - fml::RefPtr task_runner = taskRunner.taskRunner; - FML_DCHECK(task_runner); + NSAssert(callback, @"callback must not be nil"); + NSAssert(taskRunner, @"taskRunner must not be nil"); if (self = [super init]) { _refreshRate = maxRefreshRate; _isVariableRefreshRateEnabled = isVariableRefreshRateEnabled; _allowPauseAfterVsync = YES; - _callback = [callback](std::unique_ptr recorder) { - double start_time_seconds = recorder->GetVsyncStartTime().ToEpochDelta().ToSecondsF(); - double target_time_seconds = recorder->GetVsyncTargetTime().ToEpochDelta().ToSecondsF(); - callback(start_time_seconds, target_time_seconds); - }; + _callback = callback; _displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(onDisplayLink:)]; _displayLink.paused = YES; @@ -49,12 +42,12 @@ - (instancetype)initWithTaskRunner:(FlutterFMLTaskRunner*)taskRunner // Capture a weak reference to self to ensure we don't add the display link // to the run loop if the client has already been deallocated. __weak FlutterVSyncClient* weakSelf = self; - task_runner->PostTask([weakSelf]() { + [taskRunner postTask:^{ FlutterVSyncClient* strongSelf = weakSelf; if (strongSelf) { [strongSelf.displayLink addToRunLoop:NSRunLoop.currentRunLoop forMode:NSRunLoopCommonModes]; } - }); + }]; } return self; @@ -96,8 +89,6 @@ - (void)onDisplayLink:(CADisplayLink*)link { if (timestamp == 0.0) { timestamp = CACurrentMediaTime(); } - CFTimeInterval delay = CACurrentMediaTime() - timestamp; - fml::TimePoint frame_start_time = fml::TimePoint::Now() - fml::TimeDelta::FromSecondsF(delay); // targetTimestamp is the anticipated presentation time of the next screen refresh. If // targetTimestamp is zero or less than/equal to timestamp (which also occurs on paused/unpaused @@ -108,13 +99,8 @@ - (void)onDisplayLink:(CADisplayLink*)link { targetTimestamp = timestamp + (1.0 / effectiveRefreshRate); } CFTimeInterval duration = targetTimestamp - timestamp; - fml::TimePoint frame_target_time = frame_start_time + fml::TimeDelta::FromSecondsF(duration); - [FlutterTracing tracePlatformVsyncWithStartTime:frame_start_time.ToEpochDelta().ToSecondsF() - targetTime:frame_target_time.ToEpochDelta().ToSecondsF()]; - - std::unique_ptr recorder = - std::make_unique(); + [FlutterTracing tracePlatformVsyncWithStartTime:timestamp targetTime:targetTimestamp]; // In steady-state, duration reflects the hardware refresh interval (e.g., ~0.01667s for 60Hz). // We dynamically recalculate the refresh rate from the frame duration to adjust to ProMotion @@ -129,12 +115,10 @@ - (void)onDisplayLink:(CADisplayLink*)link { } } - recorder->RecordVsync(frame_start_time, frame_target_time); - if (_allowPauseAfterVsync) { link.paused = YES; } - _callback(std::move(recorder)); + _callback(timestamp, targetTimestamp); } - (void)dealloc { @@ -168,7 +152,7 @@ + (double)displayRefreshRate { CADisplayLink* displayLink = [CADisplayLink displayLinkWithTarget:[[[self class] alloc] init] selector:@selector(onDisplayLink:)]; displayLink.paused = YES; - auto preferredFPS = displayLink.preferredFramesPerSecond; + NSInteger preferredFPS = displayLink.preferredFramesPerSecond; // From Docs: // The default value for preferredFramesPerSecond is 0. When this value is 0, the preferred diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/vsync_waiter_ios.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/vsync_waiter_ios.mm index 09a015ee52719..9ed5cc19b136f 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/vsync_waiter_ios.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/vsync_waiter_ios.mm @@ -18,8 +18,25 @@ VsyncWaiterIOS::VsyncWaiterIOS(const flutter::TaskRunners& task_runners) : VsyncWaiter(task_runners) { auto vsyncCallback = ^(CFTimeInterval startTime, CFTimeInterval targetTime) { - fml::TimePoint start_time = fml::TimePoint() + fml::TimeDelta::FromSecondsF(startTime); - fml::TimePoint target_time = fml::TimePoint() + fml::TimeDelta::FromSecondsF(targetTime); + // Compute delay using the same CACurrentMediaTime() clock. + CFTimeInterval delay = CACurrentMediaTime() - startTime; + if (delay < 0.0) { + delay = 0.0; + } + + // Align the start time to the C++ steady_clock used by fml::TimePoint. + fml::TimePoint start_time = fml::TimePoint::Now() - fml::TimeDelta::FromSecondsF(delay); + + // Guard against division-by-zero when duration is 0.0, and avoid + // frame timing inconsistencies due to floating point error by + // rounding to nearest Hz value. + CFTimeInterval duration = targetTime - startTime; + if (duration <= 0.0) { + duration = 1.0 / max_refresh_rate_; // Synthesize safe default frame interval + } + + // Align target time to the C++ steady_clock used by fml::TimePoint. + fml::TimePoint target_time = start_time + fml::TimeDelta::FromSecondsF(duration); FireCallback(start_time, target_time, true); }; FlutterFMLTaskRunner* uiTaskRunner = From 6fd064bb5617e6548a0da0fd83381a78632639e0 Mon Sep 17 00:00:00 2001 From: Chris Bracken Date: Fri, 22 May 2026 18:54:25 +0900 Subject: [PATCH 2/2] Address review feedback --- .../shell/platform/darwin/ios/BUILD.gn | 5 ++- .../framework/Source/VsyncWaiterIOSTest.mm | 45 +++++++++++++++++++ .../ios/framework/Source/vsync_waiter_ios.h | 13 ++++++ .../ios/framework/Source/vsync_waiter_ios.mm | 19 +++++--- 4 files changed, 73 insertions(+), 9 deletions(-) create mode 100644 engine/src/flutter/shell/platform/darwin/ios/framework/Source/VsyncWaiterIOSTest.mm diff --git a/engine/src/flutter/shell/platform/darwin/ios/BUILD.gn b/engine/src/flutter/shell/platform/darwin/ios/BUILD.gn index 35a50dbf02450..43d14cac0e68c 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/BUILD.gn +++ b/engine/src/flutter/shell/platform/darwin/ios/BUILD.gn @@ -274,8 +274,8 @@ if (enable_ios_unittests) { # considerably slower to start than the tests in # "//flutter/shell/platform/darwin/common:framework_common_swift_unittests". # If your tests do not have to be run on a simulator (no UIKit dependencies - # for example), consider adding them to the ":framework_common_swift_unittests" - # target. + # for example), consider adding them to the + # ":framework_common_swift_unittests" target. shared_library("ios_test_flutter") { testonly = true visibility = [ "*" ] @@ -322,6 +322,7 @@ if (enable_ios_unittests) { "framework/Source/SemanticsObjectTest.mm", "framework/Source/SemanticsObjectTestMocks.h", "framework/Source/UIViewController_FlutterScreenAndSceneIfLoadedTest.mm", + "framework/Source/VsyncWaiterIOSTest.mm", "framework/Source/accessibility_bridge_test.mm", "framework/Source/availability_version_check_test.mm", "ios_context_noop_unittests.mm", diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/VsyncWaiterIOSTest.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/VsyncWaiterIOSTest.mm new file mode 100644 index 0000000000000..beb86d1603328 --- /dev/null +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/VsyncWaiterIOSTest.mm @@ -0,0 +1,45 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import + +#import "flutter/shell/platform/darwin/ios/framework/Source/vsync_waiter_ios.h" + +@interface VsyncWaiterIOSTest : XCTestCase +@end + +@implementation VsyncWaiterIOSTest + +- (void)testSnapDurationWithValidDuration { + // 60Hz: 1/60 = 0.016666... + CFTimeInterval duration = 0.016667; + CFTimeInterval snapped = flutter::VsyncWaiterIOS::SnapDuration(duration, 60.0); + XCTAssertEqualWithAccuracy(snapped, 1.0 / 60.0, 0.0001); + + // 120Hz: 1/120 = 0.008333... + duration = 0.008334; + snapped = flutter::VsyncWaiterIOS::SnapDuration(duration, 120.0); + XCTAssertEqualWithAccuracy(snapped, 1.0 / 120.0, 0.0001); +} + +- (void)testSnapDurationWithInvalidDuration { + // Zero duration should fallback to max_refresh_rate. + CFTimeInterval snapped = flutter::VsyncWaiterIOS::SnapDuration(0.0, 120.0); + XCTAssertEqualWithAccuracy(snapped, 1.0 / 120.0, 0.0001); + + // Negative duration should fallback to max_refresh_rate. + snapped = flutter::VsyncWaiterIOS::SnapDuration(-0.1, 80.0); + XCTAssertEqualWithAccuracy(snapped, 1.0 / 80.0, 0.0001); +} + +- (void)testSnapDurationWithZeroMaxRefreshRateFallback { + // If duration is invalid AND max_refresh_rate is 0, fallback to 60Hz. + CFTimeInterval snapped = flutter::VsyncWaiterIOS::SnapDuration(0.0, 0.0); + XCTAssertEqualWithAccuracy(snapped, 1.0 / 60.0, 0.0001); + + snapped = flutter::VsyncWaiterIOS::SnapDuration(-1.0, -10.0); + XCTAssertEqualWithAccuracy(snapped, 1.0 / 60.0, 0.0001); +} + +@end diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/vsync_waiter_ios.h b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/vsync_waiter_ios.h index 884c49150ed64..15a68a4b9850f 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/vsync_waiter_ios.h +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/vsync_waiter_ios.h @@ -22,6 +22,19 @@ class VsyncWaiterIOS final : public VsyncWaiter, // |VariableRefreshRateReporter| double GetRefreshRate() const override; + // @brief Snaps the duration to the nearest whole Hz value and provides safe + // fallbacks. This ensures we don't introduce frame timing issues due + // to floating point error. e.g. + // 59.998, 60.004, 59.995, ... --> 60.000 + // + // Additionally, guards against divide-by-zero and non-positive + // durations, which can occur on paused/unpaused transitions. + // + // Visible for testing. + static CFTimeInterval SnapDuration(CFTimeInterval duration, + double max_refresh_rate); + + private: // |VsyncWaiter| // Made public for testing. void AwaitVSync() override; diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/vsync_waiter_ios.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/vsync_waiter_ios.mm index 9ed5cc19b136f..b653269f2e75e 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/vsync_waiter_ios.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/vsync_waiter_ios.mm @@ -27,13 +27,9 @@ // Align the start time to the C++ steady_clock used by fml::TimePoint. fml::TimePoint start_time = fml::TimePoint::Now() - fml::TimeDelta::FromSecondsF(delay); - // Guard against division-by-zero when duration is 0.0, and avoid - // frame timing inconsistencies due to floating point error by - // rounding to nearest Hz value. - CFTimeInterval duration = targetTime - startTime; - if (duration <= 0.0) { - duration = 1.0 / max_refresh_rate_; // Synthesize safe default frame interval - } + // Snap to the nearest whole Hz value to avoid floating point errors. + CFTimeInterval duration = + VsyncWaiterIOS::SnapDuration(targetTime - startTime, max_refresh_rate_); // Align target time to the C++ steady_clock used by fml::TimePoint. fml::TimePoint target_time = start_time + fml::TimeDelta::FromSecondsF(duration); @@ -69,4 +65,13 @@ return client_.refreshRate; } +CFTimeInterval VsyncWaiterIOS::SnapDuration(CFTimeInterval duration, double max_refresh_rate) { + if (duration > 0.0) { + double roundedRefreshRate = round(1.0 / duration); + return 1.0 / roundedRefreshRate; + } + double fallbackRefreshRate = max_refresh_rate > 0.0 ? max_refresh_rate : 60.0; + return 1.0 / fallbackRefreshRate; +} + } // namespace flutter