Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions engine/src/flutter/shell/platform/darwin/ios/BUILD.gn
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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.

Not an actual change but turns out we were past 80 cols which upset the format presubmit.

shared_library("ios_test_flutter") {
testonly = true
visibility = [ "*" ]
Expand Down Expand Up @@ -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",
Copy link
Copy Markdown
Member Author

@cbracken cbracken May 22, 2026

Choose a reason for hiding this comment

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

Yes, the filename is inconsistent with the implementation file, but the test is an Obj-C XCTest and the class under test is a C++ class, hence the difference in naming.

"framework/Source/accessibility_bridge_test.mm",
"framework/Source/availability_version_check_test.mm",
"ios_context_noop_unittests.mm",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
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.

Note that this was adjusted since FlutterVSyncClient is now pure Obj-C and sticks to CoreAnimation APIs (along with the rest of the embedder now).

self.originalViewInsetBottom = currentInset;

// Invalidate old vsync client if old animation is not completed.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,20 @@
// found in the LICENSE file.

#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterVSyncClient.h"
#include "flutter/shell/common/vsync_waiter.h"

#import <UIKit/UIKit.h>

#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

NSString* const kCADisableMinimumFrameDurationOnPhoneKey = @"CADisableMinimumFrameDurationOnPhone";
static const double kDefaultRefreshRate = 60.0;

@implementation FlutterVSyncClient {
flutter::VsyncWaiter::Callback _callback;
void (^_callback)(CFTimeInterval startTime, CFTimeInterval targetTime);
CADisplayLink* _displayLink;
BOOL _isVariableRefreshRateEnabled;
}
Expand All @@ -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<fml::TaskRunner> 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<flutter::FrameTimingsRecorder> 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;

Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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<flutter::FrameTimingsRecorder> recorder =
std::make_unique<flutter::FrameTimingsRecorder>();
[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
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <XCTest/XCTest.h>

#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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
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.

The core engine deals in fml::TimePoints which are based on std::chrono::steady_clock which is backed by mach_continuous_time, whereas CoreAnimation uses mach_absolute_time.

Consistent with embedders on the embedder API, we adjust the epoch for start and target time here.

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.

The core engine deals in fml::TimePoints which are based on std::chrono::steady_clock which is backed by mach_continuous_time, whereas CoreAnimation uses mach_absolute_time.

This comment is helpful. can you add this info to the comment?

FireCallback(start_time, target_time, true);
};
FlutterFMLTaskRunner* uiTaskRunner =
Expand Down Expand Up @@ -52,4 +65,13 @@
return client_.refreshRate;
}

CFTimeInterval VsyncWaiterIOS::SnapDuration(CFTimeInterval duration, double max_refresh_rate) {
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.

what's "snap duration"?

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 comment on the method: it snaps the duration to a round number of Hz. Due to floating point error or tiny inconsistencies in timing the duration may be off by a few microseconds etc, so we compute the inverse (frequency in Hz) snap to the closes integer Hz (60, 120, etc. whatever the nearest int value of Hz is), then adjust the time to keep the frame timing from drifting)

We were doing this before but decided it'd be better to put it in a named method with a doc comment.

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
Loading