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/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/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 09a015ee52719..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 @@ -18,8 +18,21 @@ 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); + + // 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); FireCallback(start_time, target_time, true); }; FlutterFMLTaskRunner* uiTaskRunner = @@ -52,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