From 3d4c021fbd922db0b25b78970c027f74bb4024ef Mon Sep 17 00:00:00 2001 From: Yegor Date: Tue, 15 Dec 2020 14:57:05 -0800 Subject: [PATCH] [canvaskit] cache and reuse platform view overlays (#23061) --- .../src/engine/canvaskit/embedded_views.dart | 127 ++++++++++++++---- .../lib/src/engine/canvaskit/surface.dart | 84 +++++++++--- lib/web_ui/test/canvaskit/common.dart | 1 + .../test/canvaskit/embedded_views_test.dart | 104 ++++++++++++++ lib/web_ui/test/canvaskit/surface_test.dart | 45 ++++++- 5 files changed, 305 insertions(+), 56 deletions(-) diff --git a/lib/web_ui/lib/src/engine/canvaskit/embedded_views.dart b/lib/web_ui/lib/src/engine/canvaskit/embedded_views.dart index a685ef49a..32d23a791 100644 --- a/lib/web_ui/lib/src/engine/canvaskit/embedded_views.dart +++ b/lib/web_ui/lib/src/engine/canvaskit/embedded_views.dart @@ -30,14 +30,16 @@ class HtmlViewEmbedder { /// The root view in the stack of mutator elements for the view id. final Map _rootViews = {}; - /// The overlay for the view id. - final Map _overlays = {}; + /// Surfaces used to draw on top of platform views, keyed by platform view ID. + /// + /// These surfaces are cached in the [OverlayCache] and reused. + final Map _overlays = {}; /// The views that need to be recomposited into the scene on the next frame. final Set _viewsToRecomposite = {}; /// The views that need to be disposed of on the next frame. - final Set _viewsToDispose = {}; + final Set _viewsToDispose = {}; /// The list of view ids that should be composited, in order. List _compositionOrder = []; @@ -115,14 +117,15 @@ class HtmlViewEmbedder { void _dispose( MethodCall methodCall, ui.PlatformMessageResponseCallback callback) { - int? viewId = methodCall.arguments; + final int? viewId = methodCall.arguments; const MethodCodec codec = StandardMethodCodec(); - if (!_views.containsKey(viewId)) { + if (viewId == null || !_views.containsKey(viewId)) { callback(codec.encodeErrorEnvelope( code: 'unknown_view', message: 'trying to dispose an unknown view', details: 'view id: $viewId', )); + return; } _viewsToDispose.add(viewId); callback(codec.encodeSuccessEnvelope(null)); @@ -339,9 +342,9 @@ class HtmlViewEmbedder { for (int i = 0; i < _compositionOrder.length; i++) { int viewId = _compositionOrder[i]; - ensureOverlayInitialized(viewId); + _ensureOverlayInitialized(viewId); final SurfaceFrame frame = - _overlays[viewId]!.surface.acquireFrame(_frameSize); + _overlays[viewId]!.acquireFrame(_frameSize); final CkCanvas canvas = frame.skiaCanvas; canvas.drawPicture( _pictureRecorders[viewId]!.endRecording(), @@ -353,12 +356,22 @@ class HtmlViewEmbedder { _compositionOrder.clear(); return; } + + final Set unusedViews = Set.from(_activeCompositionOrder); _activeCompositionOrder.clear(); for (int i = 0; i < _compositionOrder.length; i++) { int viewId = _compositionOrder[i]; + + assert( + _views.containsKey(viewId), + 'Cannot render platform view $viewId. ' + 'It has not been created, or it has been deleted.', + ); + + unusedViews.remove(viewId); html.Element platformViewRoot = _rootViews[viewId]!; - html.Element overlay = _overlays[viewId]!.surface.htmlElement!; + html.Element overlay = _overlays[viewId]!.htmlElement; platformViewRoot.remove(); skiaSceneHost!.append(platformViewRoot); overlay.remove(); @@ -366,6 +379,10 @@ class HtmlViewEmbedder { _activeCompositionOrder.add(viewId); } _compositionOrder.clear(); + + for (final int unusedViewId in unusedViews) { + _releaseOverlay(unusedViewId); + } } void disposeViews() { @@ -373,18 +390,12 @@ class HtmlViewEmbedder { return; } - for (int? viewId in _viewsToDispose) { + for (final int viewId in _viewsToDispose) { final html.Element rootView = _rootViews[viewId]!; rootView.remove(); _views.remove(viewId); _rootViews.remove(viewId); - if (_overlays[viewId] != null) { - final Overlay overlay = _overlays[viewId]!; - overlay.surface.htmlElement?.remove(); - overlay.surface.htmlElement = null; - overlay.skSurface?.dispose(); - } - _overlays.remove(viewId); + _releaseOverlay(viewId); _currentCompositionParams.remove(viewId); _clipCount.remove(viewId); _viewsToRecomposite.remove(viewId); @@ -392,14 +403,80 @@ class HtmlViewEmbedder { _viewsToDispose.clear(); } - void ensureOverlayInitialized(int viewId) { - Overlay? overlay = _overlays[viewId]; + void _releaseOverlay(int viewId) { + if (_overlays[viewId] != null) { + OverlayCache.instance.releaseOverlay(_overlays[viewId]!); + _overlays.remove(viewId); + } + } + + void _ensureOverlayInitialized(int viewId) { + // If there's an active overlay for the view ID, continue using it. + Surface? overlay = _overlays[viewId]; if (overlay != null) { return; } - Surface surface = Surface(this); - CkSurface? skSurface = surface.acquireRenderSurface(_frameSize); - _overlays[viewId] = Overlay(surface, skSurface); + + // Try reusing a cached overlay created for another platform view. + overlay = OverlayCache.instance.reserveOverlay(); + + // If nothing to reuse, create a new overlay. + if (overlay == null) { + overlay = Surface(this); + } + + _overlays[viewId] = overlay; + } +} + +/// Caches surfaces used to overlay platform views. +class OverlayCache { + static const int kDefaultCacheSize = 5; + + /// The cache singleton. + static final OverlayCache instance = OverlayCache(kDefaultCacheSize); + + OverlayCache(this.maximumSize); + + /// The cache will not grow beyond this size. + final int maximumSize; + + /// Cached surfaces, available for reuse. + final List _cache = []; + + /// Returns the list of cached surfaces. + /// + /// Useful in tests. + List get debugCachedSurfaces => _cache; + + /// Reserves an overlay from the cache, if available. + /// + /// Returns null if the cache is empty. + Surface? reserveOverlay() { + if (_cache.isEmpty) { + return null; + } + return _cache.removeLast(); + } + + /// Returns an overlay back to the cache. + /// + /// If the cache is full, the overlay is deleted. + void releaseOverlay(Surface overlay) { + overlay.htmlElement.remove(); + if (_cache.length < maximumSize) { + _cache.add(overlay); + } else { + overlay.dispose(); + } + } + + int get debugLength => _cache.length; + + void debugClear() { + for (final Surface overlay in _cache) { + overlay.dispose(); + } } } @@ -547,11 +624,3 @@ class MutatorsStack extends Iterable { @override Iterator get iterator => _mutators.reversed.iterator; } - -/// Represents a surface overlaying a platform view. -class Overlay { - final Surface surface; - final CkSurface? skSurface; - - Overlay(this.surface, this.skSurface); -} diff --git a/lib/web_ui/lib/src/engine/canvaskit/surface.dart b/lib/web_ui/lib/src/engine/canvaskit/surface.dart index f6eb47c7e..de9c75aa3 100644 --- a/lib/web_ui/lib/src/engine/canvaskit/surface.dart +++ b/lib/web_ui/lib/src/engine/canvaskit/surface.dart @@ -38,10 +38,27 @@ class Surface { Surface(this.viewEmbedder); CkSurface? _surface; - html.Element? htmlElement; + + /// If true, forces a new WebGL context to be created, even if the window + /// size is the same. This is used to restore the UI after the browser tab + /// goes dormant and loses the GL context. + bool _forceNewContext = true; + bool get debugForceNewContext => _forceNewContext; + SkGrContext? _grContext; int? _skiaCacheBytes; + /// The root HTML element for this surface. + /// + /// This element contains the canvas used to draw the UI. Unlike the canvas, + /// this element is permanent. It is never replaced or deleted, until this + /// surface is disposed of via [dispose]. + /// + /// Conversely, the canvas that lives inside this element can be swapped, for + /// example, when the screen size changes, or when the WebGL context is lost + /// due to the browser tab becoming dormant. + final html.Element htmlElement = html.Element.tag('flt-canvas-container'); + /// Specify the GPU resource cache limits. void setSkiaResourceCacheMaxBytes(int bytes) { _skiaCacheBytes = bytes; @@ -64,7 +81,7 @@ class Surface { /// /// The given [size] is in physical pixels. SurfaceFrame acquireFrame(ui.Size size) { - final CkSurface surface = acquireRenderSurface(size); + final CkSurface surface = _createOrUpdateSurfaces(size); if (surface.context != null) { canvasKit.setCurrentContext(surface.context!); @@ -77,21 +94,16 @@ class Surface { return SurfaceFrame(surface, submitCallback); } - CkSurface acquireRenderSurface(ui.Size size) { - _createOrUpdateSurfaces(size); - return _surface!; - } - void addToScene() { if (!_addedToScene) { - skiaSceneHost!.children.insert(0, htmlElement!); + skiaSceneHost!.children.insert(0, htmlElement); } _addedToScene = true; } ui.Size? _currentSize; - void _createOrUpdateSurfaces(ui.Size size) { + CkSurface _createOrUpdateSurfaces(ui.Size size) { if (size.isEmpty) { throw CanvasKitError('Cannot create surfaces of empty size.'); } @@ -99,11 +111,12 @@ class Surface { // Check if the window is shrinking in size, and if so, don't allocate a // new canvas as the previous canvas is big enough to fit everything. final ui.Size? previousSize = _currentSize; - if (previousSize != null && + if (!_forceNewContext && + previousSize != null && size.width <= previousSize.width && size.height <= previousSize.height) { // The existing surface is still reusable. - return; + return _surface!; } _currentSize = _currentSize == null @@ -116,14 +129,17 @@ class Surface { _surface?.dispose(); _surface = null; - htmlElement?.remove(); - htmlElement = null; _addedToScene = false; - _surface = _wrapHtmlCanvas(_currentSize!); + return _surface = _wrapHtmlCanvas(_currentSize!); } CkSurface _wrapHtmlCanvas(ui.Size physicalSize) { + // Clear the container, if it's not empty. + while (htmlElement.firstChild != null) { + htmlElement.firstChild!.remove(); + } + // If `physicalSize` is not precise, use a slightly bigger canvas. This way // we ensure that the rendred picture covers the entire browser window. final int pixelWidth = physicalSize.width.ceil(); @@ -146,9 +162,28 @@ class Surface { ..width = '${logicalWidth}px' ..height = '${logicalHeight}px'; - htmlElement = htmlCanvas; - if (webGLVersion == -1 || canvasKitForceCpuOnly) { - return _makeSoftwareCanvasSurface(htmlCanvas); + // When the browser tab using WebGL goes dormant the browser and/or OS may + // decide to clear GPU resources to let other tabs/programs use the GPU. + // When this happens, the browser sends the "webglcontextlost" event as a + // notification. When we receive this notification we force a new context. + // + // See also: https://www.khronos.org/webgl/wiki/HandlingContextLost + htmlCanvas.addEventListener('webglcontextlost', (event) { + print('Flutter: restoring WebGL context.'); + _forceNewContext = true; + // Force the framework to rerender the frame. + EnginePlatformDispatcher.instance.invokeOnMetricsChanged(); + event.stopPropagation(); + event.preventDefault(); + }, false); + _forceNewContext = false; + + htmlElement.append(htmlCanvas); + + if (webGLVersion == -1) { + return _makeSoftwareCanvasSurface(htmlCanvas, 'WebGL support not detected'); + } else if (canvasKitForceCpuOnly) { + return _makeSoftwareCanvasSurface(htmlCanvas, 'CPU rendering forced by application'); } else { // Try WebGL first. final int glContext = canvasKit.GetWebGLContext( @@ -162,7 +197,7 @@ class Surface { ); if (glContext == 0) { - return _makeSoftwareCanvasSurface(htmlCanvas); + return _makeSoftwareCanvasSurface(htmlCanvas, 'Failed to initialize WebGL context'); } _grContext = canvasKit.MakeGrContext(glContext); @@ -183,7 +218,7 @@ class Surface { ); if (skSurface == null) { - return _makeSoftwareCanvasSurface(htmlCanvas); + return _makeSoftwareCanvasSurface(htmlCanvas, 'Failed to initialize WebGL surface'); } return CkSurface(skSurface, _grContext, glContext); @@ -192,9 +227,11 @@ class Surface { static bool _didWarnAboutWebGlInitializationFailure = false; - CkSurface _makeSoftwareCanvasSurface(html.CanvasElement htmlCanvas) { + CkSurface _makeSoftwareCanvasSurface(html.CanvasElement htmlCanvas, String reason) { if (!_didWarnAboutWebGlInitializationFailure) { - html.window.console.warn('WARNING: failed to initialize WebGL. Falling back to CPU-only rendering.'); + html.window.console.warn( + 'WARNING: Falling back to CPU-only rendering. $reason.' + ); _didWarnAboutWebGlInitializationFailure = true; } return CkSurface( @@ -211,6 +248,11 @@ class Surface { _surface!.flush(); return true; } + + void dispose() { + htmlElement.remove(); + _surface?.dispose(); + } } /// A Dart wrapper around Skia's CkSurface. diff --git a/lib/web_ui/test/canvaskit/common.dart b/lib/web_ui/test/canvaskit/common.dart index 97a754779..45b3bbd66 100644 --- a/lib/web_ui/test/canvaskit/common.dart +++ b/lib/web_ui/test/canvaskit/common.dart @@ -38,6 +38,7 @@ void setUpCanvasKitTest() { tearDown(() { testCollector.cleanUpAfterTest(); debugResetBrowserSupportsFinalizationRegistry(); + OverlayCache.instance.debugClear(); }); tearDownAll(() { diff --git a/lib/web_ui/test/canvaskit/embedded_views_test.dart b/lib/web_ui/test/canvaskit/embedded_views_test.dart index 73030372d..3930e2d43 100644 --- a/lib/web_ui/test/canvaskit/embedded_views_test.dart +++ b/lib/web_ui/test/canvaskit/embedded_views_test.dart @@ -114,6 +114,110 @@ void testMain() { 'matrix3d(5, 0, 0, 0, 0, 5, 0, 0, 0, 0, 5, 0, 515, 515, 0, 1)', ); }); + + test('renders overlays on top of platform views', () async { + expect(OverlayCache.instance.debugLength, 0); + final CkPicture testPicture = paintPicture( + ui.Rect.fromLTRB(0, 0, 10, 10), + (CkCanvas canvas) { + canvas.drawCircle(ui.Offset(5, 5), 5, CkPaint()); + } + ); + + // Initialize all platform views to be used in the test. + final List platformViewIds = []; + for (int i = 0; i < OverlayCache.kDefaultCacheSize * 2; i++) { + ui.platformViewRegistry.registerViewFactory( + 'test-platform-view', + (viewId) => html.DivElement()..id = 'view-$i', + ); + await _createPlatformView(i, 'test-platform-view'); + platformViewIds.add(i); + } + + final EnginePlatformDispatcher dispatcher = + ui.window.platformDispatcher as EnginePlatformDispatcher; + + void renderTestScene({ required int viewCount }) { + LayerSceneBuilder sb = LayerSceneBuilder(); + sb.pushOffset(0, 0); + for (int i = 0; i < viewCount; i++) { + sb.addPicture(ui.Offset.zero, testPicture); + sb.addPlatformView(i, width: 10, height: 10); + } + dispatcher.rasterizer!.draw(sb.build().layerTree); + } + + int countCanvases() { + return domRenderer.sceneElement!.querySelectorAll('canvas').length; + } + + // Frame 1: + // Render: up to cache size platform views. + // Expect: main canvas plus platform view overlays; empty cache. + renderTestScene(viewCount: OverlayCache.kDefaultCacheSize); + expect(countCanvases(), OverlayCache.kDefaultCacheSize + 1); + expect(OverlayCache.instance.debugLength, 0); + + // Frame 2: + // Render: zero platform views. + // Expect: main canvas, no overlays; overlays in the cache. + await Future.delayed(Duration.zero); + renderTestScene(viewCount: 0); + expect(countCanvases(), 1); + expect(OverlayCache.instance.debugLength, 5); + + // Frame 3: + // Render: less than cache size platform views. + // Expect: overlays reused; cache shrinks. + await Future.delayed(Duration.zero); + renderTestScene(viewCount: OverlayCache.kDefaultCacheSize - 2); + expect(countCanvases(), OverlayCache.kDefaultCacheSize - 1); + expect(OverlayCache.instance.debugLength, 2); + + // Frame 4: + // Render: more platform views than max cache size. + // Expect: cache empty (everything reused). + await Future.delayed(Duration.zero); + renderTestScene(viewCount: OverlayCache.kDefaultCacheSize * 2); + expect(countCanvases(), OverlayCache.kDefaultCacheSize * 2 + 1); + expect(OverlayCache.instance.debugLength, 0); + + // Frame 5: + // Render: zero platform views. + // Expect: main canvas, no overlays; cache full but does not exceed limit. + await Future.delayed(Duration.zero); + renderTestScene(viewCount: 0); + expect(countCanvases(), 1); + expect(OverlayCache.instance.debugLength, 5); + + // Frame 6: + // Render: deleted platform views. + // Expect: error. + for (final int id in platformViewIds) { + final codec = StandardMethodCodec(); + final Completer completer = Completer(); + ui.window.sendPlatformMessage( + 'flutter/platform_views', + codec.encodeMethodCall(MethodCall( + 'dispose', + id, + )), + completer.complete, + ); + await completer.future; + } + + try { + renderTestScene(viewCount: platformViewIds.length); + fail('Expected to throw'); + } on AssertionError catch (error) { + expect( + error.toString(), + 'Assertion failed: "Cannot render platform view 0. It has not been created, or it has been deleted."', + ); + } + }); // TODO: https://github.com/flutter/flutter/issues/60040 }, skip: isIosSafari); } diff --git a/lib/web_ui/test/canvaskit/surface_test.dart b/lib/web_ui/test/canvaskit/surface_test.dart index 6f61bced9..b5c98bae3 100644 --- a/lib/web_ui/test/canvaskit/surface_test.dart +++ b/lib/web_ui/test/canvaskit/surface_test.dart @@ -3,6 +3,8 @@ // found in the LICENSE file. // @dart = 2.12 +import 'dart:html' as html; + import 'package:test/bootstrap/browser.dart'; import 'package:test/test.dart'; import 'package:ui/src/engine.dart'; @@ -20,19 +22,19 @@ void testMain() { test('Surface allocates canvases efficiently', () { final Surface surface = Surface(HtmlViewEmbedder()); - final CkSurface original = surface.acquireRenderSurface(ui.Size(9, 19)); + final CkSurface original = surface.acquireFrame(ui.Size(9, 19)).skiaSurface; // Expect exact requested dimensions. expect(original.width(), 9); expect(original.height(), 19); // Shrinking reuses the existing surface straight-up. - final CkSurface shrunk = surface.acquireRenderSurface(ui.Size(5, 15)); + final CkSurface shrunk = surface.acquireFrame(ui.Size(5, 15)).skiaSurface; expect(shrunk, same(original)); // The first increase will allocate a new surface, but will overallocate // by 40% to accommodate future increases. - final CkSurface firstIncrease = surface.acquireRenderSurface(ui.Size(10, 20)); + final CkSurface firstIncrease = surface.acquireFrame(ui.Size(10, 20)).skiaSurface; expect(firstIncrease, isNot(same(original))); // Expect overallocated dimensions @@ -40,11 +42,11 @@ void testMain() { expect(firstIncrease.height(), 28); // Subsequent increases within 40% reuse the old surface. - final CkSurface secondIncrease = surface.acquireRenderSurface(ui.Size(11, 22)); + final CkSurface secondIncrease = surface.acquireFrame(ui.Size(11, 22)).skiaSurface; expect(secondIncrease, same(firstIncrease)); // Increases beyond the 40% limit will cause a new allocation. - final CkSurface huge = surface.acquireRenderSurface(ui.Size(20, 40)); + final CkSurface huge = surface.acquireFrame(ui.Size(20, 40)).skiaSurface; expect(huge, isNot(same(firstIncrease))); // Also over-allocated @@ -52,8 +54,39 @@ void testMain() { expect(huge.height(), 56); // Shrink again. Reuse the last allocated surface. - final CkSurface shrunk2 = surface.acquireRenderSurface(ui.Size(5, 15)); + final CkSurface shrunk2 = surface.acquireFrame(ui.Size(5, 15)).skiaSurface; expect(shrunk2, same(huge)); }); + + test( + 'Surface creates new context when WebGL context is lost', + () async { + final Surface surface = Surface(HtmlViewEmbedder()); + expect(surface.debugForceNewContext, isTrue); + final CkSurface before = surface.acquireFrame(ui.Size(9, 19)).skiaSurface; + expect(surface.debugForceNewContext, isFalse); + + // Pump a timer to flush any microtasks. + await Future.delayed(Duration.zero); + final CkSurface afterAcquireFrame = surface.acquireFrame(ui.Size(9, 19)).skiaSurface; + // Existing context is reused. + expect(afterAcquireFrame, same(before)); + + // Emulate WebGL context loss. + final html.CanvasElement canvas = surface.htmlElement.children.single as html.CanvasElement; + final dynamic ctx = canvas.getContext('webgl2'); + final dynamic loseContextExtension = ctx.getExtension('WEBGL_lose_context'); + loseContextExtension.loseContext(); + + // Pump a timer to allow the "lose context" event to propagate. + await Future.delayed(Duration.zero); + expect(surface.debugForceNewContext, isTrue); + final CkSurface afterContextLost = surface.acquireFrame(ui.Size(9, 19)).skiaSurface; + // A new cotext is created. + expect(afterContextLost, isNot(same(before))); + }, + // Firefox doesn't have the WEBGL_lose_context extension. + skip: isFirefox || isIosSafari, + ); }, skip: isIosSafari); } -- GitLab