[macOS] Multi-view thread synchronizer (#41915)

This PR adapts `FlutterThreadSynchronizer` to multi-view.

### Problem
The `FlutterThreadSynchronizer` is a macOS engine class to ensure that, if the current window is resized, nothing will not be presented until the layer tree is drawn with the up-to-date size. 

This class is not compatible with multiview. A simple way to realize it is: while the class is owned by `FlutterView`, it blocks the _global_ platform thread and rasterizer thread - there is got to be some problems. Indeed, when I was testing with multiple windows (https://github.com/flutter/engine/pull/40399), the app freezes as soon as I resize a window.

The problem is because Flutter only have one rasterizer thread. When I'm resizing window A, A's synchronizer detects that the size mismatches, so it blocks the rasterizer thread to wait for an updated content with the updated size. However, window B is to be rendered next, and B's size matches and will try to rasterize, and will be blocked due to the blocked rasterizer thread, window A's updated content will never arrive, causing a deadlock.

### Changes
This PR removes the single-view assumption of `FlutterThreadSynchronizer` by making it created by `FlutterEngine` and shared by all `FlutterView`s, and that it prevents rasterization for all views if any view has a mismatched size.

The synchronizer is assigned to the view controller in the `attachToEngine:withId` method (now renamed to `setUpWithEngine:viewId:threadSynchronizer:`.

This PR also adds unit tests for `FlutterThreadSynchronizer`, which didn't have any unit tests at all.
* To achieve this, the `beginResizeForView:` method no longer checks whether  is called on the main thread, but whether it's called on the main queue. These are equivalent for the real main queue, since the documentation promises that the main queue always executes on the main thread. However, this change allows substituting the queue with a custom one for unit tests.

[C++, Objective-C, Java style guides]: https://github.com/flutter/engine/blob/main/CONTRIBUTING.md#style
diff --git a/ci/licenses_golden/licenses_flutter b/ci/licenses_golden/licenses_flutter
index 2af8a23..23c6a80 100644
--- a/ci/licenses_golden/licenses_flutter
+++ b/ci/licenses_golden/licenses_flutter
@@ -2746,6 +2746,9 @@
 ORIGIN: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterTextInputSemanticsObjectTest.mm + ../../../flutter/LICENSE
 ORIGIN: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterTextureRegistrar.h + ../../../flutter/LICENSE
 ORIGIN: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterTextureRegistrar.mm + ../../../flutter/LICENSE
+ORIGIN: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterThreadSynchronizer.h + ../../../flutter/LICENSE
+ORIGIN: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterThreadSynchronizer.mm + ../../../flutter/LICENSE
+ORIGIN: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterThreadSynchronizerTest.mm + ../../../flutter/LICENSE
 ORIGIN: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterUmbrellaImportTests.m + ../../../flutter/LICENSE
 ORIGIN: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterView.h + ../../../flutter/LICENSE
 ORIGIN: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterView.mm + ../../../flutter/LICENSE
@@ -5414,6 +5417,7 @@
 FILE: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterTextureRegistrar.mm
 FILE: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterThreadSynchronizer.h
 FILE: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterThreadSynchronizer.mm
+FILE: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterThreadSynchronizerTest.mm
 FILE: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterUmbrellaImportTests.m
 FILE: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterView.h
 FILE: ../../../flutter/shell/platform/darwin/macos/framework/Source/FlutterView.mm
diff --git a/shell/platform/darwin/macos/BUILD.gn b/shell/platform/darwin/macos/BUILD.gn
index edebb4e..16ffb24 100644
--- a/shell/platform/darwin/macos/BUILD.gn
+++ b/shell/platform/darwin/macos/BUILD.gn
@@ -182,6 +182,7 @@
     "framework/Source/FlutterSurfaceManagerTest.mm",
     "framework/Source/FlutterTextInputPluginTest.mm",
     "framework/Source/FlutterTextInputSemanticsObjectTest.mm",
+    "framework/Source/FlutterThreadSynchronizerTest.mm",
     "framework/Source/FlutterViewControllerTest.mm",
     "framework/Source/FlutterViewControllerTestUtils.h",
     "framework/Source/FlutterViewControllerTestUtils.mm",
diff --git a/shell/platform/darwin/macos/framework/Source/FlutterEngine.mm b/shell/platform/darwin/macos/framework/Source/FlutterEngine.mm
index c456e42..d4f194c 100644
--- a/shell/platform/darwin/macos/framework/Source/FlutterEngine.mm
+++ b/shell/platform/darwin/macos/framework/Source/FlutterEngine.mm
@@ -388,6 +388,8 @@
   // A method channel for miscellaneous platform functionality.
   FlutterMethodChannel* _platformChannel;
 
+  FlutterThreadSynchronizer* _threadSynchronizer;
+
   int _nextViewId;
 }
 
@@ -427,6 +429,7 @@
                            object:nil];
 
   _platformViewController = [[FlutterPlatformViewController alloc] init];
+  _threadSynchronizer = [[FlutterThreadSynchronizer alloc] init];
   [self setUpPlatformViewChannel];
   [self setUpAccessibilityChannel];
   [self setUpNotificationCenterListeners];
@@ -589,7 +592,7 @@
   NSAssert(![controller attached],
            @"The incoming view controller is already attached to an engine.");
   NSAssert([_viewControllers objectForKey:@(viewId)] == nil, @"The requested view ID is occupied.");
-  [controller attachToEngine:self withId:viewId];
+  [controller setUpWithEngine:self viewId:viewId threadSynchronizer:_threadSynchronizer];
   NSAssert(controller.viewId == viewId, @"Failed to assign view ID.");
   [_viewControllers setObject:controller forKey:@(viewId)];
 }
