Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.

Commit c81730e

Browse files
authored
[macOS] FlutterSurfaceManager should not return surfaces that are in use (#52082)
Fixes flutter/flutter#138936 Currently the engine hold a single backbuffer per flutter view layer, which gets swapped with front buffer. This however is too optimistic, as the front buffer in some cases might not get immediately picked up by the compositor. When that happens, during next frame the cache may return a backbuffer that is in fact still being used by the compositor. Rendering to such surface will result in artifacts seen in the video. This seems more likely to happen with busy platform thread. It is also more likely to happen with the presence of "heavy" platform views such as `WKWebView`. IOSurface is able to report when it is being used by the compositor (`IOSurfaceIsInUse`). This PR ensures that the backing store cache never returns surface that is being used by compositor. This may result in transiently keeping more surfaces than before, as the compositor sometimes seems to holds on to the surface longer than it is displayed, but that's preferable outcome to visual glitches. This PR adds `age` field to `FlutterSurface`. When returning buffers to the surface cache (during compositor commit), the age of each surface currently present in cache increases by one, while the newly returned surfaces have their age set to 0. When returning surfaces from cache, a surface with lowest age that is not in use by compositor is returned. Surfaces with age that reaches 30 are evicted from the pool. Reaching this age means one of two things: - When surface is still in use at age 30 it means the compositor is holding on to it much longer than we expect. In this case just forget about the surface, we can't really do anything about it. - When surface is not in use at age 30, it means it hasn't been removed from cache for 29 subsequent frames. That means the cache is holding more surfaces than needed. Removing all surfaces from cache after idle period remains unchanged. Before: https://github.com/flutter/engine/assets/96958/ba2bfc43-525e-4a88-b37c-61842994d3bc After: https://github.com/flutter/engine/assets/96958/8b5d393e-3031-46b1-b3f0-cb7f63f8d960 *If you had to change anything in the [flutter/tests] repo, include a link to the migration guide as per the [breaking change policy].* ## 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] and the [C++, Objective-C, Java style guides]. - [x] I listed at least one issue that this PR fixes in the description above. - [x] I added new tests to check the change I am making or feature I am adding, or the PR is [test-exempt]. See [testing the engine] for instructions on writing and running engine tests. - [x] I updated/added relevant documentation (doc comments with `///`). - [x] I signed the [CLA]. - [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/wiki/Tree-hygiene#overview [Tree Hygiene]: https://github.com/flutter/flutter/wiki/Tree-hygiene [test-exempt]: https://github.com/flutter/flutter/wiki/Tree-hygiene#tests [Flutter Style Guide]: https://github.com/flutter/flutter/wiki/Style-guide-for-Flutter-repo [C++, Objective-C, Java style guides]: https://github.com/flutter/engine/blob/main/CONTRIBUTING.md#style [testing the engine]: https://github.com/flutter/flutter/wiki/Testing-the-engine [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/wiki/Tree-hygiene#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/wiki/Chat
1 parent 62cec3c commit c81730e

File tree

5 files changed

+108
-13
lines changed

5 files changed

+108
-13
lines changed

shell/platform/darwin/macos/framework/Source/FlutterSurface.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,13 @@
3232
@property(readonly, nonatomic, nonnull) IOSurfaceRef ioSurface;
3333
@property(readonly, nonatomic) CGSize size;
3434
@property(readonly, nonatomic) int64_t textureId;
35+
// Whether the surface is currently in use by the compositor.
36+
@property(readonly, nonatomic) BOOL isInUse;
3537

3638
@end
3739

40+
@interface FlutterSurface (Testing)
41+
@property(readwrite, nonatomic) BOOL isInUseOverride;
42+
@end
43+
3844
#endif // FLUTTER_SHELL_PLATFORM_DARWIN_MACOS_FRAMEWORK_SOURCE_FLUTTERSURFACE_H_

shell/platform/darwin/macos/framework/Source/FlutterSurface.mm

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ @interface FlutterSurface () {
1010
CGSize _size;
1111
IOSurfaceRef _ioSurface;
1212
id<MTLTexture> _texture;
13+
// Used for testing.
14+
BOOL _isInUseOverride;
1315
}
1416
@end
1517

@@ -27,6 +29,18 @@ - (int64_t)textureId {
2729
return reinterpret_cast<int64_t>(_texture);
2830
}
2931

32+
- (BOOL)isInUse {
33+
return _isInUseOverride || IOSurfaceIsInUse(_ioSurface);
34+
}
35+
36+
- (BOOL)isInUseOverride {
37+
return _isInUseOverride;
38+
}
39+
40+
- (void)setIsInUseOverride:(BOOL)isInUseOverride {
41+
_isInUseOverride = isInUseOverride;
42+
}
43+
3044
- (instancetype)initWithSize:(CGSize)size device:(id<MTLDevice>)device {
3145
if (self = [super init]) {
3246
self->_size = size;

shell/platform/darwin/macos/framework/Source/FlutterSurfaceManager.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@
8888
/**
8989
* Removes all cached surfaces replacing them with new ones.
9090
*/
91-
- (void)replaceSurfaces:(nonnull NSArray<FlutterSurface*>*)surfaces;
91+
- (void)returnSurfaces:(nonnull NSArray<FlutterSurface*>*)surfaces;
9292

9393
/**
9494
* Returns number of surfaces currently in cache. Used for tests.

shell/platform/darwin/macos/framework/Source/FlutterSurfaceManager.mm

Lines changed: 53 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ - (void)commit:(NSArray<FlutterSurfacePresentInfo*>*)surfaces {
153153
FML_DCHECK([NSThread isMainThread]);
154154

155155
// Release all unused back buffer surfaces and replace them with front surfaces.
156-
[_backBufferCache replaceSurfaces:_frontSurfaces];
156+
[_backBufferCache returnSurfaces:_frontSurfaces];
157157

158158
// Front surfaces will be replaced by currently presented surfaces.
159159
[_frontSurfaces removeAllObjects];
@@ -266,9 +266,15 @@ - (void)presentSurfaces:(NSArray<FlutterSurfacePresentInfo*>*)surfaces
266266

267267
// Cached back buffers will be released after kIdleDelay if there is no activity.
268268
static const double kIdleDelay = 1.0;
269+
// Once surfaces reach kEvictionAge, they will be evicted from the cache.
270+
// The age of 30 has been chosen to reduce potential surface allocation churn.
271+
// For unused surface 30 frames means only half a second at 60fps, and there is
272+
// idle timeout of 1 second where all surfaces are evicted.
273+
static const int kSurfaceEvictionAge = 30;
269274

270275
@interface FlutterBackBufferCache () {
271276
NSMutableArray<FlutterSurface*>* _surfaces;
277+
NSMapTable<FlutterSurface*, NSNumber*>* _surfaceAge;
272278
}
273279

274280
@end
@@ -278,28 +284,65 @@ @implementation FlutterBackBufferCache
278284
- (instancetype)init {
279285
if (self = [super init]) {
280286
self->_surfaces = [[NSMutableArray alloc] init];
287+
self->_surfaceAge = [NSMapTable weakToStrongObjectsMapTable];
281288
}
282289
return self;
283290
}
284291

292+
- (int)ageForSurface:(FlutterSurface*)surface {
293+
NSNumber* age = [_surfaceAge objectForKey:surface];
294+
return age != nil ? age.intValue : 0;
295+
}
296+
297+
- (void)setAge:(int)age forSurface:(FlutterSurface*)surface {
298+
[_surfaceAge setObject:@(age) forKey:surface];
299+
}
300+
285301
- (nullable FlutterSurface*)removeSurfaceForSize:(CGSize)size {
286302
@synchronized(self) {
303+
// Purge all cached surfaces if the size has changed.
304+
if (_surfaces.firstObject != nil && !CGSizeEqualToSize(_surfaces.firstObject.size, size)) {
305+
[_surfaces removeAllObjects];
306+
}
307+
308+
FlutterSurface* res;
309+
310+
// Returns youngest surface that is not in use. Returning youngest surface ensures
311+
// that the cache doesn't keep more surfaces than it needs to, as the unused surfaces
312+
// kept in cache will have their age kept increasing until purged (inside [returnSurfaces:]).
287313
for (FlutterSurface* surface in _surfaces) {
288-
if (CGSizeEqualToSize(surface.size, size)) {
289-
// By default ARC doesn't retain enumeration iteration variables.
290-
FlutterSurface* res = surface;
291-
[_surfaces removeObject:surface];
292-
return res;
314+
if (!surface.isInUse &&
315+
(res == nil || [self ageForSurface:res] > [self ageForSurface:surface])) {
316+
res = surface;
293317
}
294318
}
295-
return nil;
319+
if (res != nil) {
320+
[_surfaces removeObject:res];
321+
}
322+
return res;
296323
}
297324
}
298325

299-
- (void)replaceSurfaces:(nonnull NSArray<FlutterSurface*>*)surfaces {
326+
- (void)returnSurfaces:(nonnull NSArray<FlutterSurface*>*)returnedSurfaces {
300327
@synchronized(self) {
301-
[_surfaces removeAllObjects];
302-
[_surfaces addObjectsFromArray:surfaces];
328+
for (FlutterSurface* surface in returnedSurfaces) {
329+
[self setAge:0 forSurface:surface];
330+
}
331+
for (FlutterSurface* surface in _surfaces) {
332+
[self setAge:[self ageForSurface:surface] + 1 forSurface:surface];
333+
}
334+
335+
[_surfaces addObjectsFromArray:returnedSurfaces];
336+
337+
// Purge all surface with age = kSurfaceEvictionAge. Reaching this age can mean two things:
338+
// - Surface is still in use and we can't return it. This can happen in some edge
339+
// cases where the compositor holds on to the surface for much longer than expected.
340+
// - Surface is not in use but it hasn't been requested from the cache for a while.
341+
// This means there are too many surfaces in the cache.
342+
[_surfaces filterUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(FlutterSurface* surface,
343+
NSDictionary* bindings) {
344+
return [self ageForSurface:surface] < kSurfaceEvictionAge;
345+
}]];
303346
}
304347

305348
// performSelector:withObject:afterDelay needs to be performed on RunLoop thread

shell/platform/darwin/macos/framework/Source/FlutterSurfaceManagerTest.mm

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,13 @@ - (void)onPresent:(CGSize)frameSize withBlock:(nonnull dispatch_block_t)block {
117117
auto surfaceFromCache = [surfaceManager surfaceForSize:CGSizeMake(110, 110)];
118118
EXPECT_EQ(surfaceFromCache, surface2);
119119

120-
[surfaceManager presentSurfaces:@[] atTime:0 notify:nil];
121-
EXPECT_EQ(surfaceManager.backBufferCache.count, 1ul);
120+
// Submit empty surfaces until the one in cache gets to age >= kSurfaceEvictionAge, in which case
121+
// it should be removed.
122+
123+
for (int i = 0; i < 30 /* kSurfaceEvictionAge */; ++i) {
124+
[surfaceManager presentSurfaces:@[] atTime:0 notify:nil];
125+
EXPECT_EQ(surfaceManager.backBufferCache.count, 1ul);
126+
}
122127

123128
[surfaceManager presentSurfaces:@[] atTime:0 notify:nil];
124129
EXPECT_EQ(surfaceManager.backBufferCache.count, 0ul);
@@ -163,6 +168,33 @@ - (void)onPresent:(CGSize)frameSize withBlock:(nonnull dispatch_block_t)block {
163168
EXPECT_EQ(surface3, surface1);
164169
}
165170

171+
TEST(FlutterSurfaceManager, BackingStoreCacheSurfaceStuckInUse) {
172+
TestView* testView = [[TestView alloc] init];
173+
FlutterSurfaceManager* surfaceManager = CreateSurfaceManager(testView);
174+
175+
auto surface1 = [surfaceManager surfaceForSize:CGSizeMake(100, 100)];
176+
177+
[surfaceManager presentSurfaces:@[ CreatePresentInfo(surface1) ] atTime:0 notify:nil];
178+
// Pretend that compositor is holding on to the surface. The surface will be kept
179+
// in cache until the age of kSurfaceEvictionAge is reached, and then evicted.
180+
surface1.isInUseOverride = YES;
181+
182+
auto surface2 = [surfaceManager surfaceForSize:CGSizeMake(100, 100)];
183+
[surfaceManager presentSurfaces:@[ CreatePresentInfo(surface2) ] atTime:0 notify:nil];
184+
EXPECT_EQ(surfaceManager.backBufferCache.count, 1ul);
185+
186+
for (int i = 0; i < 30 /* kSurfaceEvictionAge */ - 1; ++i) {
187+
auto surface3 = [surfaceManager surfaceForSize:CGSizeMake(100, 100)];
188+
[surfaceManager presentSurfaces:@[ CreatePresentInfo(surface3) ] atTime:0 notify:nil];
189+
EXPECT_EQ(surfaceManager.backBufferCache.count, 2ul);
190+
}
191+
192+
auto surface4 = [surfaceManager surfaceForSize:CGSizeMake(100, 100)];
193+
[surfaceManager presentSurfaces:@[ CreatePresentInfo(surface4) ] atTime:0 notify:nil];
194+
// Surface in use should bet old enough at this point to be evicted.
195+
EXPECT_EQ(surfaceManager.backBufferCache.count, 1ul);
196+
}
197+
166198
inline bool operator==(const CGRect& lhs, const CGRect& rhs) {
167199
return CGRectEqualToRect(lhs, rhs);
168200
}

0 commit comments

Comments
 (0)