macOS: port ResizeSynchronizer to Swift (#168959)

This patch:
* migrates FlutterResizeSynchronizer from Objective-C to Swift.
* reorders the performCommit parameters to support trailing closure
syntax in Swift.
* adds an optional onTimeout parameter to
ResizeSynchronizer.beginResize.
* adds test coverage for the timeout case.
* significantly improves the class documentation.
* adds the first Swift Testing tests for the macOS embedder.
* adds the new tests to run_test.py.

Admittedly, the original purpose was just to add a class that we could
write Swift tests for, but things got a bit out of hand.

Issue: https://github.com/flutter/flutter/issues/168564
Issue: https://github.com/flutter/flutter/issues/144791

## Pre-launch Checklist

- [X] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [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].

<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[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
This commit is contained in:
Chris Bracken 2025-05-18 12:07:08 -07:00 committed by GitHub
parent a682cf27b2
commit e27377ef4d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 416 additions and 250 deletions

View File

@ -269,7 +269,10 @@ group("unittests") {
}
if (is_mac) {
public_deps += [ "//flutter/shell/platform/darwin/macos:flutter_desktop_darwin_unittests" ]
public_deps += [
"//flutter/shell/platform/darwin/macos:flutter_desktop_darwin_swift_unittests",
"//flutter/shell/platform/darwin/macos:flutter_desktop_darwin_unittests",
]
}
if (is_win) {

View File

@ -52962,9 +52962,6 @@ ORIGIN: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterPla
ORIGIN: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterPlatformViewControllerTest.mm + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterRenderer.h + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterRenderer.mm + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterResizeSynchronizer.h + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterResizeSynchronizer.mm + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterResizeSynchronizerTest.mm + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterRunLoop.swift + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterSurface.h + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterSurface.mm + ../../../flutter/LICENSE
@ -52999,6 +52996,8 @@ ORIGIN: ../../../flutter/shell/platform/darwin/macos/framework/Source/KeyCodeMap
ORIGIN: ../../../flutter/shell/platform/darwin/macos/framework/Source/KeyCodeMapTest.mm + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/shell/platform/darwin/macos/framework/Source/KeyCodeMap_Internal.h + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/shell/platform/darwin/macos/framework/Source/NSView+ClipsToBounds.h + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/shell/platform/darwin/macos/framework/Source/ResizeSynchronizer.swift + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/shell/platform/darwin/macos/framework/Source/ResizeSynchronizerTest.swift + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/shell/platform/darwin/macos/framework/Source/TestFlutterPlatformView.h + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/shell/platform/darwin/macos/framework/Source/TestFlutterPlatformView.mm + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/shell/platform/embedder/embedder.cc + ../../../flutter/LICENSE
@ -55994,9 +55993,6 @@ FILE: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterPlatf
FILE: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterPlatformViewControllerTest.mm
FILE: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterRenderer.h
FILE: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterRenderer.mm
FILE: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterResizeSynchronizer.h
FILE: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterResizeSynchronizer.mm
FILE: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterResizeSynchronizerTest.mm
FILE: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterRunLoop.swift
FILE: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterSurface.h
FILE: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterSurface.mm
@ -56033,6 +56029,8 @@ FILE: ../../../flutter/shell/platform/darwin/macos/framework/Source/KeyCodeMap.g
FILE: ../../../flutter/shell/platform/darwin/macos/framework/Source/KeyCodeMapTest.mm
FILE: ../../../flutter/shell/platform/darwin/macos/framework/Source/KeyCodeMap_Internal.h
FILE: ../../../flutter/shell/platform/darwin/macos/framework/Source/NSView+ClipsToBounds.h
FILE: ../../../flutter/shell/platform/darwin/macos/framework/Source/ResizeSynchronizer.swift
FILE: ../../../flutter/shell/platform/darwin/macos/framework/Source/ResizeSynchronizerTest.swift
FILE: ../../../flutter/shell/platform/darwin/macos/framework/Source/TestFlutterPlatformView.h
FILE: ../../../flutter/shell/platform/darwin/macos/framework/Source/TestFlutterPlatformView.mm
FILE: ../../../flutter/shell/platform/darwin/macos/framework/module.modulemap

View File

@ -62,7 +62,10 @@ source_set("InternalFlutterSwift") {
"//flutter/shell/platform/darwin/macos/framework", # For module.modulemap
]
bridge_header = "InternalFlutterSwift-Bridging-Header.h"
sources = [ "framework/Source/FlutterRunLoop.swift" ]
sources = [
"framework/Source/FlutterRunLoop.swift",
"framework/Source/ResizeSynchronizer.swift",
]
deps = [ "//flutter/shell/platform/darwin/common:framework_common" ]
public = _flutter_framework_headers + framework_common_headers
}
@ -109,8 +112,6 @@ source_set("flutter_framework_source") {
"framework/Source/FlutterPlatformViewController.mm",
"framework/Source/FlutterRenderer.h",
"framework/Source/FlutterRenderer.mm",
"framework/Source/FlutterResizeSynchronizer.h",
"framework/Source/FlutterResizeSynchronizer.mm",
"framework/Source/FlutterSurface.h",
"framework/Source/FlutterSurface.mm",
"framework/Source/FlutterSurfaceManager.h",
@ -188,6 +189,21 @@ test_fixtures("flutter_desktop_darwin_fixtures") {
fixtures = [ "//flutter/third_party/icu/common/icudtl.dat" ]
}
executable("flutter_desktop_darwin_swift_unittests") {
testonly = true
public_configs = [
"//flutter/shell/platform/darwin/common:config",
"//flutter/shell/platform/darwin/common:test_config",
"//flutter/shell/platform/darwin/common:swift_testing_config",
]
sources = [ "framework/Source/ResizeSynchronizerTest.swift" ]
deps = [
":flutter_framework_source",
"//flutter/shell/platform/darwin/common:swift_testing_main",
"//flutter/shell/platform/darwin/common:test_utils_swift",
]
}
executable("flutter_desktop_darwin_unittests") {
testonly = true
cflags_objc = flutter_cflags_objc
@ -210,7 +226,6 @@ executable("flutter_desktop_darwin_unittests") {
"framework/Source/FlutterMutatorViewTest.mm",
"framework/Source/FlutterPlatformNodeDelegateMacTest.mm",
"framework/Source/FlutterPlatformViewControllerTest.mm",
"framework/Source/FlutterResizeSynchronizerTest.mm",
"framework/Source/FlutterSurfaceManagerTest.mm",
"framework/Source/FlutterTextInputPluginTest.mm",
"framework/Source/FlutterTextInputSemanticsObjectTest.mm",

View File

@ -44,9 +44,9 @@ Builds are architecture-specific, and can be controlled by specifying
## Testing
The macOS-specific embedder tests are built as the
`flutter_desktop_darwin_unittests` binary. Like all gtest-based test binaries, a
subset of tests can be run by applying a filter such as
`--gtest_filter='FlutterViewControllerTest.*Key*'`.
`flutter_desktop_darwin_unittests` and `flutter_desktop_darwin_swift_unittests`
binaries. Like all gtest-based test binaries, a subset of tests can be run by
applying a filter such as `--gtest_filter='FlutterViewControllerTest.*Key*'`.
More general details on testing can be found on the [Wiki][wiki_engine_testing].

View File

@ -4,7 +4,6 @@
#import "flutter/shell/platform/darwin/macos/framework/Headers/FlutterEngine.h"
#import "flutter/shell/platform/darwin/macos/framework/Source/FlutterEngine_Internal.h"
#include "shell/platform/darwin/macos/framework/Source/FlutterResizeSynchronizer.h"
#include <objc/objc.h>
@ -19,6 +18,7 @@
#import "flutter/shell/platform/darwin/common/framework/Headers/FlutterChannels.h"
#import "flutter/shell/platform/darwin/common/framework/Source/FlutterBinaryMessengerRelay.h"
#import "flutter/shell/platform/darwin/common/test_utils_swift/test_utils_swift.h"
#import "flutter/shell/platform/darwin/macos/InternalFlutterSwift/InternalFlutterSwift.h"
#import "flutter/shell/platform/darwin/macos/framework/Headers/FlutterAppDelegate.h"
#import "flutter/shell/platform/darwin/macos/framework/Headers/FlutterAppLifecycleDelegate.h"
#import "flutter/shell/platform/darwin/macos/framework/Headers/FlutterPluginMacOS.h"
@ -909,9 +909,9 @@ TEST_F(FlutterEngineTest, ResizeSynchronizerNotBlockingRasterThreadAfterShutdown
std::thread rasterThread([&threadSynchronizer] {
[threadSynchronizer performCommitForSize:CGSizeMake(100, 100)
afterDelay:0
notify:^{
}
delay:0];
}];
});
rasterThread.join();

View File

@ -1,42 +0,0 @@
// 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.
#ifndef FLUTTER_SHELL_PLATFORM_DARWIN_MACOS_FRAMEWORK_SOURCE_FLUTTERRESIZESYNCHRONIZER_H_
#define FLUTTER_SHELL_PLATFORM_DARWIN_MACOS_FRAMEWORK_SOURCE_FLUTTERRESIZESYNCHRONIZER_H_
#import <Cocoa/Cocoa.h>
#import "flutter/shell/platform/darwin/macos/framework/Headers/FlutterViewController.h"
/**
* Class responsible for coordinating window resize with content update.
*/
@interface FlutterResizeSynchronizer : NSObject
/**
* Begins a resize operation for the given size. Block the thread until
* performCommitForSize: with the same size is called.
* While the thread is blocked Flutter messages are being pumped.
* See [FlutterRunLoop pollFlutterMessagesOnce].
*/
- (void)beginResizeForSize:(CGSize)size notify:(nonnull dispatch_block_t)notify;
/**
* Called from raster thread. Schedules the given block on platform thread
* at given delay and unblocks the platform thread if waiting for the surface
* during resize.
*/
- (void)performCommitForSize:(CGSize)size
notify:(nonnull dispatch_block_t)notify
delay:(NSTimeInterval)delay;
/**
* Called when the view is shut down. Unblocks platform thread if blocked
* during resize.
*/
- (void)shutDown;
@end
#endif // FLUTTER_SHELL_PLATFORM_DARWIN_MACOS_FRAMEWORK_SOURCE_FLUTTERRESIZESYNCHRONIZER_H_

View File

@ -1,62 +0,0 @@
// 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 "flutter/shell/platform/darwin/macos/framework/Source/FlutterResizeSynchronizer.h"
#include <atomic>
#import "flutter/shell/platform/darwin/common/InternalFlutterSwiftCommon/InternalFlutterSwiftCommon.h"
#import "flutter/shell/platform/darwin/macos/InternalFlutterSwift/InternalFlutterSwift.h"
@implementation FlutterResizeSynchronizer {
std::atomic_bool _inResize;
BOOL _shuttingDown;
BOOL _didReceiveFrame;
CGSize _contentSize;
}
- (void)beginResizeForSize:(CGSize)size notify:(nonnull dispatch_block_t)notify {
if (!_didReceiveFrame || _shuttingDown) {
notify();
return;
}
_inResize = true;
_contentSize = CGSizeMake(-1, -1);
notify();
CFAbsoluteTime start = CFAbsoluteTimeGetCurrent();
while (true) {
if (CGSizeEqualToSize(_contentSize, size) || _shuttingDown) {
break;
}
if (CFAbsoluteTimeGetCurrent() - start > 1.0) {
[FlutterLogger logError:@"Resize timed out."];
break;
}
[FlutterRunLoop.mainRunLoop pollFlutterMessagesOnce];
}
_inResize = false;
}
- (void)performCommitForSize:(CGSize)size
notify:(nonnull dispatch_block_t)notify
delay:(NSTimeInterval)delay {
if (_inResize) {
delay = 0;
}
[FlutterRunLoop.mainRunLoop performAfterDelay:delay
block:^{
_didReceiveFrame = YES;
_contentSize = size;
notify();
}];
}
- (void)shutDown {
[FlutterRunLoop.mainRunLoop performBlock:^{
_shuttingDown = YES;
}];
}
@end

View File

@ -1,122 +0,0 @@
// 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.
#include "flutter/fml/synchronization/waitable_event.h"
#import "flutter/shell/platform/darwin/macos/InternalFlutterSwift/InternalFlutterSwift.h"
#import "flutter/shell/platform/darwin/macos/framework/Source/FlutterResizeSynchronizer.h"
#import "flutter/testing/testing.h"
TEST(FlutterThreadSynchronizerTest, NotBlocked) {
[FlutterRunLoop ensureMainLoopInitialized];
FlutterResizeSynchronizer* synchronizer = [[FlutterResizeSynchronizer alloc] init];
__block BOOL performed = NO;
[NSThread detachNewThreadWithBlock:^{
[synchronizer performCommitForSize:CGSizeMake(100, 100)
notify:^{
performed = YES;
}
delay:0];
}];
CFTimeInterval start = CFAbsoluteTimeGetCurrent();
while (!performed && CFAbsoluteTimeGetCurrent() - start < 1.0) {
[FlutterRunLoop.mainRunLoop pollFlutterMessagesOnce];
}
EXPECT_EQ(performed, YES);
}
TEST(FlutterThreadSynchronizerTest, WaitForResize) {
[FlutterRunLoop ensureMainLoopInitialized];
FlutterResizeSynchronizer* synchronizer = [[FlutterResizeSynchronizer alloc] init];
__block BOOL commit1 = NO;
__block BOOL commit2 = NO;
// Capturing c++ objects in blocks requires copy constructor, that also applies
// to __block variables where the copy is made on heap.
fml::AutoResetWaitableEvent latch_;
fml::AutoResetWaitableEvent& latch = latch_;
// Resize synchronizer must have at received one frame in order to block.
__block BOOL didReceiveFrame = NO;
[synchronizer performCommitForSize:CGSizeMake(10, 10)
notify:^{
didReceiveFrame = YES;
}
delay:0];
CFTimeInterval start = CFAbsoluteTimeGetCurrent();
while (!didReceiveFrame && CFAbsoluteTimeGetCurrent() - start < 1.0) {
[FlutterRunLoop.mainRunLoop pollFlutterMessagesOnce];
}
// Now resize should block until it has received expected size.
[NSThread detachNewThreadWithBlock:^{
latch.Wait();
[synchronizer performCommitForSize:CGSizeMake(50, 100)
notify:^{
commit1 = YES;
}
delay:0];
[synchronizer performCommitForSize:CGSizeMake(100, 100)
notify:^{
commit2 = YES;
}
delay:0];
}];
[synchronizer beginResizeForSize:CGSizeMake(100, 100)
notify:^{
latch.Signal();
}];
EXPECT_EQ(commit1, YES);
EXPECT_EQ(commit2, YES);
}
TEST(FlutterThreadSynchronizerTest, UnblocksOnShutDown) {
[FlutterRunLoop ensureMainLoopInitialized];
FlutterResizeSynchronizer* synchronizer = [[FlutterResizeSynchronizer alloc] init];
// Resize synchronizer must have at received one frame in order to block.
__block BOOL didReceiveFrame = NO;
[synchronizer performCommitForSize:CGSizeMake(10, 10)
notify:^{
didReceiveFrame = YES;
}
delay:0];
CFTimeInterval start = CFAbsoluteTimeGetCurrent();
while (!didReceiveFrame && CFAbsoluteTimeGetCurrent() - start < 1.0) {
[FlutterRunLoop.mainRunLoop pollFlutterMessagesOnce];
}
fml::AutoResetWaitableEvent latch_;
fml::AutoResetWaitableEvent& latch = latch_;
[NSThread detachNewThreadWithBlock:^{
latch.Wait();
[synchronizer shutDown];
}];
[synchronizer beginResizeForSize:CGSizeMake(100, 100)
notify:^{
// Unblock resize
latch.Signal();
}];
// Subsequent calls should not block.
[synchronizer beginResizeForSize:CGSizeMake(100, 100)
notify:^{
}];
}

View File

@ -4,11 +4,12 @@
#import "flutter/shell/platform/darwin/macos/framework/Source/FlutterView.h"
#import "flutter/shell/platform/darwin/macos/framework/Source/FlutterResizeSynchronizer.h"
#import "flutter/shell/platform/darwin/macos/framework/Source/FlutterSurfaceManager.h"
#import <QuartzCore/QuartzCore.h>
#import "flutter/shell/platform/darwin/common/InternalFlutterSwiftCommon/InternalFlutterSwiftCommon.h"
#import "flutter/shell/platform/darwin/macos/InternalFlutterSwift/InternalFlutterSwift.h"
#import "flutter/shell/platform/darwin/macos/framework/Source/FlutterSurfaceManager.h"
@interface FlutterView () <FlutterSurfaceManagerDelegate> {
FlutterViewIdentifier _viewIdentifier;
__weak id<FlutterViewDelegate> _viewDelegate;
@ -42,7 +43,7 @@
}
- (void)onPresent:(CGSize)frameSize withBlock:(dispatch_block_t)block delay:(NSTimeInterval)delay {
[_resizeSynchronizer performCommitForSize:frameSize notify:block delay:delay];
[_resizeSynchronizer performCommitForSize:frameSize afterDelay:delay notify:block];
}
- (FlutterSurfaceManager*)surfaceManager {
@ -63,9 +64,12 @@
[super setFrameSize:newSize];
CGSize scaledSize = [self convertSizeToBacking:self.bounds.size];
[_resizeSynchronizer beginResizeForSize:scaledSize
notify:^{
[_viewDelegate viewDidReshape:self];
}];
notify:^{
[_viewDelegate viewDidReshape:self];
}
onTimeout:^{
[FlutterLogger logError:@"Resize timed out"];
}];
}
/**

View File

@ -0,0 +1,151 @@
// 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 CoreGraphics
import Foundation
/// Coordinates Flutter view content updates with macOS window resizing.
///
/// When a native window containing a Flutter view is resized, the platform's window bounds can
/// change before Flutter has been able to render a new frame matching those new dimensions. This
/// asynchronicity can lead to undesirable visual effects such as:
/// - Content temporarily appearing at the old size within the new, larger bounds.
/// - Black "letterboxing" artifacts rendered at view edges.
/// - A perception of lag in content resizing.
///
/// `ResizeSynchronizer` mitigates these issues by introducing a controlled blocking mechanism
/// during the resize lifecycle:
///
/// 1. Initiation (Platform Thread): When a native window resize is detected (e.g., by a window
/// delegate), the platform thread calls `beginResize(forSize:notify:onTimeout:)` with the new
/// target dimensions. This method blocks the calling platform thread, pausing the completion of
/// the native resize event. While blocked, it continuously polls for engine messages to process
/// relevant updates. The `notify` closure is invoked just before blocking begins.
///
/// 2. Commit (Raster Thread): As the engine renders frames, the raster thread, upon presenting a
/// new frame, calls `performCommit(forSize:afterDelay:notify:)`, providing the size of the
/// frame just rendered.
///
/// 3. Unblocking: If the size of the committed frame matches the target size that `beginResize()`
/// is waiting for, `beginResize()` unblocks. This allows the native resize operation to
/// complete, now that Flutter's content is synchronized with the new window dimensions.
///
/// Safeguards:
/// - Timeout: `beginResize()` includes a timeout mechanism to prevent indefinite blocking if a
/// matching frame isn't committed in a timely manner. An optional `onTimeout` closure can be
/// provided to handle this event.
/// - Shutdown: The synchronization can be cleanly interrupted by calling `shutDown()`, which will
/// also unblock any pending `beginResize()` call. After shutdown, `beginResize()` will no
/// longer block.
/// - Thread Safety: The class manages its internal state in a thread-safe manner to coordinate
/// actions between the platform thread and the raster thread.
@objc(FlutterResizeSynchronizer)
public final class ResizeSynchronizer: NSObject {
private static let invalidSize = CGSize(width: -1, height: -1)
// Synchronizes access to _isInResize_unsafe: isInResize is accessed from multiple threads and
// thus requires synchronized access to the underlying storage.
private let isInResizeLock = NSLock()
private var _isInResize_unsafe: Bool = false
private var isInResize: Bool {
get {
isInResizeLock.lock()
defer { isInResizeLock.unlock() }
return _isInResize_unsafe
}
set {
isInResizeLock.lock()
_isInResize_unsafe = newValue
isInResizeLock.unlock()
}
}
// True if the app is shutting down. Must be set on platform thread.
private var isShuttingDown: Bool = false
// True if at least one frame has been presented. Must be set on platform thread.
private var didReceiveFrame: Bool = false
// The updated view surface size. Must be set on platform thread.
private var contentSize: CGSize = ResizeSynchronizer.invalidSize
/// Begins window resize operation to the specified size.
///
/// Blocks the thread until `performCommit(forSize:notify:delay:)` with the same size is called.
/// While the thread is blocked, Flutter messages are being pumped.
/// See `FlutterRunLoop.mainRunLoop.pollFlutterMessagesOnce()`.
@objc public func beginResize(
forSize size: CGSize,
notify: () -> Void,
onTimeout: (() -> Void)? = nil
) {
if !didReceiveFrame || isShuttingDown {
// If we haven't yet received a frame, or we're shutting down, there's nothing to do.
notify()
return
}
// Mark that we're in a resize and set the content size to a sentinel value.
isInResize = true
contentSize = ResizeSynchronizer.invalidSize
// Call the notify callback.
notify()
// Spin, waiting for the commit (during frame present) for up to 1 second, then time out.
let startTime = CFAbsoluteTimeGetCurrent()
let timeoutDuration: TimeInterval = 1.0
while true {
// If no change to size, or we got a shutdown notice, bail out.
if contentSize == size || isShuttingDown {
break
}
// If we've hit the timeout, notify the caller and bail out.
if CFAbsoluteTimeGetCurrent() - startTime > timeoutDuration {
onTimeout?()
break
}
// Process platform thread messages to pick up any updates to contentSize, didReceiveFrame,
// isShuttingDown made in performCommit and shutdown methods.
FlutterRunLoop.mainRunLoop.pollFlutterMessagesOnce()
}
isInResize = false
}
/// Commit the updated frame size.
///
/// Schedules the given block on the platform thread with the given delay. Unblocks `beginResize`
/// on the platform thread, if waiting for the surface during resize.
///
/// Called from the raster thread on frame present.
@objc public func performCommit(
forSize size: CGSize,
afterDelay delay: TimeInterval,
notify: @escaping () -> Void
) {
var effectiveDelay = delay
// If we're currently resizing, process immediately.
if self.isInResize { // Accesses the computed property which uses the lock
effectiveDelay = 0
}
FlutterRunLoop.mainRunLoop.perform(afterDelay: effectiveDelay) {
self.didReceiveFrame = true
self.contentSize = size
notify()
}
}
/// Notifies the synchronizer that the Flutter view is being shut down.
///
/// Unblocks the platform thread if blocked.
@objc public func shutDown() {
FlutterRunLoop.mainRunLoop.perform {
self.isShuttingDown = true
}
}
}

View File

@ -0,0 +1,212 @@
// 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 InternalFlutterSwift
import Testing
// Tests for `ResizeSynchronizer`.
//
// `FlutterRunLoop` asserts that it be called on the main thread, and thus the main thread is,
// in effect, a source of implicit shared state/behaviour. Because `beginResize` is a blocking call
// performed on the main thread, we serialise to avoid potential interactions between tests.
@Suite("ResizeSynchronizer tests", .serialized)
struct ResizeSynchronizerTest {
@MainActor
@Test("performCommit callback executes when no resize is active")
func testNotBlocked() async {
FlutterRunLoop.ensureMainLoopInitialized()
let synchronizer = ResizeSynchronizer()
var didReceiveFrame = false
// Call performCommit from raster thread during frame present.
Thread.detachNewThread {
synchronizer.performCommit(forSize: CGSize(width: 100, height: 100), afterDelay: 0) {
didReceiveFrame = true
}
}
// Ensure the task is processed within the timeout.
do {
try await waitForCondition("didReceiveFrame to be true", timeout: 1.0) { didReceiveFrame }
} catch {
// Record the timeout and bail out of the test.
Issue.record("\(error)")
return
}
}
@MainActor
@Test("beginResize does not invoke onTimeout if performCommit called with matching frame size")
func testBeginResizeDoesNotTimeOutWithMatchingPerformCommit() async {
FlutterRunLoop.ensureMainLoopInitialized()
// Resize synchronizer must have presented a frame in order to block.
let synchronizer = ResizeSynchronizer()
var didReceiveFrame = false
synchronizer.performCommit(forSize: CGSize(width: 10, height: 10), afterDelay: 0) {
didReceiveFrame = true
}
do {
try await waitForCondition("didReceiveFrame to be true", timeout: 1.0) { didReceiveFrame }
} catch {
// Record the timeout and bail out of the test.
Issue.record("\(error)")
return
}
var commit1 = false
var commit2 = false
var didTimeout = false
let latch = DispatchSemaphore(value: 0)
// Call performCommit from raster thread during frame present.
Thread.detachNewThread {
// Block until `beginResize` has been called.
latch.wait()
// First commit size DOES NOT match that passed to beginResize.
synchronizer.performCommit(forSize: CGSize(width: 50, height: 100), afterDelay: 0) {
commit1 = true
}
// Second commit size DOES match that passed to beginResize.
synchronizer.performCommit(forSize: CGSize(width: 100, height: 100), afterDelay: 0) {
commit2 = true
}
}
// This call blocks until performCommit is called with matching size, or times out.
synchronizer.beginResize(forSize: CGSize(width: 100, height: 100)) {
// Unblock the raster thread by signaling the latch.
latch.signal()
} onTimeout: {
didTimeout = true
}
// Verify beginResize did not timeout.
#expect(
didTimeout == false,
"onTimeout was called even though performCommit was called with matching size")
// Verify performCommit callbacks were invoked.
#expect(commit1 == true)
#expect(commit2 == true)
}
@MainActor
@Test("beginResize invokes onTimeout if performCommit not called with matching frame size")
func testBeginResizeDoesTimeOutWithoutMatchingPerformCommit() async {
FlutterRunLoop.ensureMainLoopInitialized()
// Resize synchronizer must have presented a frame in order to block.
let synchronizer = ResizeSynchronizer()
var didReceiveFrame = false
synchronizer.performCommit(forSize: CGSize(width: 10, height: 10), afterDelay: 0) {
didReceiveFrame = true
}
do {
try await waitForCondition("didReceiveFrame to be true", timeout: 1.0) { didReceiveFrame }
} catch {
// Record the timeout and bail out of the test.
Issue.record("\(error)")
return
}
didReceiveFrame = false
var didTimeout = false
let latch = DispatchSemaphore(value: 0)
// Call performCommit from raster thread during frame present.
Thread.detachNewThread {
// Block until `beginResize` has been called.
latch.wait()
// First commit size DOES NOT match that passed to beginResize.
synchronizer.performCommit(forSize: CGSize(width: 50, height: 100), afterDelay: 0) {
didReceiveFrame = true
}
}
// This call blocks until performCommit is called with matching size, or times out.
synchronizer.beginResize(forSize: CGSize(width: 100, height: 100)) {
// Unblock the raster thread by signaling the latch.
latch.signal()
} onTimeout: {
didTimeout = true
}
// Verify beginResize timed out.
#expect(
didTimeout == true,
"onTimeout was not called even though performCommit was not called with matching size")
// Verify performCommit callback was invoked.
#expect(didReceiveFrame == true)
}
@MainActor
@Test("shutDown unblocks an active beginResize and prevents future blocking")
func testUnblocksOnShutdown() async {
FlutterRunLoop.ensureMainLoopInitialized()
let synchronizer = ResizeSynchronizer()
// Resize synchronizer must have received one frame in order to block.
var didReceiveFrame = false
synchronizer.performCommit(forSize: CGSize(width: 10, height: 10), afterDelay: 0) {
didReceiveFrame = true
}
do {
try await waitForCondition("didReceiveFrame to be true", timeout: 1.0) { didReceiveFrame }
} catch {
// Record the timeout and bail out of the test.
Issue.record("\(error)")
return
}
// Block until we receive a performCommit with a matching frameSize.
let latch = DispatchSemaphore(value: 0)
Thread.detachNewThread {
// Block until `beginResize` has been called, and signals the latch.
latch.wait()
synchronizer.shutDown()
}
synchronizer.beginResize(forSize: CGSize(width: 100, height: 100)) {
// Unblock resize.
latch.signal()
}
// Subsequent calls should not block.
synchronizer.beginResize(forSize: CGSize(width: 100, height: 100)) {}
}
}
// Returns when `condition` is true, or throws if it's not true at the expiration of `timeout`.
// Polls `FlutterRunLoop.mainRunLoop` to execute any posted tasks every `pollingInterval` seconds.
@MainActor
private func waitForCondition(
_ description: String,
timeout: TimeInterval,
pollingInterval: TimeInterval = 0.01,
condition: @escaping @MainActor () -> Bool
) async throws {
let startTime = CFAbsoluteTimeGetCurrent()
while !condition() {
if CFAbsoluteTimeGetCurrent() - startTime > timeout {
struct TimeoutError: Error, CustomStringConvertible {
let message: String
var description: String { message }
}
throw TimeoutError(message: "Timeout waiting for \(description) after \(timeout) seconds")
}
// Pump messages to ensure performCommit blocks execute.
FlutterRunLoop.mainRunLoop.pollFlutterMessagesOnce()
// Allow other tasks to run, avoid pegging the CPU.
try await Task.sleep(nanoseconds: UInt64(pollingInterval * 1_000_000_000))
}
}

View File

@ -506,6 +506,15 @@ def run_cc_tests(build_dir, executable_filter, coverage, capture_core_dump):
xvfb.stop_virtual_x(build_name)
if is_mac():
# macOS Desktop unit tests written in Swift.
run_engine_executable(
build_dir,
'flutter_desktop_darwin_swift_unittests',
executable_filter,
shuffle_flags,
coverage=coverage
)
# flutter_desktop_darwin_unittests uses global state that isn't handled
# correctly by gtest-parallel.
# https://github.com/flutter/flutter/issues/104789