@@ -928,11 +931,8 @@
     return;
   }
 
-  NSEnumerator* viewControllerEnumerator = [_viewControllers objectEnumerator];
-  FlutterViewController* nextViewController;
-  while ((nextViewController = [viewControllerEnumerator nextObject])) {
-    [nextViewController.flutterView shutdown];
-  }
+  [_threadSynchronizer shutdown];
+  _threadSynchronizer = nil;
 
   FlutterEngineResult result = _embedderAPI.Deinitialize(_engine);
   if (result != kSuccess) {
@@ -1117,6 +1117,10 @@
   return flutter::GetSwitchesFromEnvironment();
 }
 
+- (FlutterThreadSynchronizer*)testThreadSynchronizer {
+  return _threadSynchronizer;
+}
+
 #pragma mark - FlutterBinaryMessenger
 
 - (void)sendOnChannel:(nonnull NSString*)channel message:(nullable NSData*)message {
diff --git a/shell/platform/darwin/macos/framework/Source/FlutterEngineTest.mm b/shell/platform/darwin/macos/framework/Source/FlutterEngineTest.mm
index 1d9afad..cf81a8a 100644
--- a/shell/platform/darwin/macos/framework/Source/FlutterEngineTest.mm
+++ b/shell/platform/darwin/macos/framework/Source/FlutterEngineTest.mm
@@ -25,6 +25,8 @@
 // CREATE_NATIVE_ENTRY and MOCK_ENGINE_PROC are leaky by design
 // NOLINTBEGIN(clang-analyzer-core.StackAddressEscape)
 
+constexpr int64_t kDefaultViewId = 0ll;
+
 @interface FlutterEngine (Test)
 /**
  * The FlutterCompositor object currently in use by the FlutterEngine.
@@ -32,6 +34,7 @@
  * May be nil if the compositor has not been initialized yet.
  */
 @property(nonatomic, readonly, nullable) flutter::FlutterCompositor* macOSCompositor;
+
 @end
 
 @interface TestPlatformViewFactory : NSObject <FlutterPlatformViewFactory>
@@ -438,7 +441,7 @@
                 result:^(id result){
                 }];
 
-  [viewController.flutterView.threadSynchronizer blockUntilFrameAvailable];
+  [engine.testThreadSynchronizer blockUntilFrameAvailable];
 
   CALayer* rootLayer = viewController.flutterView.layer;
 
@@ -629,9 +632,10 @@
   [threadSynchronizer shutdown];
 
   std::thread rasterThread([&threadSynchronizer] {
-    [threadSynchronizer performCommit:CGSizeMake(100, 100)
-                               notify:^{
-                               }];
+    [threadSynchronizer performCommitForView:kDefaultViewId
+                                        size:CGSizeMake(100, 100)
+                                      notify:^{
+                                      }];
   });
 
   rasterThread.join();
diff --git a/shell/platform/darwin/macos/framework/Source/FlutterEngine_Internal.h b/shell/platform/darwin/macos/framework/Source/FlutterEngine_Internal.h
index 31c076d..be97322 100644
--- a/shell/platform/darwin/macos/framework/Source/FlutterEngine_Internal.h
+++ b/shell/platform/darwin/macos/framework/Source/FlutterEngine_Internal.h
@@ -195,4 +195,8 @@
 
 @end
 
+@interface FlutterEngine (Tests)
+- (nonnull FlutterThreadSynchronizer*)testThreadSynchronizer;
+@end
+
 NS_ASSUME_NONNULL_END
diff --git a/shell/platform/darwin/macos/framework/Source/FlutterThreadSynchronizer.h b/shell/platform/darwin/macos/framework/Source/FlutterThreadSynchronizer.h
index 8eca961..8d8d248 100644
--- a/shell/platform/darwin/macos/framework/Source/FlutterThreadSynchronizer.h
+++ b/shell/platform/darwin/macos/framework/Source/FlutterThreadSynchronizer.h
@@ -1,21 +1,29 @@
+// 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 <Cocoa/Cocoa.h>
 
 /**
  * Takes care of synchronization between raster and platform thread.
+ *
+ * All methods of this class must be called from the platform thread,
+ * except for performCommitForView:size:notify:.
  */
 @interface FlutterThreadSynchronizer : NSObject
 
 /**
- * Blocks current thread until there is frame available.
- * Used in FlutterEngineTest.
+ * Creates a FlutterThreadSynchronizer that uses the OS main thread as the
+ * platform thread.
  */
-- (void)blockUntilFrameAvailable;
+- (nullable instancetype)init;
 
 /**
- * Called from platform thread. Blocks until commit with given size (or empty)
- * is requested.
+ * Blocks until all views have a commit with their given sizes (or empty) is requested.
  */
-- (void)beginResize:(CGSize)size notify:(nonnull dispatch_block_t)notify;
+- (void)beginResizeForView:(int64_t)viewId
+                      size:(CGSize)size
+                    notify:(nonnull dispatch_block_t)notify;
 
 /**
  * Called from raster thread. Schedules the given block on platform thread
@@ -26,11 +34,65 @@
  *
  * The notify block is guaranteed to be called within a core animation transaction.
  */
