[web:canvaskit] migrate Paint API to UniqueRef (flutter/engine#41230)

Migrate Paint API to `UniqueRef`. This includes `Paint`, `ImageFilter` (and all subtypes), `ColorFilter` (and all subtypes).

Also fix the following memory leaks:

* `CkPaint` is frequently used by layers where a one-off paint object is created, used, and immediately dropped. `CkPaint` now has a `dispose` method, and all one-off usages now dispose of the paint after they are done.
* `CkColorFilter.initRawImageFilter` was leaking the `SkColorFilter` created by `_initRawColorFilter` inside the expression.
* `CkManagedSkImageFilterConvertible.imageFilter` now takes a closure, which allows the implementation decide on the lifetime of the `SkImageFilter` vended to the caller. Because `CkColorFilter` is a const class it cannot store C++ instances inside its own fields, so it creates a temporary `SkImageFilter` class to be used by the caller and then it needs to delete it. Now it does.
This commit is contained in:
Yegor 2023-04-17 10:30:19 -07:00 committed by GitHub
parent 108eb0fa89
commit 2fcdf5a731
10 changed files with 245 additions and 201 deletions

View File

@ -291,18 +291,20 @@ class CkCanvas {
void saveLayerWithFilter(ui.Rect bounds, ui.ImageFilter filter,
[CkPaint? paint]) {
final CkManagedSkImageFilterConvertible convertible;
if (filter is ui.ColorFilter) {
convertible = createCkColorFilter(filter as EngineColorFilter)!;
} else {
convertible = filter as CkManagedSkImageFilterConvertible;
}
return skCanvas.saveLayer(
paint?.skiaObject,
toSkRect(bounds),
convertible.imageFilter.skiaObject,
0,
);
final CkManagedSkImageFilterConvertible convertible;
if (filter is ui.ColorFilter) {
convertible = createCkColorFilter(filter as EngineColorFilter)!;
} else {
convertible = filter as CkManagedSkImageFilterConvertible;
}
convertible.imageFilter((SkImageFilter filter) {
skCanvas.saveLayer(
paint?.skiaObject,
toSkRect(bounds),
filter,
0,
);
});
}
void scale(double sx, double sy) {
@ -1173,11 +1175,13 @@ class CkSaveLayerWithFilterCommand extends CkPaintCommand {
} else {
convertible = filter as CkManagedSkImageFilterConvertible;
}
return canvas.saveLayer(
paint?.skiaObject,
toSkRect(bounds),
convertible.imageFilter.skiaObject,
0,
);
convertible.imageFilter((SkImageFilter filter) {
canvas.saveLayer(
paint?.skiaObject,
toSkRect(bounds),
filter,
0,
);
});
}
}

View File

@ -10,31 +10,25 @@ import '../color_filter.dart';
import '../util.dart';
import 'canvaskit_api.dart';
import 'image_filter.dart';
import 'skia_object_cache.dart';
import 'native_memory.dart';
/// A concrete [ManagedSkiaObject] subclass that owns a [SkColorFilter] and
/// manages its lifecycle.
/// Owns a [SkColorFilter] and manages its lifecycle.
///
/// Seealso:
/// See also:
///
/// * [CkPaint.colorFilter], which uses a [ManagedSkColorFilter] to manage
/// the lifecycle of its [SkColorFilter].
class ManagedSkColorFilter extends ManagedSkiaObject<SkColorFilter> {
class ManagedSkColorFilter {
ManagedSkColorFilter(CkColorFilter ckColorFilter)
: colorFilter = ckColorFilter;
: colorFilter = ckColorFilter {
_ref = UniqueRef<SkColorFilter>(this, colorFilter._initRawColorFilter(), 'ColorFilter');
}
final CkColorFilter colorFilter;
@override
SkColorFilter createDefault() => colorFilter._initRawColorFilter();
late final UniqueRef<SkColorFilter> _ref;
@override
SkColorFilter resurrect() => colorFilter._initRawColorFilter();
@override
void delete() {
rawSkiaObject?.delete();
}
SkColorFilter get skiaObject => _ref.nativeObject;
@override
int get hashCode => colorFilter.hashCode;
@ -51,64 +45,83 @@ class ManagedSkColorFilter extends ManagedSkiaObject<SkColorFilter> {
String toString() => colorFilter.toString();
}
/// A [ui.ColorFilter] backed by Skia's [SkColorFilter].
///
/// Additionally, this class provides the interface for converting itself to a
/// [ManagedSkiaObject] that manages a skia image filter.
abstract class CkColorFilter
implements CkManagedSkImageFilterConvertible {
/// CanvasKit implementation of [ui.ColorFilter].
abstract class CkColorFilter implements CkManagedSkImageFilterConvertible {
const CkColorFilter();
/// Called by [ManagedSkiaObject.createDefault] and
/// [ManagedSkiaObject.resurrect] to create a new [SkImageFilter], when this
/// filter is used as an [ImageFilter].
SkImageFilter initRawImageFilter() =>
canvasKit.ImageFilter.MakeColorFilter(_initRawColorFilter(), null);
/// Converts this color filter into an image filter.
///
/// Passes the ownership of the returned [SkImageFilter] to the caller. It is
/// the caller's responsibility to manage the lifecycle of the returned value.
SkImageFilter initRawImageFilter() {
final SkColorFilter skColorFilter = _initRawColorFilter();
final SkImageFilter result = canvasKit.ImageFilter.MakeColorFilter(skColorFilter, null);
/// Called by [ManagedSkiaObject.createDefault] and
/// [ManagedSkiaObject.resurrect] to create a new [SkColorFilter], when this
/// filter is used as a [ColorFilter].
// The underlying SkColorFilter is now owned by the SkImageFilter, so we
// need to drop the reference to allow it to be collected.
skColorFilter.delete();
return result;
}
/// Creates a Skia object based on the properties of this color filter.
///
/// Passes the ownership of the returned [SkColorFilter] to the caller. It is
/// the caller's responsibility to manage the lifecycle of the returned value.
SkColorFilter _initRawColorFilter();
@override
ManagedSkiaObject<SkImageFilter> get imageFilter =>
CkColorFilterImageFilter(colorFilter: this);
void imageFilter(SkImageFilterBorrow borrow) {
// Since ColorFilter has a const constructor it cannot store dynamically
// created Skia objects. Therefore a new SkImageFilter is created every time
// it's used. However, once used it's no longer needed, so it's deleted
// immediately to free memory.
final SkImageFilter skImageFilter = initRawImageFilter();
borrow(skImageFilter);
skImageFilter.delete();
}
}
/// A reusable identity transform matrix.
///
/// WARNING: DO NOT MUTATE THIS MATRIX! It is a shared global singleton.
Float32List _identityTransform = _computeIdentityTransform();
Float32List _computeIdentityTransform() {
final Float32List result = Float32List(20);
const List<int> translationIndices = <int>[0, 6, 12, 18];
for (final int i in translationIndices) {
result[i] = 1;
}
_identityTransform = result;
return result;
}
SkColorFilter createSkColorFilterFromColorAndBlendMode(ui.Color color, ui.BlendMode blendMode) {
/// Return the identity matrix when the color opacity is 0. Replicates
/// effect of applying no filter
if (color.opacity == 0) {
return canvasKit.ColorFilter.MakeMatrix(_identityTransform);
}
final SkColorFilter? filter = canvasKit.ColorFilter.MakeBlend(
toSharedSkColor1(color),
toSkBlendMode(blendMode),
);
if (filter == null) {
throw ArgumentError('Invalid parameters for blend mode ColorFilter');
}
return filter;
}
class CkBlendModeColorFilter extends CkColorFilter {
const CkBlendModeColorFilter(this.color, this.blendMode);
final ui.Color color;
final ui.BlendMode blendMode;
static Float32List get identityTransform => _identityTransform ?? _computeIdentityTransform();
static Float32List? _identityTransform;
static Float32List _computeIdentityTransform() {
final Float32List result = Float32List(20);
const List<int> translationIndices = <int>[0, 6, 12, 18];
for (final int i in translationIndices) {
result[i] = 1;
}
_identityTransform = result;
return result;
}
@override
SkColorFilter _initRawColorFilter() {
/// Return the identity matrix when the color opacity is 0. Replicates
/// effect of applying no filter
if (color.opacity == 0) {
return canvasKit.ColorFilter.MakeMatrix(identityTransform);
}
final SkColorFilter? filter = canvasKit.ColorFilter.MakeBlend(
toSharedSkColor1(color),
toSkBlendMode(blendMode),
);
if (filter == null) {
throw ArgumentError('Invalid parameters for blend mode ColorFilter');
}
return filter;
return createSkColorFilterFromColorAndBlendMode(color, blendMode);
}
@override

View File

@ -137,12 +137,14 @@ CkImage scaleImage(SkImage image, int? targetWidth, int? targetHeight) {
final CkPictureRecorder recorder = CkPictureRecorder();
final CkCanvas canvas = recorder.beginRecording(ui.Rect.largest);
final CkPaint paint = CkPaint();
canvas.drawImageRect(
CkImage(image),
ui.Rect.fromLTWH(0, 0, image.width(), image.height()),
ui.Rect.fromLTWH(0, 0, targetWidth!.toDouble(), targetHeight!.toDouble()),
CkPaint()
paint,
);
paint.dispose();
final CkPicture picture = recorder.endRecording();
final ui.Image finalImage = picture.toImageSync(

View File

@ -10,7 +10,9 @@ import 'package:ui/ui.dart' as ui;
import '../util.dart';
import 'canvaskit_api.dart';
import 'color_filter.dart';
import 'skia_object_cache.dart';
import 'native_memory.dart';
typedef SkImageFilterBorrow = void Function(SkImageFilter);
/// An [ImageFilter] that can create a managed skia [SkImageFilter] object.
///
@ -20,14 +22,13 @@ import 'skia_object_cache.dart';
///
/// Currently implemented by [CkImageFilter] and [CkColorFilter].
abstract class CkManagedSkImageFilterConvertible implements ui.ImageFilter {
ManagedSkiaObject<SkImageFilter> get imageFilter;
void imageFilter(SkImageFilterBorrow borrow);
}
/// The CanvasKit implementation of [ui.ImageFilter].
///
/// Currently only supports `blur`, `matrix`, and ColorFilters.
abstract class CkImageFilter extends ManagedSkiaObject<SkImageFilter>
implements CkManagedSkImageFilterConvertible {
abstract class CkImageFilter implements CkManagedSkImageFilterConvertible {
factory CkImageFilter.blur(
{required double sigmaX,
required double sigmaY,
@ -39,31 +40,26 @@ abstract class CkImageFilter extends ManagedSkiaObject<SkImageFilter>
required ui.FilterQuality filterQuality}) = _CkMatrixImageFilter;
CkImageFilter._();
@override
ManagedSkiaObject<SkImageFilter> get imageFilter => this;
SkImageFilter _initSkiaObject();
@override
SkImageFilter createDefault() => _initSkiaObject();
@override
SkImageFilter resurrect() => _initSkiaObject();
@override
void delete() {
rawSkiaObject?.delete();
}
}
class CkColorFilterImageFilter extends CkImageFilter {
CkColorFilterImageFilter({required this.colorFilter}) : super._();
CkColorFilterImageFilter({required this.colorFilter}) : super._() {
final SkImageFilter skImageFilter = colorFilter.initRawImageFilter();
_ref = UniqueRef<SkImageFilter>(this, skImageFilter, 'ImageFilter.color');
}
final CkColorFilter colorFilter;
late final UniqueRef<SkImageFilter> _ref;
@override
SkImageFilter _initSkiaObject() => colorFilter.initRawImageFilter();
void imageFilter(SkImageFilterBorrow borrow) {
borrow(_ref.nativeObject);
}
void dispose() {
_ref.dispose();
}
@override
int get hashCode => colorFilter.hashCode;
@ -84,12 +80,38 @@ class CkColorFilterImageFilter extends CkImageFilter {
class _CkBlurImageFilter extends CkImageFilter {
_CkBlurImageFilter(
{required this.sigmaX, required this.sigmaY, required this.tileMode})
: super._();
: super._() {
/// Return the identity matrix when both sigmaX and sigmaY are 0. Replicates
/// effect of applying no filter
final SkImageFilter skImageFilter;
if (sigmaX == 0 && sigmaY == 0) {
skImageFilter = canvasKit.ImageFilter.MakeMatrixTransform(
toSkMatrixFromFloat32(Matrix4.identity().storage),
toSkFilterOptions(ui.FilterQuality.none),
null
);
} else {
skImageFilter = canvasKit.ImageFilter.MakeBlur(
sigmaX,
sigmaY,
toSkTileMode(tileMode),
null,
);
}
_ref = UniqueRef<SkImageFilter>(this, skImageFilter, 'ImageFilter.blur');
}
final double sigmaX;
final double sigmaY;
final ui.TileMode tileMode;
late final UniqueRef<SkImageFilter> _ref;
@override
void imageFilter(SkImageFilterBorrow borrow) {
borrow(_ref.nativeObject);
}
String get _modeString {
switch (tileMode) {
case ui.TileMode.clamp:
@ -103,25 +125,6 @@ class _CkBlurImageFilter extends CkImageFilter {
}
}
@override
SkImageFilter _initSkiaObject() {
/// Return the identity matrix when both sigmaX and sigmaY are 0. Replicates
/// effect of applying no filter
if (sigmaX == 0 && sigmaY == 0) {
return canvasKit.ImageFilter.MakeMatrixTransform(
toSkMatrixFromFloat32(Matrix4.identity().storage),
toSkFilterOptions(ui.FilterQuality.none),
null
);
}
return canvasKit.ImageFilter.MakeBlur(
sigmaX,
sigmaY,
toSkTileMode(tileMode),
null,
);
}
@override
bool operator ==(Object other) {
if (runtimeType != other.runtimeType) {
@ -146,18 +149,23 @@ class _CkMatrixImageFilter extends CkImageFilter {
_CkMatrixImageFilter(
{required Float64List matrix, required this.filterQuality})
: matrix = Float64List.fromList(matrix),
super._();
final Float64List matrix;
final ui.FilterQuality filterQuality;
@override
SkImageFilter _initSkiaObject() {
return canvasKit.ImageFilter.MakeMatrixTransform(
super._() {
final SkImageFilter skImageFilter = canvasKit.ImageFilter.MakeMatrixTransform(
toSkMatrixFromFloat64(matrix),
toSkFilterOptions(filterQuality),
null,
);
_ref = UniqueRef<SkImageFilter>(this, skImageFilter, 'ImageFilter.matrix');
}
final Float64List matrix;
final ui.FilterQuality filterQuality;
late final UniqueRef<SkImageFilter> _ref;
@override
void imageFilter(SkImageFilterBorrow borrow) {
borrow(_ref.nativeObject);
}
@override

View File

@ -179,6 +179,7 @@ class BackdropFilterEngineLayer extends ContainerLayer
final CkPaint paint = CkPaint()..blendMode = _blendMode;
paintContext.internalNodesCanvas
.saveLayerWithFilter(paintBounds, _filter, paint);
paint.dispose();
paintChildren(paintContext);
paintContext.internalNodesCanvas.restore();
}
@ -340,6 +341,7 @@ class OpacityEngineLayer extends ContainerLayer
final ui.Rect saveLayerBounds = paintBounds.shift(-_offset);
paintContext.internalNodesCanvas.saveLayer(saveLayerBounds, paint);
paint.dispose();
paintChildren(paintContext);
// Restore twice: once for the translate and once for the saveLayer.
paintContext.internalNodesCanvas.restore();
@ -403,6 +405,7 @@ class ImageFilterEngineLayer extends ContainerLayer
final CkPaint paint = CkPaint();
paint.imageFilter = _filter;
paintContext.internalNodesCanvas.saveLayer(paintBounds, paint);
paint.dispose();
paintChildren(paintContext);
paintContext.internalNodesCanvas.restore();
paintContext.internalNodesCanvas.restore();
@ -439,6 +442,7 @@ class ShaderMaskEngineLayer extends ContainerLayer
paintContext.leafNodesCanvas!.drawRect(
ui.Rect.fromLTWH(0, 0, maskRect.width, maskRect.height), paint);
paint.dispose();
paintContext.leafNodesCanvas!.restore();
paintContext.internalNodesCanvas.restore();
@ -539,6 +543,7 @@ class PhysicalShapeEngineLayer extends ContainerLayer
// anti-aliased drawPath will always have such artifacts.
paintContext.leafNodesCanvas!.drawPaint(paint);
}
paint.dispose();
paintChildren(paintContext);
@ -570,6 +575,7 @@ class ColorFilterEngineLayer extends ContainerLayer
paint.colorFilter = filter;
paintContext.internalNodesCanvas.saveLayer(paintBounds, paint);
paint.dispose();
paintChildren(paintContext);
paintContext.internalNodesCanvas.restore();
}

View File

@ -5,33 +5,25 @@
import 'package:ui/ui.dart' as ui;
import 'canvaskit_api.dart';
import 'skia_object_cache.dart';
import 'native_memory.dart';
/// The CanvasKit implementation of [ui.MaskFilter].
class CkMaskFilter extends ManagedSkiaObject<SkMaskFilter> {
class CkMaskFilter {
CkMaskFilter.blur(ui.BlurStyle blurStyle, double sigma)
: _blurStyle = blurStyle,
_sigma = sigma;
final ui.BlurStyle _blurStyle;
final double _sigma;
@override
SkMaskFilter createDefault() => _initSkiaObject();
@override
SkMaskFilter resurrect() => _initSkiaObject();
SkMaskFilter _initSkiaObject() {
return canvasKit.MaskFilter.MakeBlur(
_sigma = sigma {
final SkMaskFilter skMaskFilter = canvasKit.MaskFilter.MakeBlur(
toSkBlurStyle(_blurStyle),
_sigma,
true,
)!;
_ref = UniqueRef<SkMaskFilter>(this, skMaskFilter, 'MaskFilter');
}
@override
void delete() {
rawSkiaObject?.delete();
}
final ui.BlurStyle _blurStyle;
final double _sigma;
late final UniqueRef<SkMaskFilter> _ref;
SkMaskFilter get skiaObject => _ref.nativeObject;
}

View File

@ -22,11 +22,25 @@ import 'skia_object_cache.dart';
///
/// This class is backed by a Skia object that must be explicitly
/// deleted to avoid a memory leak. This is done by extending [SkiaObject].
class CkPaint extends ManagedSkiaObject<SkPaint> implements ui.Paint {
CkPaint();
class CkPaint implements ui.Paint {
CkPaint() : skiaObject = SkPaint() {
skiaObject.setAntiAlias(_isAntiAlias);
skiaObject.setColorInt(_defaultPaintColor.toDouble());
_ref = UniqueRef<SkPaint>(this, skiaObject, 'Paint');
}
final SkPaint skiaObject;
late final UniqueRef<SkPaint> _ref;
CkManagedSkImageFilterConvertible? _imageFilter;
static const int _defaultPaintColor = 0xFF000000;
/// Returns the native reference to the underlying [SkPaint] object.
///
/// This should only be used in tests.
@visibleForTesting
UniqueRef<SkPaint> get debugRef => _ref;
@override
ui.BlendMode get blendMode => _blendMode;
@override
@ -260,50 +274,28 @@ class CkPaint extends ManagedSkiaObject<SkPaint> implements ui.Paint {
if (_imageFilter == value) {
return;
}
final CkManagedSkImageFilterConvertible? filter;
if (value is ui.ColorFilter) {
_imageFilter = createCkColorFilter(value as EngineColorFilter);
filter = createCkColorFilter(value as EngineColorFilter);
}
else {
_imageFilter = value as CkManagedSkImageFilterConvertible?;
filter = value as CkManagedSkImageFilterConvertible?;
}
_managedImageFilter = _imageFilter?.imageFilter;
skiaObject.setImageFilter(_managedImageFilter?.skiaObject);
if (filter != null) {
filter.imageFilter((SkImageFilter skImageFilter) {
skiaObject.setImageFilter(skImageFilter);
});
}
_imageFilter = filter;
}
CkManagedSkImageFilterConvertible? _imageFilter;
ManagedSkiaObject<SkImageFilter>? _managedImageFilter;
@override
SkPaint createDefault() {
final SkPaint paint = SkPaint();
paint.setAntiAlias(_isAntiAlias);
paint.setColorInt(_color.toDouble());
return paint;
}
@override
SkPaint resurrect() {
final SkPaint paint = SkPaint();
// No need to do anything for `invertColors`. If it was set, then it
// updated `_managedColorFilter`.
paint.setBlendMode(toSkBlendMode(_blendMode));
paint.setStyle(toSkPaintStyle(_style));
paint.setStrokeWidth(_strokeWidth);
paint.setAntiAlias(_isAntiAlias);
paint.setColorInt(_color.toDouble());
paint.setShader(_shader?.getSkShader(_filterQuality));
paint.setMaskFilter(_ckMaskFilter?.skiaObject);
paint.setColorFilter(_effectiveColorFilter?.skiaObject);
paint.setImageFilter(_managedImageFilter?.skiaObject);
paint.setStrokeCap(toSkStrokeCap(_strokeCap));
paint.setStrokeJoin(toSkStrokeJoin(_strokeJoin));
paint.setStrokeMiter(_strokeMiterLimit);
return paint;
}
@override
void delete() {
rawSkiaObject?.delete();
/// Disposes of this paint object.
///
/// This object cannot be used again after calling this method.
void dispose() {
_ref.dispose();
}
}

View File

@ -13,7 +13,6 @@ import 'font_fallbacks.dart';
import 'native_memory.dart';
import 'painting.dart';
import 'renderer.dart';
import 'skia_object_cache.dart';
import 'text_fragmenter.dart';
import 'util.dart';
@ -552,16 +551,6 @@ SkFontStyle toSkFontStyle(ui.FontWeight? fontWeight, ui.FontStyle? fontStyle) {
}
/// The CanvasKit implementation of [ui.Paragraph].
///
/// This class does not use [ManagedSkiaObject] because it requires that its
/// memory is reclaimed synchronously. This protects our memory usage from
/// blowing up if within a single frame the framework needs to layout a lot of
/// paragraphs. One common use-case is `ListView.builder`, which needs to layout
/// more of its content than it actually renders to compute the scroll position.
/// More generally, this protects from the pattern of laying out a lot of text
/// while painting a small subset of it. To achieve this a
/// [SynchronousSkiaObjectCache] is used that limits the number of live laid out
/// paragraphs at any point in time within or outside the frame.
class CkParagraph implements ui.Paragraph {
CkParagraph(SkParagraph skParagraph, this._paragraphStyle) {
_ref = UniqueRef<SkParagraph>(this, skParagraph, 'Paragraph');

View File

@ -52,8 +52,11 @@ void testMain() {
test('can be constructed', () {
final CkImageFilter imageFilter = CkImageFilter.blur(sigmaX: 5, sigmaY: 10, tileMode: ui.TileMode.clamp);
expect(imageFilter, isA<CkImageFilter>());
expect(imageFilter.createDefault(), isNotNull);
expect(imageFilter.resurrect(), isNotNull);
SkImageFilter? skFilter;
imageFilter.imageFilter((SkImageFilter value) {
skFilter = value;
});
expect(skFilter, isNotNull);
});
@ -82,11 +85,12 @@ void testMain() {
final CkPaint paint = CkPaint();
paint.imageFilter = CkImageFilter.blur(sigmaX: 5, sigmaY: 10, tileMode: ui.TileMode.clamp);
final ManagedSkiaObject<Object> managedFilter = paint.imageFilter! as ManagedSkiaObject<Object>;
final Object skiaFilter = managedFilter.skiaObject;
final CkManagedSkImageFilterConvertible managedFilter1 = paint.imageFilter! as CkManagedSkImageFilterConvertible;
paint.imageFilter = CkImageFilter.blur(sigmaX: 5, sigmaY: 10, tileMode: ui.TileMode.clamp);
expect((paint.imageFilter! as ManagedSkiaObject<Object>).skiaObject, same(skiaFilter));
final CkManagedSkImageFilterConvertible managedFilter2 = paint.imageFilter! as CkManagedSkImageFilterConvertible;
expect(managedFilter1, same(managedFilter2));
});
test('does not throw for both sigmaX and sigmaY set to 0', () async {

View File

@ -0,0 +1,34 @@
// 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 '../common/matchers.dart';
import 'common.dart';
void main() {
internalBootstrapBrowserTest(() => testMain);
}
void testMain() {
group('CkPaint', () {
setUpCanvasKitTest();
test('lifecycle', () {
final CkPaint paint = CkPaint();
expect(paint.skiaObject, isNotNull);
expect(paint.debugRef.isDisposed, isFalse);
paint.dispose();
expect(paint.debugRef.isDisposed, isTrue);
expect(
reason: 'Cannot dispose more than once',
() => paint.dispose(),
throwsA(isAssertionError),
);
});
});
}