未验证 提交 58459a5e 编写于 作者: H Harry Terkelsen 提交者: GitHub

[canvaskit] Set a maximum number of overlay canvases. (#25994)

上级 bb55051d
......@@ -490,6 +490,7 @@ FILE: ../../../flutter/lib/web_ui/lib/src/engine/canvaskit/rasterizer.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/canvaskit/shader.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/canvaskit/skia_object_cache.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/canvaskit/surface.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/canvaskit/surface_factory.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/canvaskit/text.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/canvaskit/util.dart
FILE: ../../../flutter/lib/web_ui/lib/src/engine/canvaskit/vertices.dart
......
......@@ -204,9 +204,10 @@ export 'engine/canvaskit/shader.dart';
export 'engine/canvaskit/skia_object_cache.dart';
import 'engine/canvaskit/surface.dart';
export 'engine/canvaskit/surface.dart';
export 'engine/canvaskit/surface_factory.dart';
export 'engine/canvaskit/text.dart';
export 'engine/canvaskit/util.dart';
......
......@@ -4,7 +4,7 @@
import 'dart:html' as html;
import 'package:ui/src/engine.dart' show window, NullTreeSanitizer, platformViewManager, createPlatformViewSlot;
import 'package:ui/src/engine.dart';
import 'package:ui/ui.dart' as ui;
import '../html/path_to_svg_clip.dart';
......@@ -15,9 +15,28 @@ import 'initialization.dart';
import 'path.dart';
import 'picture_recorder.dart';
import 'surface.dart';
import 'surface_factory.dart';
/// This composites HTML views into the [ui.Scene].
class HtmlViewEmbedder {
/// The [HtmlViewEmbedder] singleton.
static HtmlViewEmbedder instance = HtmlViewEmbedder._();
HtmlViewEmbedder._();
/// The maximum number of overlay surfaces that can be live at once.
static const int maximumOverlaySurfaces = const int.fromEnvironment(
'FLUTTER_WEB_MAXIMUM_OVERLAYS',
defaultValue: 8,
);
/// The picture recorder shared by all platform views which paint to the
/// backup surface.
CkPictureRecorder? _backupPictureRecorder;
/// The set of platform views using the backup surface.
final Set<int> _viewsUsingBackupSurface = <int>{};
/// A picture recorder associated with a view id.
///
/// When we composite in the platform view, we need to create a new canvas
......@@ -69,19 +88,31 @@ class HtmlViewEmbedder {
}
List<CkCanvas> getCurrentCanvases() {
final List<CkCanvas> canvases = <CkCanvas>[];
final Set<CkCanvas> canvases = <CkCanvas>{};
for (int i = 0; i < _compositionOrder.length; i++) {
final int viewId = _compositionOrder[i];
canvases.add(_pictureRecorders[viewId]!.recordingCanvas!);
}
return canvases;
return canvases.toList();
}
void prerollCompositeEmbeddedView(int viewId, EmbeddedViewParams params) {
final pictureRecorder = CkPictureRecorder();
pictureRecorder.beginRecording(ui.Offset.zero & _frameSize);
pictureRecorder.recordingCanvas!.clear(ui.Color(0x00000000));
_pictureRecorders[viewId] = pictureRecorder;
_ensureOverlayInitialized(viewId);
if (_viewsUsingBackupSurface.contains(viewId)) {
if (_backupPictureRecorder == null) {
// Only initialize the picture recorder for the backup surface once.
final pictureRecorder = CkPictureRecorder();
pictureRecorder.beginRecording(ui.Offset.zero & _frameSize);
pictureRecorder.recordingCanvas!.clear(ui.Color(0x00000000));
_backupPictureRecorder = pictureRecorder;
}
_pictureRecorders[viewId] = _backupPictureRecorder!;
} else {
final pictureRecorder = CkPictureRecorder();
pictureRecorder.beginRecording(ui.Offset.zero & _frameSize);
pictureRecorder.recordingCanvas!.clear(ui.Color(0x00000000));
_pictureRecorders[viewId] = pictureRecorder;
}
_compositionOrder.add(viewId);
// Do nothing if the params didn't change.
......@@ -113,9 +144,9 @@ class HtmlViewEmbedder {
// See `apply()` in the PersistedPlatformView class for the HTML version
// of this code.
slot.style
..width = '${params.size.width}px'
..height = '${params.size.height}px'
..position = 'absolute';
..width = '${params.size.width}px'
..height = '${params.size.height}px'
..position = 'absolute';
// Recompute the position in the DOM of the `slot` element...
final int currentClippingCount = _countClips(params.mutators);
......@@ -158,21 +189,21 @@ class HtmlViewEmbedder {
indexInFlutterView = skiaSceneHost!.children.indexOf(headClipView);
headClipView.remove();
}
html.Element? head = platformView;
html.Element head = platformView;
int clipIndex = 0;
// Re-use as much existing clip views as needed.
while (head != headClipView && clipIndex < numClips) {
head = head!.parent;
head = head.parent!;
clipIndex++;
}
// If there weren't enough existing clip views, add more.
while (clipIndex < numClips) {
html.Element clippingView = html.Element.tag('flt-clip');
clippingView.append(head!);
clippingView.append(head);
head = clippingView;
clipIndex++;
}
head!.remove();
head.remove();
// If the chain was previously attached, attach it to the same position.
if (indexInFlutterView > -1) {
......@@ -321,15 +352,28 @@ class HtmlViewEmbedder {
}
void submitFrame() {
bool _didPaintBackupSurface = false;
for (int i = 0; i < _compositionOrder.length; i++) {
int viewId = _compositionOrder[i];
_ensureOverlayInitialized(viewId);
final SurfaceFrame frame = _overlays[viewId]!.acquireFrame(_frameSize);
final CkCanvas canvas = frame.skiaCanvas;
canvas.drawPicture(
_pictureRecorders[viewId]!.endRecording(),
);
frame.submit();
if (_viewsUsingBackupSurface.contains(viewId)) {
// Only draw the picture to the backup surface once.
if (!_didPaintBackupSurface) {
SurfaceFrame backupFrame =
SurfaceFactory.instance.backupSurface.acquireFrame(_frameSize);
backupFrame.skiaCanvas
.drawPicture(_backupPictureRecorder!.endRecording());
_backupPictureRecorder = null;
backupFrame.submit();
_didPaintBackupSurface = true;
}
} else {
final SurfaceFrame frame = _overlays[viewId]!.acquireFrame(_frameSize);
final CkCanvas canvas = frame.skiaCanvas;
canvas.drawPicture(
_pictureRecorders[viewId]!.endRecording(),
);
frame.submit();
}
}
_pictureRecorders.clear();
if (listEquals(_compositionOrder, _activeCompositionOrder)) {
......@@ -379,8 +423,8 @@ class HtmlViewEmbedder {
void disposeViews(Set<int> viewsToDispose) {
for (final int viewId in viewsToDispose) {
// Remove viewId from the _viewClipChains Map, and then from the DOM.
ViewClipChain clipChain = _viewClipChains.remove(viewId)!;
clipChain.root.remove();
ViewClipChain? clipChain = _viewClipChains.remove(viewId);
clipChain?.root.remove();
// More cleanup
_releaseOverlay(viewId);
_currentCompositionParams.remove(viewId);
......@@ -392,26 +436,42 @@ class HtmlViewEmbedder {
void _releaseOverlay(int viewId) {
if (_overlays[viewId] != null) {
OverlayCache.instance.releaseOverlay(_overlays[viewId]!);
_overlays.remove(viewId);
Surface overlay = _overlays[viewId]!;
if (overlay == SurfaceFactory.instance.backupSurface) {
assert(_viewsUsingBackupSurface.contains(viewId));
_viewsUsingBackupSurface.remove(viewId);
_overlays.remove(viewId);
// If no views use the backup surface, then we can release it. This
// happens when the number of live platform views drops below the
// maximum overlay surfaces, so the backup surface is no longer needed.
if (_viewsUsingBackupSurface.isEmpty) {
SurfaceFactory.instance.releaseSurface(overlay);
}
} else {
SurfaceFactory.instance.releaseSurface(overlay);
_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) {
if (overlay != null && !_viewsUsingBackupSurface.contains(viewId)) {
return;
}
// 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);
// If this view was using the backup surface, try to release the backup
// surface and see if a non-backup surface became available.
if (_viewsUsingBackupSurface.contains(viewId)) {
_releaseOverlay(viewId);
}
// Try reusing a cached overlay created for another platform view.
overlay = SurfaceFactory.instance.getSurface();
if (overlay == SurfaceFactory.instance.backupSurface) {
_viewsUsingBackupSurface.add(viewId);
}
_overlays[viewId] = overlay;
}
......@@ -422,6 +482,24 @@ class HtmlViewEmbedder {
});
_svgClipDefs.clear();
}
/// Clears the state of this view embedder. Used in tests.
void debugClear() {
final Set<int> allViews = platformViewManager.debugClear();
disposeViews(allViews);
_backupPictureRecorder?.endRecording();
_backupPictureRecorder = null;
_viewsUsingBackupSurface.clear();
_pictureRecorders.clear();
_currentCompositionParams.clear();
debugCleanupSvgClipPaths();
_currentCompositionParams.clear();
_viewClipChains.clear();
_overlays.clear();
_viewsToRecomposite.clear();
_activeCompositionOrder.clear();
_compositionOrder.clear();
}
}
/// Represents a Clip Chain (for a view).
......@@ -435,7 +513,9 @@ class ViewClipChain {
html.Element _slot;
int _clipCount = -1;
ViewClipChain({required html.Element view}) : this._root = view, this._slot = view;
ViewClipChain({required html.Element view})
: this._root = view,
this._slot = view;
html.Element get root => _root;
html.Element get slot => _slot;
......@@ -447,58 +527,6 @@ class ViewClipChain {
}
}
/// 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();
}
_cache.clear();
}
}
/// The parameters passed to the view embedder.
class EmbeddedViewParams {
EmbeddedViewParams(this.offset, this.size, MutatorsStack mutators)
......
......@@ -6,19 +6,18 @@ import 'package:ui/src/engine.dart' show frameReferences;
import 'package:ui/ui.dart' as ui;
import 'canvas.dart';
import 'embedded_views.dart';
import 'layer_tree.dart';
import 'surface.dart';
import 'surface_factory.dart';
/// A class that can rasterize [LayerTree]s into a given [Surface].
class Rasterizer {
final Surface surface;
final CompositorContext context = CompositorContext();
final List<ui.VoidCallback> _postFrameCallbacks = <ui.VoidCallback>[];
Rasterizer(this.surface);
void setSkiaResourceCacheMaxBytes(int bytes) =>
surface.setSkiaResourceCacheMaxBytes(bytes);
SurfaceFactory.instance.baseSurface.setSkiaResourceCacheMaxBytes(bytes);
/// Creates a new frame from this rasterizer's surface, draws the given
/// [LayerTree] into it, and then submits the frame.
......@@ -29,16 +28,17 @@ class Rasterizer {
return;
}
final SurfaceFrame frame = surface.acquireFrame(layerTree.frameSize);
surface.viewEmbedder.frameSize = layerTree.frameSize;
final SurfaceFrame frame =
SurfaceFactory.instance.baseSurface.acquireFrame(layerTree.frameSize);
HtmlViewEmbedder.instance.frameSize = layerTree.frameSize;
final CkCanvas canvas = frame.skiaCanvas;
final Frame compositorFrame =
context.acquireFrame(canvas, surface.viewEmbedder);
context.acquireFrame(canvas, HtmlViewEmbedder.instance);
compositorFrame.raster(layerTree, ignoreRasterCache: true);
surface.addToScene();
SurfaceFactory.instance.baseSurface.addToScene();
frame.submit();
surface.viewEmbedder.submitFrame();
HtmlViewEmbedder.instance.submitFrame();
} finally {
_runPostFrameCallbacks();
}
......
......@@ -11,8 +11,8 @@ import '../browser_detection.dart';
import '../util.dart';
import 'canvas.dart';
import 'canvaskit_api.dart';
import 'embedded_views.dart';
import 'initialization.dart';
import 'surface_factory.dart';
import 'util.dart';
typedef SubmitCallback = bool Function(SurfaceFrame, CkCanvas);
......@@ -45,7 +45,7 @@ class SurfaceFrame {
/// successive frames if they are the same size. Otherwise, a new [CkSurface] is
/// created.
class Surface {
Surface(this.viewEmbedder);
Surface();
CkSurface? _surface;
......@@ -55,6 +55,24 @@ class Surface {
bool _forceNewContext = true;
bool get debugForceNewContext => _forceNewContext;
bool _contextLost = false;
bool get debugContextLost => _contextLost;
/// A cached copy of the most recently created `webglcontextlost` listener.
///
/// We must cache this function because each time we access the tear-off it
/// creates a new object, meaning we won't be able to remove this listener
/// later.
void Function(html.Event)? _cachedContextLostListener;
/// A cached copy of the most recently created `webglcontextrestored`
/// listener.
///
/// We must cache this function because each time we access the tear-off it
/// creates a new object, meaning we won't be able to remove this listener
/// later.
void Function(html.Event)? _cachedContextRestoredListener;
SkGrContext? _grContext;
int? _skiaCacheBytes;
......@@ -88,10 +106,6 @@ class Surface {
bool _addedToScene = false;
/// The default view embedder. Coordinates embedding platform views and
/// overlaying subsequent draw operations on top.
final HtmlViewEmbedder viewEmbedder;
/// Acquire a frame of the given [size] containing a drawable canvas.
///
/// The given [size] is in physical pixels.
......@@ -171,12 +185,51 @@ class Surface {
..height = '${logicalHeight}px';
}
void _contextRestoredListener(html.Event event) {
assert(
_contextLost,
'Received "webglcontextrestored" event but never received '
'a "webglcontextlost" event.');
_contextLost = false;
// Force the framework to rerender the frame.
EnginePlatformDispatcher.instance.invokeOnMetricsChanged();
event.stopPropagation();
event.preventDefault();
}
void _contextLostListener(html.Event event) {
assert(event.target == this.htmlCanvas,
'Received a context lost event for a disposed canvas');
final SurfaceFactory factory = SurfaceFactory.instance;
_contextLost = true;
if (factory.isLive(this)) {
_forceNewContext = true;
event.preventDefault();
} else {
dispose();
}
}
/// This function is expensive.
///
/// It's better to reuse surface if possible.
CkSurface _createNewSurface(ui.Size physicalSize) {
// Clear the container, if it's not empty. We're going to create a new <canvas>.
this.htmlCanvas?.remove();
if (this.htmlCanvas != null) {
this.htmlCanvas!.removeEventListener(
'webglcontextrestored',
_cachedContextRestoredListener,
false,
);
this.htmlCanvas!.removeEventListener(
'webglcontextlost',
_cachedContextLostListener,
false,
);
this.htmlCanvas!.remove();
_cachedContextRestoredListener = null;
_cachedContextLostListener = null;
}
// If `physicalSize` is not precise, use a slightly bigger canvas. This way
// we ensure that the rendred picture covers the entire browser window.
......@@ -196,15 +249,20 @@ class Surface {
// 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);
_cachedContextRestoredListener = _contextRestoredListener;
_cachedContextLostListener = _contextLostListener;
htmlCanvas.addEventListener(
'webglcontextlost',
_cachedContextLostListener,
false,
);
htmlCanvas.addEventListener(
'webglcontextrestored',
_cachedContextRestoredListener,
false,
);
_forceNewContext = false;
_contextLost = false;
htmlElement.append(htmlCanvas);
......@@ -282,6 +340,12 @@ class Surface {
}
void dispose() {
htmlCanvas?.removeEventListener(
'webglcontextlost', _cachedContextLostListener, false);
htmlCanvas?.removeEventListener(
'webglcontextrestored', _cachedContextRestoredListener, false);
_cachedContextLostListener = null;
_cachedContextRestoredListener = null;
htmlElement.remove();
_surface?.dispose();
}
......@@ -296,6 +360,7 @@ class CkSurface {
CkSurface(this._surface, this._grContext, this._glContext);
CkCanvas getCanvas() {
assert(!_isDisposed, 'Attempting to use the canvas of a disposed surface');
return CkCanvas(_surface.getCanvas());
}
......
// 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 'package:meta/meta.dart';
import 'package:ui/src/engine.dart';
import 'embedded_views.dart';
import 'surface.dart';
/// Caches surfaces used to overlay platform views.
class SurfaceFactory {
/// The cache singleton.
static final SurfaceFactory instance =
SurfaceFactory(HtmlViewEmbedder.maximumOverlaySurfaces);
SurfaceFactory(this.maximumSurfaces)
: assert(maximumSurfaces >= 2,
'The maximum number of surfaces must be at least 2');
/// The base surface to paint on. This is the default surface which will be
/// painted to. If there are no platform views, then this surface will receive
/// all painting commands.
final Surface baseSurface = Surface();
/// The shared backup surface
final Surface backupSurface = Surface();
/// The maximum number of surfaces which can be live at once.
final int maximumSurfaces;
/// Surfaces created by this factory which are currently in use.
final List<Surface> _liveSurfaces = <Surface>[];
/// Surfaces created by this factory which are no longer in use. These can be
/// reused.
final List<Surface> _cache = <Surface>[];
/// The number of surfaces which have been created by this factory.
int get _surfaceCount => _liveSurfaces.length + _cache.length + 2;
/// The number of surfaces created by this factory. Used for testing.
@visibleForTesting
int get debugSurfaceCount => _surfaceCount;
/// Returns the number of cached surfaces.
///
/// Useful in tests.
int get debugCacheSize => _cache.length;
/// Whether or not we have already emitted a warning about creating too many
/// surfaces.
bool _warnedAboutTooManySurfaces = false;
/// Gets a [Surface] which is ready to paint to.
///
/// If there are available surfaces in the cache, then this will return one of
/// them. If this factory hasn't yet created [maximumSurfaces] surfaces, then a
/// new one will be created. If this factory has already created [maximumSurfaces]
/// surfaces, then this will return a backup surface which will be returned by
/// all subsequent calls to [getSurface] until some surfaces have been
/// released with [releaseSurface].
Surface getSurface() {
if (_cache.isNotEmpty) {
final surface = _cache.removeLast();
_liveSurfaces.add(surface);
return surface;
} else if (debugSurfaceCount < maximumSurfaces) {
final surface = Surface();
_liveSurfaces.add(surface);
return surface;
} else {
if (!_warnedAboutTooManySurfaces) {
_warnedAboutTooManySurfaces = true;
printWarning('Flutter was unable to create enough overlay surfaces. '
'This is usually caused by too many platform views being '
'displayed at once. '
'You may experience incorrect rendering.');
}
return backupSurface;
}
}
/// Signals that a surface is no longer being used. It can be reused.
void releaseSurface(Surface surface) {
assert(surface != baseSurface, 'Attempting to release the base surface');
if (surface == backupSurface) {
// If it's the backup surface, just remove it from the DOM.
surface.htmlElement.remove();
return;
}
assert(
_liveSurfaces.contains(surface),
'Attempting to release a Surface which '
'was not created by this factory');
surface.htmlElement.remove();
_liveSurfaces.remove(surface);
_cache.add(surface);
}
/// Returns [true] if [surface] is currently being used to paint content.
///
/// The base surface and backup surface always count as live.
///
/// If a surface is not live, then it must be in the cache and ready to be
/// reused.
bool isLive(Surface surface) {
if (surface == baseSurface ||
surface == backupSurface ||
_liveSurfaces.contains(surface)) {
return true;
}
assert(_cache.contains(surface));
return false;
}
/// Dispose all surfaces created by this factory. Used in tests.
void debugClear() {
for (final Surface surface in _cache) {
surface.dispose();
}
for (final Surface surface in _liveSurfaces) {
surface.dispose();
}
_liveSurfaces.clear();
_cache.clear();
}
}
......@@ -452,11 +452,11 @@ class EnginePlatformDispatcher extends ui.PlatformDispatcher {
case 'flutter/platform_views':
_platformViewMessageHandler ??= PlatformViewMessageHandler(
contentManager: platformViewManager,
contentHandler: (html.Element content) {
domRenderer.glassPaneElement!.append(content);
}
);
contentManager: platformViewManager,
contentHandler: (html.Element content) {
domRenderer.glassPaneElement!.append(content);
},
);
_platformViewMessageHandler!.handlePlatformViewCall(data, callback!);
return;
......@@ -923,8 +923,7 @@ class EnginePlatformDispatcher extends ui.PlatformDispatcher {
String? _defaultRouteName;
@visibleForTesting
late Rasterizer? rasterizer =
useCanvasKit ? Rasterizer(Surface(HtmlViewEmbedder())) : null;
late Rasterizer? rasterizer = useCanvasKit ? Rasterizer() : null;
/// In Flutter, platform messages are exchanged between threads so the
/// messages and responses have to be exchanged asynchronously. We simulate
......
......@@ -11,6 +11,7 @@ typedef ParameterizedPlatformViewFactory = html.Element Function(
int viewId, {
Object? params,
});
/// A function which takes a unique `id` and creates an HTML element.
///
/// This is made available to end-users through dart:ui in web.
......@@ -152,4 +153,17 @@ class PlatformViewManager {
content.style.width = '100%';
}
}
/// Clears the state. Used in tests.
///
/// Returns the set of know view ids, so they can be cleaned up.
Set<int> debugClear() {
final Set<int> result = _contents.keys.toSet();
for (int viewId in result) {
clearPlatformView(viewId);
}
_factories.clear();
_contents.clear();
return result;
}
}
......@@ -5,7 +5,7 @@
part of engine;
/// When set to true, all platform messages will be printed to the console.
const bool/*!*/ _debugPrintPlatformMessages = false;
const bool /*!*/ _debugPrintPlatformMessages = false;
/// Whether [_customUrlStrategy] has been set or not.
///
......@@ -24,7 +24,8 @@ set customUrlStrategy(UrlStrategy? strategy) {
/// The Web implementation of [ui.SingletonFlutterWindow].
class EngineFlutterWindow extends ui.SingletonFlutterWindow {
EngineFlutterWindow(this._windowId, this.platformDispatcher) {
final EnginePlatformDispatcher engineDispatcher = platformDispatcher as EnginePlatformDispatcher;
final EnginePlatformDispatcher engineDispatcher =
platformDispatcher as EnginePlatformDispatcher;
engineDispatcher._windows[_windowId] = this;
engineDispatcher._windowConfigurations[_windowId] = ui.ViewConfiguration();
if (_isUrlStrategySet) {
......@@ -48,15 +49,15 @@ class EngineFlutterWindow extends ui.SingletonFlutterWindow {
}
UrlStrategy? get _urlStrategyForInitialization {
final UrlStrategy? urlStrategy = _isUrlStrategySet
? _customUrlStrategy
: _createDefaultUrlStrategy();
final UrlStrategy? urlStrategy =
_isUrlStrategySet ? _customUrlStrategy : _createDefaultUrlStrategy();
// Prevent any further customization of URL strategy.
_isUrlStrategySet = true;
return urlStrategy;
}
BrowserHistory? _browserHistory; // Must be either SingleEntryBrowserHistory or MultiEntriesBrowserHistory.
BrowserHistory?
_browserHistory; // Must be either SingleEntryBrowserHistory or MultiEntriesBrowserHistory.
Future<void> _useSingleEntryBrowserHistory() async {
if (_browserHistory is SingleEntryBrowserHistory) {
......@@ -141,9 +142,11 @@ class EngineFlutterWindow extends ui.SingletonFlutterWindow {
@override
ui.ViewConfiguration get viewConfiguration {
final EnginePlatformDispatcher engineDispatcher = platformDispatcher as EnginePlatformDispatcher;
final EnginePlatformDispatcher engineDispatcher =
platformDispatcher as EnginePlatformDispatcher;
assert(engineDispatcher._windowConfigurations.containsKey(_windowId));
return engineDispatcher._windowConfigurations[_windowId] ?? ui.ViewConfiguration();
return engineDispatcher._windowConfigurations[_windowId] ??
ui.ViewConfiguration();
}
@override
......@@ -185,8 +188,10 @@ class EngineFlutterWindow extends ui.SingletonFlutterWindow {
/// accurate physical size. VisualViewport api is only used during
/// text editing to make sure inset is correctly reported to
/// framework.
final double docWidth = html.document.documentElement!.clientWidth.toDouble();
final double docHeight = html.document.documentElement!.clientHeight.toDouble();
final double docWidth =
html.document.documentElement!.clientWidth.toDouble();
final double docHeight =
html.document.documentElement!.clientHeight.toDouble();
windowInnerWidth = docWidth * devicePixelRatio;
windowInnerHeight = docHeight * devicePixelRatio;
} else {
......@@ -204,12 +209,18 @@ class EngineFlutterWindow extends ui.SingletonFlutterWindow {
}
}
/// Forces the window to recompute its physical size. Useful for tests.
void debugForceResize() {
_computePhysicalSize();
}
void computeOnScreenKeyboardInsets(bool isEditingOnMobile) {
double windowInnerHeight;
final html.VisualViewport? viewport = html.window.visualViewport;
if (viewport != null) {
if (operatingSystem == OperatingSystem.iOs && !isEditingOnMobile) {
windowInnerHeight = html.document.documentElement!.clientHeight * devicePixelRatio;
windowInnerHeight =
html.document.documentElement!.clientHeight * devicePixelRatio;
} else {
windowInnerHeight = viewport.height!.toDouble() * devicePixelRatio;
}
......@@ -294,10 +305,14 @@ UrlStrategy? _createDefaultUrlStrategy() {
/// The Web implementation of [ui.SingletonFlutterWindow].
class EngineSingletonFlutterWindow extends EngineFlutterWindow {
EngineSingletonFlutterWindow(Object windowId, ui.PlatformDispatcher platformDispatcher) : super(windowId, platformDispatcher);
EngineSingletonFlutterWindow(
Object windowId, ui.PlatformDispatcher platformDispatcher)
: super(windowId, platformDispatcher);
@override
double get devicePixelRatio => _debugDevicePixelRatio ?? EnginePlatformDispatcher.browserDevicePixelRatio;
double get devicePixelRatio =>
_debugDevicePixelRatio ??
EnginePlatformDispatcher.browserDevicePixelRatio;
/// Overrides the default device pixel ratio.
///
......@@ -319,9 +334,11 @@ class EngineFlutterWindowView extends ui.FlutterWindow {
@override
ui.ViewConfiguration get viewConfiguration {
final EnginePlatformDispatcher engineDispatcher = platformDispatcher as EnginePlatformDispatcher;
final EnginePlatformDispatcher engineDispatcher =
platformDispatcher as EnginePlatformDispatcher;
assert(engineDispatcher._windowConfigurations.containsKey(_viewId));
return engineDispatcher._windowConfigurations[_viewId] ?? ui.ViewConfiguration();
return engineDispatcher._windowConfigurations[_viewId] ??
ui.ViewConfiguration();
}
}
......@@ -330,7 +347,8 @@ class EngineFlutterWindowView extends ui.FlutterWindow {
/// `dart:ui` window delegates to this value. However, this value has a wider
/// API surface, providing Web-specific functionality that the standard
/// `dart:ui` version does not.
final EngineSingletonFlutterWindow window = EngineSingletonFlutterWindow(0, EnginePlatformDispatcher.instance);
final EngineSingletonFlutterWindow window =
EngineSingletonFlutterWindow(0, EnginePlatformDispatcher.instance);
/// The Web implementation of [ui.WindowPadding].
class WindowPadding implements ui.WindowPadding {
......
......@@ -127,7 +127,7 @@ void testMain() {
psl.preroll(
PrerollContext(
RasterCache(),
HtmlViewEmbedder(),
HtmlViewEmbedder.instance,
),
Matrix4.identity(),
);
......@@ -218,7 +218,7 @@ void testMain() {
buildTestScene(paintShadowBounds: false).rootLayer.preroll(
PrerollContext(
RasterCache(),
HtmlViewEmbedder(),
HtmlViewEmbedder.instance,
),
Matrix4.identity(),
);
......@@ -676,9 +676,9 @@ void testMain() {
await testSampleText(
'chinese',
'也称乱数假文或者哑元文本, '
'是印刷及排版领域所常用的虚拟文字。'
'由于曾经一台匿名的打印机刻意打乱了'
'一盒印刷字体从而造出一本字体样品书',
'是印刷及排版领域所常用的虚拟文字。'
'由于曾经一台匿名的打印机刻意打乱了'
'一盒印刷字体从而造出一本字体样品书',
);
});
......@@ -768,17 +768,17 @@ void testMain() {
await testSampleText(
'multilingual',
'也称乱数假文或者哑元文本, 是印刷及排版领域所常用的虚拟文字。 '
'տպագրության և տպագրական արդյունաբերության համար '
'është një tekst shabllon i industrisë së printimit '
' زمن طويل وهي أن المحتوى المقروء لصفحة ما سيلهي '
'е елементарен примерен текст използван в печатарската '
'és un text de farciment usat per la indústria de la '
'Lorem Ipsum is simply dummy text of the printing '
'είναι απλά ένα κείμενο χωρίς νόημα για τους επαγγελματίες '
' זוהי עובדה מבוססת שדעתו של הקורא תהיה מוסחת על ידי טקטס קריא '
'छपाई और अक्षर योजन उद्योग का एक साधारण डमी पाठ है सन '
'คือ เนื้อหาจำลองแบบเรียบๆ ที่ใช้กันในธุรกิจงานพิมพ์หรืองานเรียงพิมพ์ '
'საბეჭდი და ტიპოგრაფიული ინდუსტრიის უშინაარსო ტექსტია ',
'տպագրության և տպագրական արդյունաբերության համար '
'është një tekst shabllon i industrisë së printimit '
' زمن طويل وهي أن المحتوى المقروء لصفحة ما سيلهي '
'е елементарен примерен текст използван в печатарската '
'és un text de farciment usat per la indústria de la '
'Lorem Ipsum is simply dummy text of the printing '
'είναι απλά ένα κείμενο χωρίς νόημα για τους επαγγελματίες '
' זוהי עובדה מבוססת שדעתו של הקורא תהיה מוסחת על ידי טקטס קריא '
'छपाई और अक्षर योजन उद्योग का एक साधारण डमी पाठ है सन '
'คือ เนื้อหาจำลองแบบเรียบๆ ที่ใช้กันในธุรกิจงานพิมพ์หรืองานเรียงพิมพ์ '
'საბეჭდი და ტიპოგრაფიული ინდუსტრიის უშინაარსო ტექსტია ',
);
});
// TODO: https://github.com/flutter/flutter/issues/60040
......
......@@ -5,6 +5,7 @@
import 'package:test/test.dart';
import 'package:ui/src/engine.dart';
import 'package:ui/src/engine/canvaskit/surface_factory.dart';
import 'package:ui/ui.dart' as ui;
/// Whether the current browser is Safari on iOS.
......@@ -38,7 +39,8 @@ void setUpCanvasKitTest() {
tearDown(() {
testCollector.cleanUpAfterTest();
debugResetBrowserSupportsFinalizationRegistry();
OverlayCache.instance.debugClear();
HtmlViewEmbedder.instance.debugClear();
SurfaceFactory.instance.debugClear();
});
tearDownAll(() {
......
......@@ -5,11 +5,10 @@
import 'dart:async';
import 'dart:html' as html;
import 'package:ui/src/engine.dart';
import 'package:ui/ui.dart' as ui;
import 'package:test/bootstrap/browser.dart';
import 'package:test/test.dart';
import 'package:ui/src/engine.dart';
import 'package:ui/ui.dart' as ui;
import 'common.dart';
......@@ -20,18 +19,13 @@ void main() {
}
void testMain() {
group('HtmlViewEmbedder', () {
group('$HtmlViewEmbedder', () {
setUpCanvasKitTest();
setUp(() {
window.debugOverrideDevicePixelRatio(1);
});
tearDown(() {
EnginePlatformDispatcher.instance.rasterizer?.surface.viewEmbedder
.debugCleanupSvgClipPaths();
});
test('embeds interactive platform views', () async {
ui.platformViewRegistry.registerViewFactory(
'test-platform-view',
......@@ -123,7 +117,8 @@ void testMain() {
dispatcher.rasterizer!.draw(sb.build().layerTree);
// Transformations happen on the slot element.
final html.Element slotHost = domRenderer.sceneElement!.querySelector('flt-platform-view-slot')!;
final html.Element slotHost =
domRenderer.sceneElement!.querySelector('flt-platform-view-slot')!;
expect(
slotHost.style.transform,
......@@ -164,7 +159,8 @@ void testMain() {
dispatcher.rasterizer!.draw(sb.build().layerTree);
// Transformations happen on the slot element.
final html.Element slotHost = domRenderer.sceneElement!.querySelector('flt-platform-view-slot')!;
final html.Element slotHost =
domRenderer.sceneElement!.querySelector('flt-platform-view-slot')!;
expect(
getTransformChain(slotHost),
......@@ -192,7 +188,8 @@ void testMain() {
dispatcher.rasterizer!.draw(sb.build().layerTree);
// Transformations happen on the slot element.
final html.Element slotHost = domRenderer.sceneElement!.querySelector('flt-platform-view-slot')!;
final html.Element slotHost =
domRenderer.sceneElement!.querySelector('flt-platform-view-slot')!;
expect(
getTransformChain(slotHost),
......@@ -205,7 +202,7 @@ void testMain() {
});
test('renders overlays on top of platform views', () async {
expect(OverlayCache.instance.debugLength, 0);
expect(SurfaceFactory.instance.debugCacheSize, 0);
final CkPicture testPicture =
paintPicture(ui.Rect.fromLTRB(0, 0, 10, 10), (CkCanvas canvas) {
canvas.drawCircle(ui.Offset(5, 5), 5, CkPaint());
......@@ -213,7 +210,7 @@ void testMain() {
// Initialize all platform views to be used in the test.
final List<int> platformViewIds = <int>[];
for (int i = 0; i < OverlayCache.kDefaultCacheSize * 2; i++) {
for (int i = 0; i < HtmlViewEmbedder.maximumOverlaySurfaces * 2; i++) {
ui.platformViewRegistry.registerViewFactory(
'test-platform-view',
(viewId) => html.DivElement()..id = 'view-$i',
......@@ -242,9 +239,9 @@ void testMain() {
// 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);
renderTestScene(viewCount: HtmlViewEmbedder.maximumOverlaySurfaces);
expect(countCanvases(), HtmlViewEmbedder.maximumOverlaySurfaces);
expect(SurfaceFactory.instance.debugCacheSize, 0);
// Frame 2:
// Render: zero platform views.
......@@ -252,23 +249,27 @@ void testMain() {
await Future<void>.delayed(Duration.zero);
renderTestScene(viewCount: 0);
expect(countCanvases(), 1);
expect(OverlayCache.instance.debugLength, 5);
// The cache contains all the surfaces except the base surface and the
// backup surface.
expect(SurfaceFactory.instance.debugCacheSize,
HtmlViewEmbedder.maximumOverlaySurfaces - 2);
// 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);
renderTestScene(viewCount: HtmlViewEmbedder.maximumOverlaySurfaces - 2);
expect(countCanvases(), HtmlViewEmbedder.maximumOverlaySurfaces - 1);
expect(SurfaceFactory.instance.debugCacheSize, 0);
// Frame 4:
// Render: more platform views than max cache size.
// Expect: cache empty (everything reused).
// Expect: main canvas, backup overlay, maximum overlays;
// 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);
renderTestScene(viewCount: HtmlViewEmbedder.maximumOverlaySurfaces * 2);
expect(countCanvases(), HtmlViewEmbedder.maximumOverlaySurfaces);
expect(SurfaceFactory.instance.debugCacheSize, 0);
// Frame 5:
// Render: zero platform views.
......@@ -276,7 +277,8 @@ void testMain() {
await Future<void>.delayed(Duration.zero);
renderTestScene(viewCount: 0);
expect(countCanvases(), 1);
expect(OverlayCache.instance.debugLength, 5);
expect(SurfaceFactory.instance.debugCacheSize,
HtmlViewEmbedder.maximumOverlaySurfaces - 2);
// Frame 6:
// Render: deleted platform views.
......@@ -301,7 +303,7 @@ void testMain() {
} on AssertionError catch (error) {
expect(
error.toString(),
'Assertion failed: "Cannot render platform views: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9. These views have not been created, or they have been deleted."',
'Assertion failed: "Cannot render platform views: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15. These views have not been created, or they have been deleted."',
);
}
......
......@@ -302,13 +302,10 @@ class TestBoxWrapper implements StackTraceDebugger {
_debugStackTrace = StackTrace.current;
}
box = SkiaObjectBox<TestBoxWrapper, TestSkDeletable>.resurrectable(
this,
TestSkDeletable(),
() {
resurrectCount += 1;
return TestSkDeletable();
}
);
this, TestSkDeletable(), () {
resurrectCount += 1;
return TestSkDeletable();
});
}
TestBoxWrapper.cloneOf(this.box) {
......@@ -331,7 +328,6 @@ class TestBoxWrapper implements StackTraceDebugger {
TestBoxWrapper clone() => TestBoxWrapper.cloneOf(box);
}
class TestSkDeletable implements SkDeletable {
static int deleteCount = 0;
......@@ -342,7 +338,8 @@ class TestSkDeletable implements SkDeletable {
@override
void delete() {
expect(_isDeleted, isFalse,
reason: 'CanvasKit does not allow deleting the same object more than once.');
reason:
'CanvasKit does not allow deleting the same object more than once.');
_isDeleted = true;
deleteCount++;
}
......@@ -351,7 +348,7 @@ class TestSkDeletable implements SkDeletable {
JsConstructor get constructor => TestJsConstructor('TestSkDeletable');
}
class TestJsConstructor implements JsConstructor{
class TestJsConstructor implements JsConstructor {
TestJsConstructor(this.name);
@override
......@@ -409,9 +406,6 @@ class FakeRasterizer implements Rasterizer {
void setSkiaResourceCacheMaxBytes(int bytes) {
throw UnimplementedError();
}
@override
Surface get surface => throw UnimplementedError();
}
class TestSelfManagedObject extends SkiaObject<TestSkDeletable> {
......
// 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 'package:test/bootstrap/browser.dart';
import 'package:test/test.dart';
import 'package:ui/src/engine.dart';
import '../matchers.dart';
import 'common.dart';
const MethodCodec codec = StandardMethodCodec();
void main() {
internalBootstrapBrowserTest(() => testMain);
}
void testMain() {
group('$SurfaceFactory', () {
setUpCanvasKitTest();
test('cannot be created with size less than 2', () {
expect(() => SurfaceFactory(-1), throwsAssertionError);
expect(() => SurfaceFactory(0), throwsAssertionError);
expect(() => SurfaceFactory(1), throwsAssertionError);
expect(SurfaceFactory(2), isNotNull);
});
test('getSurface', () {
final SurfaceFactory factory = SurfaceFactory(3);
expect(factory.baseSurface, isNotNull);
expect(factory.backupSurface, isNotNull);
expect(factory.baseSurface, isNot(equals(factory.backupSurface)));
expect(factory.debugSurfaceCount, equals(2));
// Get a surface from the factory, it should be unique.
final Surface newSurface = factory.getSurface();
expect(newSurface, isNot(equals(factory.baseSurface)));
expect(newSurface, isNot(equals(factory.backupSurface)));
expect(factory.debugSurfaceCount, equals(3));
// Get another surface from the factory. Now we are at maximum capacity,
// so it should return the backup surface.
final Surface anotherSurface = factory.getSurface();
expect(anotherSurface, isNot(equals(factory.baseSurface)));
expect(anotherSurface, equals(factory.backupSurface));
expect(factory.debugSurfaceCount, equals(3));
});
test('releaseSurface', () {
final SurfaceFactory factory = SurfaceFactory(3);
// Create a new surface and immediately release it.
final Surface surface = factory.getSurface();
factory.releaseSurface(surface);
// If we create a new surface, it should be the same as the one we
// just created.
final Surface newSurface = factory.getSurface();
expect(newSurface, equals(surface));
});
test('isLive', () {
final SurfaceFactory factory = SurfaceFactory(3);
expect(factory.isLive(factory.baseSurface), isTrue);
expect(factory.isLive(factory.backupSurface), isTrue);
final Surface surface = factory.getSurface();
expect(factory.isLive(surface), isTrue);
factory.releaseSurface(surface);
expect(factory.isLive(surface), isFalse);
});
// TODO: https://github.com/flutter/flutter/issues/60040
}, skip: isIosSafari);
}
......@@ -20,8 +20,9 @@ void testMain() {
setUpCanvasKitTest();
test('Surface allocates canvases efficiently', () {
final Surface surface = Surface(HtmlViewEmbedder());
final CkSurface original = surface.acquireFrame(ui.Size(9, 19)).skiaSurface;
final Surface surface = SurfaceFactory.instance.getSurface();
final CkSurface original =
surface.acquireFrame(ui.Size(9, 19)).skiaSurface;
// Expect exact requested dimensions.
expect(original.width(), 9);
......@@ -37,7 +38,8 @@ void testMain() {
// The first increase will allocate a new surface, but will overallocate
// by 40% to accommodate future increases.
final CkSurface firstIncrease = surface.acquireFrame(ui.Size(10, 20)).skiaSurface;
final CkSurface firstIncrease =
surface.acquireFrame(ui.Size(10, 20)).skiaSurface;
expect(firstIncrease, isNot(same(original)));
// Expect overallocated dimensions
......@@ -47,7 +49,8 @@ void testMain() {
expect(surface.htmlCanvas!.style.height, '28px');
// Subsequent increases within 40% reuse the old surface.
final CkSurface secondIncrease = surface.acquireFrame(ui.Size(11, 22)).skiaSurface;
final CkSurface secondIncrease =
surface.acquireFrame(ui.Size(11, 22)).skiaSurface;
expect(secondIncrease, same(firstIncrease));
// Increases beyond the 40% limit will cause a new allocation.
......@@ -61,35 +64,51 @@ void testMain() {
expect(surface.htmlCanvas!.style.height, '56px');
// Shrink again. Reuse the last allocated surface.
final CkSurface shrunk2 = surface.acquireFrame(ui.Size(5, 15)).skiaSurface;
final CkSurface shrunk2 =
surface.acquireFrame(ui.Size(5, 15)).skiaSurface;
expect(shrunk2, same(huge));
});
test(
'Surface creates new context when WebGL context is lost',
'Surface creates new context when WebGL context is restored',
() async {
final Surface surface = Surface(HtmlViewEmbedder());
final Surface surface = SurfaceFactory.instance.getSurface();
expect(surface.debugForceNewContext, isTrue);
final CkSurface before = surface.acquireFrame(ui.Size(9, 19)).skiaSurface;
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;
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 html.CanvasElement canvas =
surface.htmlElement.children.single as html.CanvasElement;
final dynamic ctx = canvas.getContext('webgl2');
final dynamic loseContextExtension = ctx.getExtension('WEBGL_lose_context');
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);
// We don't create a new GL context until the context is restored.
expect(surface.debugContextLost, isTrue);
expect(ctx.isContextLost(), isTrue);
// Emulate WebGL context restoration.
loseContextExtension.restoreContext();
// Pump a timer to allow the "restore 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.
final CkSurface afterContextLost =
surface.acquireFrame(ui.Size(9, 19)).skiaSurface;
// A new context is created.
expect(afterContextLost, isNot(same(before)));
},
// Firefox doesn't have the WEBGL_lose_context extension.
......@@ -98,8 +117,9 @@ void testMain() {
// Regression test for https://github.com/flutter/flutter/issues/75286
test('updates canvas logical size when device-pixel ratio changes', () {
final Surface surface = Surface(HtmlViewEmbedder());
final CkSurface original = surface.acquireFrame(ui.Size(10, 16)).skiaSurface;
final Surface surface = Surface();
final CkSurface original =
surface.acquireFrame(ui.Size(10, 16)).skiaSurface;
expect(original.width(), 10);
expect(original.height(), 16);
......@@ -109,7 +129,8 @@ void testMain() {
// Increase device-pixel ratio: this makes CSS pixels bigger, so we need
// fewer of them to cover the browser window.
window.debugOverrideDevicePixelRatio(2.0);
final CkSurface highDpr = surface.acquireFrame(ui.Size(10, 16)).skiaSurface;
final CkSurface highDpr =
surface.acquireFrame(ui.Size(10, 16)).skiaSurface;
expect(highDpr.width(), 10);
expect(highDpr.height(), 16);
expect(surface.htmlCanvas!.style.width, '5px');
......@@ -118,7 +139,8 @@ void testMain() {
// Decrease device-pixel ratio: this makes CSS pixels smaller, so we need
// more of them to cover the browser window.
window.debugOverrideDevicePixelRatio(0.5);
final CkSurface lowDpr = surface.acquireFrame(ui.Size(10, 16)).skiaSurface;
final CkSurface lowDpr =
surface.acquireFrame(ui.Size(10, 16)).skiaSurface;
expect(lowDpr.width(), 10);
expect(lowDpr.height(), 16);
expect(surface.htmlCanvas!.style.width, '20px');
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册