-- (void)performCommit:(CGSize)size notify:(nonnull dispatch_block_t)notify;
+- (void)performCommitForView:(int64_t)viewId
+                        size:(CGSize)size
+                      notify:(nonnull dispatch_block_t)notify;
 
 /**
- * Called when shutting down. Unblocks everything and prevents any further synchronization.
+ * Requests the synchronizer to track another view.
+ *
+ * A view must be registered before calling begineResizeForView: or
+ * performCommitForView:. It is typically done when the view controller is
+ * created.
+ */
+- (void)registerView:(int64_t)viewId;
+
+/**
+ * Requests the synchronizer to no longer track a view.
+ *
+ * It is typically done when the view controller is destroyed.
+ */
+- (void)deregisterView:(int64_t)viewId;
+
+/**
+ * Called when the engine shuts down.
+ *
+ * Prevents any further synchronization and no longer blocks any threads.
  */
 - (void)shutdown;
 
 @end
+
+@interface FlutterThreadSynchronizer (TestUtils)
+
+/**
+ * Creates a FlutterThreadSynchronizer that uses the specified queue as the
+ * platform thread.
+ */
+- (nullable instancetype)initWithMainQueue:(nonnull dispatch_queue_t)queue;
+
+/**
+ * Blocks current thread until the mutex is available, then return whether the
+ * synchronizer is waiting for a correct commit during resizing.
+ *
+ * After calling an operation of the thread synchronizer, call this method,
+ * and when it returns, the thread synchronizer can be at one of the following 3
+ * states:
+ *
+ *  1. The operation has not started at all (with a return value FALSE.)
+ *  2. The operation has ended (with a return value FALSE.)
+ *  3. beginResizeForView: is in progress, waiting (with a return value TRUE.)
+ *
+ * By eliminating the 1st case (such as using the notify callback), we can use
+ * this return value to decide whether the synchronizer is in case 2 or case 3,
+ * that is whether the resizing is blocked by a mismatching commit.
+ */
+- (BOOL)isWaitingWhenMutexIsAvailable;
+
+/**
+ * Blocks current thread until there is frame available.
+ * Used in FlutterEngineTest.
+ */
+- (void)blockUntilFrameAvailable;
+
+@end
diff --git a/shell/platform/darwin/macos/framework/Source/FlutterThreadSynchronizer.mm b/shell/platform/darwin/macos/framework/Source/FlutterThreadSynchronizer.mm
index cb442ea..75a4ff5 100644
--- a/shell/platform/darwin/macos/framework/Source/FlutterThreadSynchronizer.mm
+++ b/shell/platform/darwin/macos/framework/Source/FlutterThreadSynchronizer.mm
@@ -1,17 +1,23 @@
+// 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/FlutterThreadSynchronizer.h"
 
 #import <QuartzCore/QuartzCore.h>
 
 #include <mutex>
+#include <unordered_map>
 #include <vector>
 
 #import "flutter/fml/logging.h"
 #import "flutter/fml/synchronization/waitable_event.h"
 
 @interface FlutterThreadSynchronizer () {
+  dispatch_queue_t _mainQueue;
   std::mutex _mutex;
   BOOL _shuttingDown;
-  CGSize _contentSize;
+  std::unordered_map<int64_t, CGSize> _contentSizes;
   std::vector<dispatch_block_t> _scheduledBlocks;
 
   BOOL _beginResizeWaiting;
@@ -20,12 +26,56 @@
   std::condition_variable _condBlockBeginResize;
 }
 
+/**
+ * Returns true if all existing views have a non-zero size.
+ *
+ * If there are no views, still returns true.
+ */
+- (BOOL)allViewsHaveFrame;
+
+/**
+ * Returns true if there are any views that have a non-zero size.
+ *
+ * If there are no views, returns false.
+ */
+- (BOOL)someViewsHaveFrame;
+
 @end
 
 @implementation FlutterThreadSynchronizer
 
