未验证 提交 3d4c021f 编写于 作者: Y Yegor 提交者: GitHub

[canvaskit] cache and reuse platform view overlays (#23061)

上级 3d3e16e3
......@@ -30,14 +30,16 @@ class HtmlViewEmbedder {
/// The root view in the stack of mutator elements for the view id.
final Map<int?, html.Element?> _rootViews = <int?, html.Element?>{};
/// The overlay for the view id.
final Map<int, Overlay> _overlays = <int, Overlay>{};
/// 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<int, Surface> _overlays = <int, Surface>{};
/// The views that need to be recomposited into the scene on the next frame.
final Set<int> _viewsToRecomposite = <int>{};
/// The views that need to be disposed of on the next frame.
final Set<int?> _viewsToDispose = <int?>{};
final Set<int> _viewsToDispose = <int>{};
/// The list of view ids that should be composited, in order.
List<int> _compositionOrder = <int>[];
......@@ -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<int> unusedViews = Set<int>.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<Surface> _cache = <Surface>[];
/// Returns the list of cached surfaces.
///
/// Useful in tests.
List<Surface> 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<Mutator> {
@override
Iterator<Mutator> 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);
}
......@@ -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.
......
......@@ -38,6 +38,7 @@ void setUpCanvasKitTest() {
tearDown(() {
testCollector.cleanUpAfterTest();
debugResetBrowserSupportsFinalizationRegistry();
OverlayCache.instance.debugClear();
});
tearDownAll(() {
......
......@@ -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<int> platformViewIds = <int>[];
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<void>.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<void>.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<void>.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<void>.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<void> completer = Completer<void>();
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);
}
......
......@@ -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<void>.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<void>.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);
}
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册