+- (instancetype)init {
+  return [self initWithMainQueue:dispatch_get_main_queue()];
+}
+
+- (instancetype)initWithMainQueue:(dispatch_queue_t)queue {
+  self = [super init];
+  if (self != nil) {
+    _mainQueue = queue;
+  }
+  return self;
+}
+
+- (BOOL)allViewsHaveFrame {
+  for (auto const& [viewId, contentSize] : _contentSizes) {
+    if (CGSizeEqualToSize(contentSize, CGSizeZero)) {
+      return NO;
+    }
+  }
+  return YES;
+}
+
+- (BOOL)someViewsHaveFrame {
+  for (auto const& [viewId, contentSize] : _contentSizes) {
+    if (!CGSizeEqualToSize(contentSize, CGSizeZero)) {
+      return YES;
+    }
+  }
+  return NO;
+}
+
 - (void)drain {
-  FML_DCHECK([NSThread isMainThread]);
+  dispatch_assert_queue(_mainQueue);
 
   [CATransaction begin];
   [CATransaction setDisableActions:YES];
@@ -41,7 +91,7 @@
 
   _beginResizeWaiting = YES;
 
-  while (CGSizeEqualToSize(_contentSize, CGSizeZero) && !_shuttingDown) {
+  while (![self someViewsHaveFrame] && !_shuttingDown) {
     _condBlockBeginResize.wait(lock);
     [self drain];
   }
@@ -49,10 +99,13 @@
   _beginResizeWaiting = NO;
 }
 
-- (void)beginResize:(CGSize)size notify:(nonnull dispatch_block_t)notify {
+- (void)beginResizeForView:(int64_t)viewId
+                      size:(CGSize)size
+                    notify:(nonnull dispatch_block_t)notify {
+  dispatch_assert_queue(_mainQueue);
   std::unique_lock<std::mutex> lock(_mutex);
 
-  if (CGSizeEqualToSize(_contentSize, CGSizeZero) || _shuttingDown) {
+  if (![self allViewsHaveFrame] || _shuttingDown) {
     // No blocking until framework produces at least one frame
     notify();
     return;
@@ -62,12 +115,18 @@
 
   notify();
 
-  _contentSize = CGSizeMake(-1, -1);
+  _contentSizes[viewId] = CGSizeMake(-1, -1);
 
   _beginResizeWaiting = YES;
 
-  while (!CGSizeEqualToSize(_contentSize, size) &&  //
-         !CGSizeEqualToSize(_contentSize, CGSizeZero) && !_shuttingDown) {
+  while (true) {
+    if (_shuttingDown) {
+      break;
+    }
+    const CGSize& contentSize = _contentSizes[viewId];
+    if (CGSizeEqualToSize(contentSize, size) || CGSizeEqualToSize(contentSize, CGSizeZero)) {
+      break;
+    }
     _condBlockBeginResize.wait(lock);
     [self drain];
   }
@@ -75,7 +134,10 @@
   _beginResizeWaiting = NO;
 }
 
-- (void)performCommit:(CGSize)size notify:(nonnull dispatch_block_t)notify {
+- (void)performCommitForView:(int64_t)viewId
+                        size:(CGSize)size
+                      notify:(nonnull dispatch_block_t)notify {
+  dispatch_assert_queue_not(_mainQueue);
   fml::AutoResetWaitableEvent event;
   {
     std::unique_lock<std::mutex> lock(_mutex);
@@ -87,13 +149,13 @@
     fml::AutoResetWaitableEvent& e = event;
     _scheduledBlocks.push_back(^{
       notify();
-      _contentSize = size;
+      _contentSizes[viewId] = size;
       e.Signal();
     });
     if (_beginResizeWaiting) {
       _condBlockBeginResize.notify_all();
     } else {
-      dispatch_async(dispatch_get_main_queue(), ^{
+      dispatch_async(_mainQueue, ^{
         std::unique_lock<std::mutex> lock(_mutex);
         [self drain];
       });
@@ -102,11 +164,29 @@
   event.Wait();
 }
 
+- (void)registerView:(int64_t)viewId {
+  dispatch_assert_queue(_mainQueue);
+  std::unique_lock<std::mutex> lock(_mutex);
+  _contentSizes[viewId] = CGSizeZero;
+}
+
+- (void)deregisterView:(int64_t)viewId {
+  dispatch_assert_queue(_mainQueue);
+  std::unique_lock<std::mutex> lock(_mutex);
+  _contentSizes.erase(viewId);
+}
+
 - (void)shutdown {
+  dispatch_assert_queue(_mainQueue);
   std::unique_lock<std::mutex> lock(_mutex);
   _shuttingDown = YES;
   _condBlockBeginResize.notify_all();
   [self drain];
 }
 
+- (BOOL)isWaitingWhenMutexIsAvailable {
+  std::unique_lock<std::mutex> lock(_mutex);
+  return _beginResizeWaiting;
+}
+
 @end
diff --git a/shell/platform/darwin/macos/framework/Source/FlutterThreadSynchronizerTest.mm b/shell/platform/darwin/macos/framework/Source/FlutterThreadSynchronizerTest.mm
new file mode 100644
index 0000000..2541f86
--- /dev/null
+++ b/shell/platform/darwin/macos/framework/Source/FlutterThreadSynchronizerTest.mm
@@ -0,0 +1,383 @@
+// 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/FlutterThreadSynchronizer.h"
+
+#import <OCMock/OCMock.h>
+#import "flutter/fml/synchronization/waitable_event.h"
+#import "flutter/testing/testing.h"
+
+namespace flutter::testing {
+
+namespace {}  // namespace
+
+}  // namespace flutter::testing
+
+@interface FlutterThreadSynchronizerTestScaffold : NSObject
+
+@property(nonatomic, readonly, nonnull) FlutterThreadSynchronizer* synchronizer;
+
+- (nullable instancetype)init;
+- (void)dispatchMainTask:(nonnull void (^)())task;
+- (void)dispatchRenderTask:(nonnull void (^)())task;
+- (void)joinMain;
+- (void)joinRender;
+@end
+
+@implementation FlutterThreadSynchronizerTestScaffold {
+  dispatch_queue_t _mainQueue;
+  std::shared_ptr<fml::AutoResetWaitableEvent> _mainLatch;
+
+  dispatch_queue_t _renderQueue;
+  std::shared_ptr<fml::AutoResetWaitableEvent> _renderLatch;
+
+  FlutterThreadSynchronizer* _synchronizer;
+}
+
+@synthesize synchronizer = _synchronizer;
+
+- (nullable instancetype)init {
+  self = [super init];
+  if (self != nil) {
+    _mainQueue = dispatch_queue_create("MAIN", DISPATCH_QUEUE_SERIAL);
+    _renderQueue = dispatch_queue_create("RENDER", DISPATCH_QUEUE_SERIAL);
+    _synchronizer = [[FlutterThreadSynchronizer alloc] initWithMainQueue:_mainQueue];
+  }
+  return self;
+}
+
+- (void)dispatchMainTask:(nonnull void (^)())task {
+  dispatch_async(_mainQueue, task);
+}
+
+- (void)dispatchRenderTask:(nonnull void (^)())task {
+  dispatch_async(_renderQueue, task);
+}
+
+- (void)joinMain {
+  fml::AutoResetWaitableEvent latch;
+  fml::AutoResetWaitableEvent* pLatch = &latch;
+  dispatch_async(_mainQueue, ^{
+    pLatch->Signal();
+  });
+  latch.Wait();
+}
+
+- (void)joinRender {
+  fml::AutoResetWaitableEvent latch;
+  fml::AutoResetWaitableEvent* pLatch = &latch;
+  dispatch_async(_renderQueue, ^{
+    pLatch->Signal();
+  });
+  latch.Wait();
+}
+
+@end
+
+TEST(FlutterThreadSynchronizerTest, RegularCommit) {
+  FlutterThreadSynchronizerTestScaffold* scaffold =
+      [[FlutterThreadSynchronizerTestScaffold alloc] init];
+  FlutterThreadSynchronizer* synchronizer = scaffold.synchronizer;
+
+  // Initial resize: does not block until the first frame.
+  __block int notifiedResize = 0;
+  [scaffold dispatchMainTask:^{
+    [synchronizer registerView:1];
+    [synchronizer beginResizeForView:1
+                                size:CGSize{5, 5}
+                              notify:^{
+                                notifiedResize += 1;
+                              }];
+  }];
+  EXPECT_FALSE([synchronizer isWaitingWhenMutexIsAvailable]);
+  [scaffold joinMain];
+  EXPECT_EQ(notifiedResize, 1);
+
+  // Still does not block.
+  [scaffold dispatchMainTask:^{
+    [synchronizer beginResizeForView:1
+                                size:CGSize{7, 7}
+                              notify:^{
+                                notifiedResize += 1;
+                              }];
+  }];
+  EXPECT_FALSE([synchronizer isWaitingWhenMutexIsAvailable]);
+  [scaffold joinMain];
+  EXPECT_EQ(notifiedResize, 2);
+
+  // First frame
+  __block int notifiedCommit = 0;
+  [scaffold dispatchRenderTask:^{
+    [synchronizer performCommitForView:1
+                                  size:CGSize{7, 7}
+                                notify:^{
+                                  notifiedCommit += 1;
+                                }];
+  }];
+  EXPECT_FALSE([synchronizer isWaitingWhenMutexIsAvailable]);
+  [scaffold joinRender];
+  EXPECT_EQ(notifiedCommit, 1);
+}
+
+TEST(FlutterThreadSynchronizerTest, ResizingBlocksRenderingUntilSizeMatches) {
+  FlutterThreadSynchronizerTestScaffold* scaffold =
+      [[FlutterThreadSynchronizerTestScaffold alloc] init];
+  FlutterThreadSynchronizer* synchronizer = scaffold.synchronizer;
+  // A latch to ensure that a beginResizeForView: call has at least executed
+  // something, so that the isWaitingWhenMutexIsAvailable: call correctly stops
+  // at either when beginResizeForView: finishes or waits half way.
+  fml::AutoResetWaitableEvent begunResizingLatch;
+  fml::AutoResetWaitableEvent* begunResizing = &begunResizingLatch;
+
+  // Initial resize: does not block until the first frame.
+  [scaffold dispatchMainTask:^{
+    [synchronizer registerView:1];
+    [synchronizer beginResizeForView:1
+                                size:CGSize{5, 5}
+                              notify:^{
+                              }];
+  }];
+  [scaffold joinMain];
+  EXPECT_FALSE([synchronizer isWaitingWhenMutexIsAvailable]);
+
+  // First frame.
+  [scaffold dispatchRenderTask:^{
+    [synchronizer performCommitForView:1
+                                  size:CGSize{5, 5}
+                                notify:^{
+                                }];
+  }];
+  [scaffold joinRender];
+  EXPECT_FALSE([synchronizer isWaitingWhenMutexIsAvailable]);
+
+  // Resize to (7, 7): blocks until the next frame.
+  [scaffold dispatchMainTask:^{
+    [synchronizer beginResizeForView:1
+                                size:CGSize{7, 7}
+                              notify:^{
+                                begunResizing->Signal();
+                              }];
+  }];
+  begunResizing->Wait();
+  EXPECT_TRUE([synchronizer isWaitingWhenMutexIsAvailable]);
+
+  // Render with old size.
+  [scaffold dispatchRenderTask:^{
+    [synchronizer performCommitForView:1
+                                  size:CGSize{5, 5}
+                                notify:^{
+                                }];
+  }];
+  [scaffold joinRender];
+  EXPECT_TRUE([synchronizer isWaitingWhenMutexIsAvailable]);
+
+  // Render with new size.
+  [scaffold dispatchRenderTask:^{
+    [synchronizer performCommitForView:1
+                                  size:CGSize{7, 7}
+                                notify:^{
+                                }];
+  }];
+  [scaffold joinRender];
+  EXPECT_FALSE([synchronizer isWaitingWhenMutexIsAvailable]);
+
+  [scaffold joinMain];
+}
+
+TEST(FlutterThreadSynchronizerTest, ShutdownMakesEverythingNonBlocking) {
+  FlutterThreadSynchronizerTestScaffold* scaffold =
+      [[FlutterThreadSynchronizerTestScaffold alloc] init];
+  FlutterThreadSynchronizer* synchronizer = scaffold.synchronizer;
+  fml::AutoResetWaitableEvent begunResizingLatch;
+  fml::AutoResetWaitableEvent* begunResizing = &begunResizingLatch;
+
+  // Initial resize
+  [scaffold dispatchMainTask:^{
+    [synchronizer registerView:1];
+    [synchronizer beginResizeForView:1
+                                size:CGSize{5, 5}
+                              notify:^{
+                              }];
+  }];
+  [scaffold joinMain];
+  EXPECT_FALSE([synchronizer isWaitingWhenMutexIsAvailable]);
+
+  // Push a frame.
+  [scaffold dispatchRenderTask:^{
+    [synchronizer performCommitForView:1
+                                  size:CGSize{5, 5}
+                                notify:^{
+                                }];
+  }];
+  [scaffold joinRender];
+  EXPECT_FALSE([synchronizer isWaitingWhenMutexIsAvailable]);
+
+  [scaffold dispatchMainTask:^{
+    [synchronizer shutdown];
+  }];
+
+  // Resize to (7, 7). Should not block any frames since it has shut down.
+  [scaffold dispatchMainTask:^{
+    [synchronizer beginResizeForView:1
+                                size:CGSize{7, 7}
+                              notify:^{
+                                begunResizing->Signal();
+                              }];
+  }];
+  begunResizing->Wait();
+  EXPECT_FALSE([synchronizer isWaitingWhenMutexIsAvailable]);
+  [scaffold joinMain];
+
+  // All further calls should be unblocking.
+  [scaffold dispatchRenderTask:^{
+    [synchronizer performCommitForView:1
+                                  size:CGSize{9, 9}
+                                notify:^{
+                                }];
+  }];
+  [scaffold joinRender];
+  EXPECT_FALSE([synchronizer isWaitingWhenMutexIsAvailable]);
+}
+
+TEST(FlutterThreadSynchronizerTest, RegularCommitForMultipleViews) {
+  FlutterThreadSynchronizerTestScaffold* scaffold =
+      [[FlutterThreadSynchronizerTestScaffold alloc] init];
+  FlutterThreadSynchronizer* synchronizer = scaffold.synchronizer;
+  fml::AutoResetWaitableEvent begunResizingLatch;
+  fml::AutoResetWaitableEvent* begunResizing = &begunResizingLatch;
+
+  // Initial resize: does not block until the first frame.
+  [scaffold dispatchMainTask:^{
+    [synchronizer registerView:1];
+    [synchronizer registerView:2];
+    [synchronizer beginResizeForView:1
+                                size:CGSize{5, 5}
+                              notify:^{
+                              }];
+    [synchronizer beginResizeForView:2
+                                size:CGSize{15, 15}
+                              notify:^{
+                                begunResizing->Signal();
+                              }];
+  }];
+  begunResizing->Wait();
+  EXPECT_FALSE([synchronizer isWaitingWhenMutexIsAvailable]);
+  [scaffold joinMain];
+
+  // Still does not block.
+  [scaffold dispatchMainTask:^{
+    [synchronizer beginResizeForView:1
+                                size:CGSize{7, 7}
+                              notify:^{
+                                begunResizing->Signal();
+                              }];
+  }];
+  begunResizing->Signal();
+  EXPECT_FALSE([synchronizer isWaitingWhenMutexIsAvailable]);
+  [scaffold joinMain];
+
+  // First frame
+  [scaffold dispatchRenderTask:^{
+    [synchronizer performCommitForView:1
+                                  size:CGSize{7, 7}
+                                notify:^{
+                                }];
+    [synchronizer performCommitForView:2
+                                  size:CGSize{15, 15}
+                                notify:^{
+                                }];
+  }];
+  [scaffold joinRender];
+  EXPECT_FALSE([synchronizer isWaitingWhenMutexIsAvailable]);
+}
+
+TEST(FlutterThreadSynchronizerTest, ResizingForMultipleViews) {
+  FlutterThreadSynchronizerTestScaffold* scaffold =
+      [[FlutterThreadSynchronizerTestScaffold alloc] init];
+  FlutterThreadSynchronizer* synchronizer = scaffold.synchronizer;
+  fml::AutoResetWaitableEvent begunResizingLatch;
+  fml::AutoResetWaitableEvent* begunResizing = &begunResizingLatch;
+
+  // Initial resize: does not block until the first frame.
+  [scaffold dispatchMainTask:^{
+    [synchronizer registerView:1];
+    [synchronizer registerView:2];
+    [synchronizer beginResizeForView:1
+                                size:CGSize{5, 5}
+                              notify:^{
+                              }];
+    [synchronizer beginResizeForView:2
+                                size:CGSize{15, 15}
+                              notify:^{
+                              }];
+  }];
+  [scaffold joinMain];
+  EXPECT_FALSE([synchronizer isWaitingWhenMutexIsAvailable]);
+
+  // First frame.
+  [scaffold dispatchRenderTask:^{
+    [synchronizer performCommitForView:1
+                                  size:CGSize{5, 5}
+                                notify:^{
+                                }];
+    [synchronizer performCommitForView:2
+                                  size:CGSize{15, 15}
+                                notify:^{
+                                }];
+  }];
+  [scaffold joinRender];
+  EXPECT_FALSE([synchronizer isWaitingWhenMutexIsAvailable]);
+
+  // Resize view 2 to (17, 17): blocks until the next frame.
+  [scaffold dispatchMainTask:^{
+    [synchronizer beginResizeForView:2
+                                size:CGSize{17, 17}
+                              notify:^{
+                                begunResizing->Signal();
+                              }];
+  }];
+  begunResizing->Wait();
+  EXPECT_TRUE([synchronizer isWaitingWhenMutexIsAvailable]);
+
+  // Render view 1 with the size. Still blocking.
+  [scaffold dispatchRenderTask:^{
+    [synchronizer performCommitForView:1
+                                  size:CGSize{5, 5}
+                                notify:^{
+                                }];
+  }];
+  [scaffold joinRender];
+  EXPECT_TRUE([synchronizer isWaitingWhenMutexIsAvailable]);
+
+  // Render view 2 with the old size. Still blocking.
+  [scaffold dispatchRenderTask:^{
+    [synchronizer performCommitForView:1
+                                  size:CGSize{15, 15}
+                                notify:^{
+                                }];
+  }];
+  [scaffold joinRender];
+  EXPECT_TRUE([synchronizer isWaitingWhenMutexIsAvailable]);
+
+  // Render view 1 with the size.
+  [scaffold dispatchRenderTask:^{
+    [synchronizer performCommitForView:1
+                                  size:CGSize{5, 5}
+                                notify:^{
+                                }];
+  }];
+  [scaffold joinRender];
+  EXPECT_TRUE([synchronizer isWaitingWhenMutexIsAvailable]);
+
+  // Render view 2 with the new size. Unblocks.
+  [scaffold dispatchRenderTask:^{
+    [synchronizer performCommitForView:2
+                                  size:CGSize{17, 17}
+                                notify:^{
+                                }];
+  }];
+  [scaffold joinRender];
+  [scaffold joinMain];
+  EXPECT_FALSE([synchronizer isWaitingWhenMutexIsAvailable]);
+}
diff --git a/shell/platform/darwin/macos/framework/Source/FlutterView.h b/shell/platform/darwin/macos/framework/Source/FlutterView.h
index f581079..0c468d4 100644
--- a/shell/platform/darwin/macos/framework/Source/FlutterView.h
+++ b/shell/platform/darwin/macos/framework/Source/FlutterView.h
@@ -44,7 +44,8 @@
 - (nullable instancetype)initWithMTLDevice:(nonnull id<MTLDevice>)device
                               commandQueue:(nonnull id<MTLCommandQueue>)commandQueue
                            reshapeListener:(nonnull id<FlutterViewReshapeListener>)reshapeListener
-    NS_DESIGNATED_INITIALIZER;
+                        threadSynchronizer:(nonnull FlutterThreadSynchronizer*)threadSynchronizer
+                                    viewId:(int64_t)viewId NS_DESIGNATED_INITIALIZER;
 
 - (nullable instancetype)initWithFrame:(NSRect)frameRect
                            pixelFormat:(nullable NSOpenGLPixelFormat*)format NS_UNAVAILABLE;
@@ -59,12 +60,6 @@
 @property(readonly, nonatomic, nonnull) FlutterSurfaceManager* surfaceManager;
 
 /**
- * Must be called when shutting down. Unblocks raster thread and prevents any further
- * synchronization.
- */
-- (void)shutdown;
-
-/**
  * By default, the `FlutterSurfaceManager` creates two layers to manage Flutter
  * content, the content layer and containing layer. To set the native background
  * color, onto which the Flutter content is drawn, call this method with the
@@ -74,13 +69,3 @@
 - (void)setBackgroundColor:(nonnull NSColor*)color;
 
 @end
-
-@interface FlutterView (FlutterViewPrivate)
-
-/**
- * Returns FlutterThreadSynchronizer for this view.
- * Used for FlutterEngineTest.
- */
-- (nonnull FlutterThreadSynchronizer*)threadSynchronizer;
-
-@end
diff --git a/shell/platform/darwin/macos/framework/Source/FlutterView.mm b/shell/platform/darwin/macos/framework/Source/FlutterView.mm
index c3f802d..89bbdb9 100644
--- a/shell/platform/darwin/macos/framework/Source/FlutterView.mm
+++ b/shell/platform/darwin/macos/framework/Source/FlutterView.mm
@@ -10,6 +10,7 @@
 #import <QuartzCore/QuartzCore.h>
 
 @interface FlutterView () <FlutterSurfaceManagerDelegate> {
+  int64_t _viewId;
   __weak id<FlutterViewReshapeListener> _reshapeListener;
   FlutterThreadSynchronizer* _threadSynchronizer;
   FlutterSurfaceManager* _surfaceManager;
@@ -21,14 +22,17 @@
 
 - (instancetype)initWithMTLDevice:(id<MTLDevice>)device
                      commandQueue:(id<MTLCommandQueue>)commandQueue
-                  reshapeListener:(id<FlutterViewReshapeListener>)reshapeListener {
+                  reshapeListener:(id<FlutterViewReshapeListener>)reshapeListener
+               threadSynchronizer:(FlutterThreadSynchronizer*)threadSynchronizer
+                           viewId:(int64_t)viewId {
   self = [super initWithFrame:NSZeroRect];
   if (self) {
     [self setWantsLayer:YES];
     [self setBackgroundColor:[NSColor blackColor]];
     [self setLayerContentsRedrawPolicy:NSViewLayerContentsRedrawDuringViewResize];
+    _viewId = viewId;
     _reshapeListener = reshapeListener;
-    _threadSynchronizer = [[FlutterThreadSynchronizer alloc] init];
+    _threadSynchronizer = threadSynchronizer;
     _surfaceManager = [[FlutterSurfaceManager alloc] initWithDevice:device
                                                        commandQueue:commandQueue
                                                               layer:self.layer
@@ -38,23 +42,20 @@
 }
 
 - (void)onPresent:(CGSize)frameSize withBlock:(dispatch_block_t)block {
-  [_threadSynchronizer performCommit:frameSize notify:block];
+  [_threadSynchronizer performCommitForView:_viewId size:frameSize notify:block];
 }
 
 - (FlutterSurfaceManager*)surfaceManager {
   return _surfaceManager;
 }
 
-- (FlutterThreadSynchronizer*)threadSynchronizer {
-  return _threadSynchronizer;
-}
-
 - (void)reshaped {
   CGSize scaledSize = [self convertSizeToBacking:self.bounds.size];
-  [_threadSynchronizer beginResize:scaledSize
-                            notify:^{
-                              [_reshapeListener viewDidReshape:self];
-                            }];
+  [_threadSynchronizer beginResizeForView:_viewId
+                                     size:scaledSize
+                                   notify:^{
+                                     [_reshapeListener viewDidReshape:self];
+                                   }];
 }
 
 - (void)setBackgroundColor:(NSColor*)color {
@@ -112,9 +113,6 @@
   return YES;
 }
 
-- (void)shutdown {
-  [_threadSynchronizer shutdown];
-}
 #pragma mark - NSAccessibility overrides
 
 - (BOOL)isAccessibilityElement {
diff --git a/shell/platform/darwin/macos/framework/Source/FlutterViewController.mm b/shell/platform/darwin/macos/framework/Source/FlutterViewController.mm
index e234741..5129ef4 100644
--- a/shell/platform/darwin/macos/framework/Source/FlutterViewController.mm
+++ b/shell/platform/darwin/macos/framework/Source/FlutterViewController.mm
@@ -372,6 +372,10 @@
   std::shared_ptr<flutter::AccessibilityBridgeMac> _bridge;
 
   FlutterViewId _id;
+
+  // FlutterViewController does not actually uses the synchronizer, but only
+  // passes it to FlutterView.
+  FlutterThreadSynchronizer* _threadSynchronizer;
 }
 
 @synthesize viewId = _viewId;
@@ -541,14 +545,20 @@
   return _bridge;
 }
 
-- (void)attachToEngine:(nonnull FlutterEngine*)engine withId:(FlutterViewId)viewId {
+- (void)setUpWithEngine:(FlutterEngine*)engine
+                 viewId:(FlutterViewId)viewId
+     threadSynchronizer:(FlutterThreadSynchronizer*)threadSynchronizer {
   NSAssert(_engine == nil, @"Already attached to an engine %@.", _engine);
   _engine = engine;
   _viewId = viewId;
+  _threadSynchronizer = threadSynchronizer;
+  [_threadSynchronizer registerView:_viewId];
 }
 
 - (void)detachFromEngine {
   NSAssert(_engine != nil, @"Not attached to any engine.");
+  [_threadSynchronizer deregisterView:_viewId];
+  _threadSynchronizer = nil;
   _engine = nil;
 }
 
@@ -858,7 +868,9 @@
                                           commandQueue:(id<MTLCommandQueue>)commandQueue {
   return [[FlutterView alloc] initWithMTLDevice:device
                                    commandQueue:commandQueue
-                                reshapeListener:self];
+                                reshapeListener:self
+                             threadSynchronizer:_threadSynchronizer
+                                         viewId:_viewId];
 }
 
 - (void)onKeyboardLayoutChanged {
diff --git a/shell/platform/darwin/macos/framework/Source/FlutterViewController_Internal.h b/shell/platform/darwin/macos/framework/Source/FlutterViewController_Internal.h
index 62801f9..4ffeffa 100644
--- a/shell/platform/darwin/macos/framework/Source/FlutterViewController_Internal.h
+++ b/shell/platform/darwin/macos/framework/Source/FlutterViewController_Internal.h
@@ -39,11 +39,14 @@
 - (BOOL)isDispatchingKeyEvent:(nonnull NSEvent*)event;
 
 /**
- * Set the `engine` and `id` of this controller.
+ * Set up the controller with `engine` and `id`, and other engine-level classes.
  *
- * This method is called by FlutterEngine.
+ * This method is called by FlutterEngine. A view controller must be set up
+ * before being used, and must be set up only once until detachFromEngine:.
  */
-- (void)attachToEngine:(nonnull FlutterEngine*)engine withId:(FlutterViewId)viewId;
+- (void)setUpWithEngine:(nonnull FlutterEngine*)engine
+                 viewId:(FlutterViewId)viewId
+     threadSynchronizer:(nonnull FlutterThreadSynchronizer*)threadSynchronizer;
 
 /**
  * Reset the `engine` and `id` of this controller.
diff --git a/shell/platform/darwin/macos/framework/Source/FlutterViewTest.mm b/shell/platform/darwin/macos/framework/Source/FlutterViewTest.mm
index 15ca078..7599b66 100644
--- a/shell/platform/darwin/macos/framework/Source/FlutterViewTest.mm
+++ b/shell/platform/darwin/macos/framework/Source/FlutterViewTest.mm
@@ -8,6 +8,8 @@
 
 #import "flutter/testing/testing.h"
 
+constexpr int64_t kDefaultViewId = 0ll;
+
 @interface TestReshapeListener : NSObject <FlutterViewReshapeListener>
 
 @end
@@ -23,8 +25,11 @@
   id<MTLDevice> device = MTLCreateSystemDefaultDevice();
   id<MTLCommandQueue> queue = [device newCommandQueue];
   TestReshapeListener* listener = [[TestReshapeListener alloc] init];
+  FlutterThreadSynchronizer* threadSynchronizer = [[FlutterThreadSynchronizer alloc] init];
   FlutterView* view = [[FlutterView alloc] initWithMTLDevice:device
                                                 commandQueue:queue
-                                             reshapeListener:listener];
+                                             reshapeListener:listener
+                                          threadSynchronizer:threadSynchronizer
+                                                      viewId:kDefaultViewId];
   EXPECT_EQ([view layer:view.layer shouldInheritContentsScale:3.0 fromWindow:view.window], YES);
 }