diff --git a/packages/vector_graphics/CHANGELOG.md b/packages/vector_graphics/CHANGELOG.md index a839b37646d..b0f1ea12c6e 100644 --- a/packages/vector_graphics/CHANGELOG.md +++ b/packages/vector_graphics/CHANGELOG.md @@ -1,3 +1,7 @@ +## 1.2.0 + +* Add an auto render strategy to reduce Impeller engine rendering overhead. + ## 1.1.18 * Allow transition between placeholder and loaded image to have an animation. diff --git a/packages/vector_graphics/lib/src/render_vector_graphic.dart b/packages/vector_graphics/lib/src/render_vector_graphic.dart index a6b530469e5..f3ec66d7560 100644 --- a/packages/vector_graphics/lib/src/render_vector_graphic.dart +++ b/packages/vector_graphics/lib/src/render_vector_graphic.dart @@ -2,12 +2,12 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:math' as math; import 'dart:ui' as ui; - import 'package:flutter/animation.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/rendering.dart'; - +import 'package:flutter/scheduler.dart'; import 'debug.dart'; import 'listener.dart'; @@ -40,6 +40,41 @@ class RasterKey { int get hashCode => Object.hash(assetKey, width, height); } +/// The cache key for a auto rasterized vector graphic. +@immutable +class RasterKeyWithPaint { + /// Create a new [RasterKeyWithPaint]. + const RasterKeyWithPaint(this.assetKey, this.width, this.height, this.paint); + + /// An object that is used to identify the raster data this key will store. + /// + /// Typically this is the value returned from [BytesLoader.cacheKey]. + final Object assetKey; + + /// The height of this vector graphic raster, in physical pixels. + final int width; + + /// The width of this vector graphic raster, in physical pixels. + final int height; + + /// The paint of this vector graphic raster. + final Paint paint; + + @override + bool operator ==(Object other) { + return other is RasterKeyWithPaint && + other.assetKey == assetKey && + other.width == width && + other.height == height && + other.paint.color == paint.color && + other.paint.colorFilter == paint.colorFilter; + } + + @override + int get hashCode => + Object.hash(assetKey, width, height, paint.color, paint.colorFilter); +} + /// The cache entry for a rasterized vector graphic. class RasterData { /// Create a new [RasterData]. @@ -63,6 +98,29 @@ class RasterData { } } +/// The cache entry for a rasterized vector graphic. +class RasterDataWithPaint { + /// Create a new [RasterDataWithPaint]. + RasterDataWithPaint(this._image, this.count, this.key); + + /// The rasterized vector graphic. + ui.Image get image => _image!; + ui.Image? _image; + + /// The cache key used to identify this vector graphic. + final RasterKeyWithPaint key; + + /// The number of render objects currently using this + /// vector graphic raster data. + int count = 0; + + /// Dispose this raster data. + void dispose() { + _image?.dispose(); + _image = null; + } +} + /// For testing only, clear all pending rasters. @visibleForTesting void debugClearRasteCaches() { @@ -72,6 +130,360 @@ void debugClearRasteCaches() { RenderVectorGraphic._liveRasterCache.clear(); } +/// A render object which draws a vector graphic instance as a raster. +class RenderAutoVectorGraphic extends RenderBox { + /// Create a new [RenderAutoVectorGraphic]. + RenderAutoVectorGraphic( + this._pictureInfo, + this._assetKey, + this._colorFilter, + this._devicePixelRatio, + this._opacity, + this._scale, + ) { + _opacity?.addListener(_updateOpacity); + _updateOpacity(); + } + + static final Map _liveRasterCache = + {}; + + /// It ensures that multiple raster caches are not created simultaneously to avoid jank. + static int _globalRasterDelayFrames = 0; + int _delayFrames = 0; + bool _rasterIsCreating = false; + int _rasterTaskId = 0; + double _rasterScaleFactor = 1.0; + final Paint _colorPaint = Paint(); + RasterKeyWithPaint? _rasterKey; + + /// A key that uniquely identifies the [pictureInfo] used for this vg. + Object get assetKey => _assetKey; + Object _assetKey; + set assetKey(Object value) { + if (value == assetKey) { + return; + } + _assetKey = value; + // Dont call mark needs paint here since a change in just the asset key + // isn't sufficient to force a re-draw. + } + + /// The [PictureInfo] which contains the vector graphic and size to draw. + PictureInfo get pictureInfo => _pictureInfo; + PictureInfo _pictureInfo; + set pictureInfo(PictureInfo value) { + if (identical(value, _pictureInfo)) { + return; + } + _pictureInfo = value; + markNeedsPaint(); + } + + /// An optional [ColorFilter] to apply to the rasterized vector graphic. + ColorFilter? get colorFilter => _colorFilter; + ColorFilter? _colorFilter; + set colorFilter(ColorFilter? value) { + if (colorFilter == value) { + return; + } + _colorFilter = value; + markNeedsPaint(); + } + + /// The device pixel ratio the vector graphic should be rasterized at. + double get devicePixelRatio => _devicePixelRatio; + double _devicePixelRatio; + set devicePixelRatio(double value) { + if (value == devicePixelRatio) { + return; + } + _devicePixelRatio = value; + markNeedsPaint(); + } + + double _opacityValue = 1.0; + + /// An opacity to draw the rasterized vector graphic with. + Animation? get opacity => _opacity; + Animation? _opacity; + set opacity(Animation? value) { + if (value == opacity) { + return; + } + _opacity?.removeListener(_updateOpacity); + _opacity = value; + _opacity?.addListener(_updateOpacity); + markNeedsPaint(); + } + + void _updateOpacity() { + if (opacity == null) { + return; + } + final double newValue = opacity!.value; + if (newValue == _opacityValue) { + return; + } + _opacityValue = newValue; + markNeedsPaint(); + } + + /// An additional ratio the picture will be transformed by. + /// + /// This value is used to ensure the computed raster does not + /// have extra pixelation from scaling in the case that a the [BoxFit] + /// value used in the [VectorGraphic] widget implies a scaling factor + /// greater than 1.0. + /// + /// For example, if the vector graphic widget is sized at 100x100, + /// the vector graphic itself has a size of 50x50, and [BoxFit.fill] + /// is used. This will compute a scale of 2.0, which will result in a + /// raster that is 100x100. + double get scale => _scale; + double _scale; + set scale(double value) { + assert(value != 0); + if (value == scale) { + return; + } + _scale = value; + markNeedsPaint(); + } + + @override + bool hitTestSelf(Offset position) => true; + + @override + bool get sizedByParent => true; + + @override + Size computeDryLayout(BoxConstraints constraints) { + return constraints.smallest; + } + + static RasterDataWithPaint _createRaster(RasterKeyWithPaint key, + double scaleFactor, PictureInfo info, Paint colorPaint) { + final int scaledWidth = key.width; + final int scaledHeight = key.height; + // In order to scale a picture, it must be placed in a new picture + // with a transform applied. Surprisingly, the height and width + // arguments of Picture.toImage do not control the resolution that the + // picture is rendered at, instead it controls how much of the picture to + // capture in a raster. + final ui.PictureRecorder recorder = ui.PictureRecorder(); + final ui.Canvas canvas = ui.Canvas(recorder); + final Rect drawSize = + ui.Rect.fromLTWH(0, 0, scaledWidth.toDouble(), scaledHeight.toDouble()); + canvas.clipRect(drawSize); + final int saveCount = canvas.getSaveCount(); + if (colorPaint.color.opacity != 1.0 || colorPaint.colorFilter != null) { + canvas.saveLayer(drawSize, colorPaint); + } + canvas.scale(scaleFactor); + canvas.drawPicture(info.picture); + canvas.restoreToCount(saveCount); + + final ui.Picture rasterPicture = recorder.endRecording(); + + final ui.Image pending = + rasterPicture.toImageSync(scaledWidth, scaledHeight); + return RasterDataWithPaint(pending, 0, key); + } + + void _maybeReleaseRaster(RasterDataWithPaint? data) { + if (data == null) { + return; + } + data.count -= 1; + if (data.count == 0 && _liveRasterCache.containsKey(data.key)) { + _liveRasterCache.remove(data.key); + data.dispose(); + } + } + + void _buildRasterAfterDelayFrames(VoidCallback callback) { + if (_delayFrames <= 0) { + _buildRaster(); + callback(); + return; + } + if (_rasterKey != null && _liveRasterCache.containsKey(_rasterKey)) { + final RasterDataWithPaint data = _liveRasterCache[_rasterKey]!; + data.count += 1; + _rasterData = data; + callback(); + return; + } + _delayFrames--; + _rasterTaskId = SchedulerBinding.instance.scheduleFrameCallback((_) { + _buildRasterAfterDelayFrames(callback); + }); + } + + void _buildRaster() { + // creating a new raster cache + if (_rasterKey == null) { + return; + } + + final RasterDataWithPaint data = _createRaster( + _rasterKey!, _rasterScaleFactor, pictureInfo, _colorPaint); + data.count += 1; + + assert(!_liveRasterCache.containsKey(_rasterKey)); + assert(_rasterData == null); + assert(data.count == 1); + assert(!debugDisposed!); + _liveRasterCache[_rasterKey!] = data; + _rasterData = data; + } + + // Re-create the raster for a given vector graphic if the target size + // is sufficiently different. Returns `null` if rasterData has been + // updated immediately. + void _maybeUpdateRaster(double drawScaleFactor) { + _rasterScaleFactor = devicePixelRatio * drawScaleFactor; + final int scaledWidth = + (pictureInfo.size.width * _rasterScaleFactor).round(); + final int scaledHeight = + (pictureInfo.size.height * _rasterScaleFactor).round(); + final RasterKeyWithPaint key = + RasterKeyWithPaint(assetKey, scaledWidth, scaledHeight, _colorPaint); + _rasterKey = key; + // First check if the raster is available synchronously. This also handles + // a no-op change that would resolve to an identical picture. + if (_liveRasterCache.containsKey(key)) { + final RasterDataWithPaint data = _liveRasterCache[key]!; + if (data != _rasterData) { + _maybeReleaseRaster(_rasterData); + data.count += 1; + } + _rasterData = data; + return; + } + + _maybeReleaseRaster(_rasterData); + _rasterData = null; + + if (scaledWidth > 2048 || scaledHeight > 2048) { + return; + } + + // we need create raster cache + if (!_rasterIsCreating) { + _globalRasterDelayFrames++; + // +1 is to avoid frequent cache creation caused by frequent size changes. + _delayFrames = _globalRasterDelayFrames + 1; + _rasterIsCreating = true; + + // need delay raster creating + _buildRasterAfterDelayFrames(() { + markNeedsPaint(); + _rasterIsCreating = false; + _globalRasterDelayFrames--; + _rasterTaskId = 0; + }); + return; + } + // Something changes during cache creation, so delay the creation to avoid frequent cache creation. + _delayFrames++; + return; + } + + RasterDataWithPaint? _rasterData; + + @override + void attach(covariant PipelineOwner owner) { + _opacity?.addListener(_updateOpacity); + _updateOpacity(); + super.attach(owner); + } + + @override + void detach() { + _opacity?.removeListener(_updateOpacity); + super.detach(); + } + + @override + void dispose() { + _maybeReleaseRaster(_rasterData); + _opacity?.removeListener(_updateOpacity); + if (_rasterTaskId != 0) { + SchedulerBinding.instance.cancelFrameCallbackWithId(_rasterTaskId); + _globalRasterDelayFrames--; + } + super.dispose(); + } + + @override + void paint(PaintingContext context, ui.Offset offset) { + assert(size == pictureInfo.size); + if (kDebugMode && debugSkipRaster) { + context.canvas + .drawRect(offset & size, Paint()..color = const Color(0xFFFF00FF)); + return; + } + + if (_opacityValue <= 0.0) { + return; + } + + if (colorFilter != null) { + _colorPaint.colorFilter = colorFilter; + } + _colorPaint.color = Color.fromRGBO(0, 0, 0, _opacityValue); + + // Use this transform to get real x/y scale facotr. + final Float64List transformList = context.canvas.getTransform(); + final double drawScaleFactor = math.max( + transformList[0 + 0 * 4], + transformList[1 + 1 * 4], + ); + _maybeUpdateRaster(drawScaleFactor); + + if (_rasterData != null) { + final ui.Image image = _rasterData!.image; + final int width = _rasterData!.key.width; + final int height = _rasterData!.key.height; + + final Rect src = ui.Rect.fromLTWH( + 0, + 0, + width.toDouble(), + height.toDouble(), + ); + final Rect dst = ui.Rect.fromLTWH( + offset.dx, + offset.dy, + pictureInfo.size.width, + pictureInfo.size.height, + ); + + context.canvas.drawImageRect( + image, + src, + dst, + Paint(), + ); + } else { + final int saveCount = context.canvas.getSaveCount(); + if (offset != Offset.zero) { + context.canvas.save(); + context.canvas.translate(offset.dx, offset.dy); + } + if (_opacityValue != 1.0 || colorFilter != null) { + context.canvas.save(); + context.canvas.clipRect(Offset.zero & size); + context.canvas.saveLayer(Offset.zero & size, _colorPaint); + } + context.canvas.drawPicture(pictureInfo.picture); + context.canvas.restoreToCount(saveCount); + } + } +} + /// A render object which draws a vector graphic instance as a raster. class RenderVectorGraphic extends RenderBox { /// Create a new [RenderVectorGraphic]. diff --git a/packages/vector_graphics/lib/src/vector_graphics.dart b/packages/vector_graphics/lib/src/vector_graphics.dart index f43990fdab8..a2a276a50fb 100644 --- a/packages/vector_graphics/lib/src/vector_graphics.dart +++ b/packages/vector_graphics/lib/src/vector_graphics.dart @@ -41,6 +41,12 @@ enum RenderingStrategy { /// Draw the vector graphic as a picture. picture, + + /// Draw the vector graphic using an automatic mode. + /// + /// For small images, it attempts to generate a raster cache. + /// If no raster cache is available, it falls back to picture mode rendering. + auto, } /// The signature that [VectorGraphic.errorBuilder] uses to report exceptions. @@ -476,6 +482,14 @@ class _VectorGraphicWidgetState extends State { opacity: widget.opacity, scale: scale, ); + } else if (widget.strategy == RenderingStrategy.auto) { + child = _RawAutoVectorGraphicWidget( + pictureInfo: pictureInfo, + assetKey: _pictureInfo!.key, + colorFilter: widget.colorFilter, + opacity: widget.opacity, + scale: scale, + ); } else { child = _RawPictureVectorGraphicWidget( pictureInfo: pictureInfo, @@ -591,6 +605,48 @@ class _RawVectorGraphicWidget extends SingleChildRenderObjectWidget { } } +class _RawAutoVectorGraphicWidget extends SingleChildRenderObjectWidget { + const _RawAutoVectorGraphicWidget({ + required this.pictureInfo, + required this.colorFilter, + required this.opacity, + required this.scale, + required this.assetKey, + }); + + final PictureInfo pictureInfo; + final ColorFilter? colorFilter; + final double scale; + final Animation? opacity; + final Object assetKey; + + @override + RenderObject createRenderObject(BuildContext context) { + return RenderAutoVectorGraphic( + pictureInfo, + assetKey, + colorFilter, + MediaQuery.maybeOf(context)?.devicePixelRatio ?? 1.0, + opacity, + scale, + ); + } + + @override + void updateRenderObject( + BuildContext context, + covariant RenderAutoVectorGraphic renderObject, + ) { + renderObject + ..pictureInfo = pictureInfo + ..assetKey = assetKey + ..colorFilter = colorFilter + ..devicePixelRatio = MediaQuery.maybeOf(context)?.devicePixelRatio ?? 1.0 + ..opacity = opacity + ..scale = scale; + } +} + class _RawWebVectorGraphicWidget extends SingleChildRenderObjectWidget { const _RawWebVectorGraphicWidget({ required this.pictureInfo, diff --git a/packages/vector_graphics/pubspec.yaml b/packages/vector_graphics/pubspec.yaml index 67d3f0ed3d3..d2c8e4ec7a8 100644 --- a/packages/vector_graphics/pubspec.yaml +++ b/packages/vector_graphics/pubspec.yaml @@ -2,7 +2,7 @@ name: vector_graphics description: A vector graphics rendering package for Flutter using a binary encoding. repository: https://github.com/flutter/packages/tree/main/packages/vector_graphics issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+vector_graphics%22 -version: 1.1.18 +version: 1.2.0 environment: sdk: ^3.4.0 diff --git a/packages/vector_graphics/test/render_vector_graphics_test.dart b/packages/vector_graphics/test/render_vector_graphics_test.dart index bf5f12889ca..c6751be722b 100644 --- a/packages/vector_graphics/test/render_vector_graphics_test.dart +++ b/packages/vector_graphics/test/render_vector_graphics_test.dart @@ -29,6 +29,7 @@ void main() { setUpAll(() async { final VectorGraphicsBuffer buffer = VectorGraphicsBuffer(); const VectorGraphicsCodec().writeSize(buffer, 50, 50); + TestWidgetsFlutterBinding.ensureInitialized(); pictureInfo = await decodeVectorGraphics( buffer.done(), @@ -426,6 +427,542 @@ void main() { expect(context.canvas.totalSaves, 1); expect(context.canvas.totalSaveLayers, 1); }); + + test('RenderAutoVectorGraphic paints picture correctly with default settings', + () async { + final RenderAutoVectorGraphic renderAutoVectorGraphic = + RenderAutoVectorGraphic( + pictureInfo, + 'testAutoPicture', + null, + 1.0, + null, + 1.0, + ); + renderAutoVectorGraphic.layout(BoxConstraints.tight(const Size(50, 50))); + final FakePaintingContext context = FakePaintingContext(); + renderAutoVectorGraphic.paint(context, Offset.zero); + + // When the rasterization is finished, it marks self as needing paint. + expect(renderAutoVectorGraphic.debugNeedsPaint, true); + + renderAutoVectorGraphic.paint(context, Offset.zero); + expect(context.canvas.lastImage, isNull); + + // change offset + renderAutoVectorGraphic.paint(context, const Offset(20, 30)); + + expect(context.canvas.saveCount, 0); + expect(context.canvas.totalSaves, 1); + expect(context.canvas.totalSaveLayers, 0); + + expect(context.canvas.lastImage, isNull); + renderAutoVectorGraphic.dispose(); + }); + + test('RenderAutoVectorGraphic attaches and detaches listeners correctly', + () async { + final FixedOpacityAnimation opacity = FixedOpacityAnimation(0.5); + final RenderAutoVectorGraphic renderAutoVectorGraphic = + RenderAutoVectorGraphic( + pictureInfo, + 'testAutoPictureListener', + null, + 1.0, + opacity, + 1.0, + ); + final PipelineOwner pipelineOwner = PipelineOwner(); + expect(opacity._listeners, hasLength(1)); + + renderAutoVectorGraphic.attach(pipelineOwner); + expect(opacity._listeners, hasLength(1)); + + renderAutoVectorGraphic.detach(); + expect(opacity._listeners, hasLength(0)); + + renderAutoVectorGraphic.attach(pipelineOwner); + expect(opacity._listeners, hasLength(1)); + + renderAutoVectorGraphic.dispose(); + expect(opacity._listeners, hasLength(0)); + }); + + test('RasterDataWithPaint.dispose is safe to call multiple times', () async { + final ui.PictureRecorder recorder = ui.PictureRecorder(); + ui.Canvas(recorder); + final ui.Image image = await recorder.endRecording().toImage(1, 1); + final RasterDataWithPaint data = RasterDataWithPaint( + image, 1, RasterKeyWithPaint('test', 1, 1, Paint())); + + data.dispose(); + + expect(data.dispose, returnsNormally); + }); + + test('RenderAutoVectorGraphic applies color filter with clip correctly', + () async { + final FixedOpacityAnimation opacity = FixedOpacityAnimation(0.5); + final RenderAutoVectorGraphic renderAutoVectorGraphic = + RenderAutoVectorGraphic( + pictureInfo, + 'test', + null, + 1.0, + opacity, + 1.0, + ); + renderAutoVectorGraphic.layout(BoxConstraints.tight(const Size(50, 50))); + final FakePaintingContext context = FakePaintingContext(); + renderAutoVectorGraphic.paint(context, Offset.zero); + + expect(context.canvas.lastClipRect, + equals(const ui.Rect.fromLTRB(0, 0, 50, 50))); + expect(context.canvas.saveCount, 0); + expect(context.canvas.totalSaves, 1); + expect(context.canvas.totalSaveLayers, 1); + + renderAutoVectorGraphic.dispose(); + }); + + testWidgets('Auto strategy triggers reater cache creation correctly', + (WidgetTester tester) async { + final RenderAutoVectorGraphic renderAutoVectorGraphic = + RenderAutoVectorGraphic( + pictureInfo, + 'testAuto', + null, + 1.0, + null, + 1.0, + ); + + renderAutoVectorGraphic.layout(BoxConstraints.tight(const Size(50, 50))); + + final FakePaintingContext context = FakePaintingContext(); + renderAutoVectorGraphic.paint(context, Offset.zero); + + // When the rasterization is finished, it marks self as needing paint. + expect(renderAutoVectorGraphic.debugNeedsPaint, true); + expect(context.canvas.lastImage, isNull); + + await tester.pump(); + // a new paint will delay the raster cache creating. + renderAutoVectorGraphic.paint(context, Offset.zero); + expect(context.canvas.lastImage, isNull); + + await tester.pump(); + // a new paint will delay the raster cache creating. + renderAutoVectorGraphic.paint(context, Offset.zero); + expect(context.canvas.lastImage, isNull); + + // two additional frame will create the raster cache. + await tester.pump(); + await tester.pump(); + + renderAutoVectorGraphic.paint(context, Offset.zero); + expect(context.canvas.lastImage, isNotNull); + + renderAutoVectorGraphic.dispose(); + }); + + testWidgets( + 'Multiple RenderAutoVectorGraphics create raster cache sequentially', + (WidgetTester tester) async { + final List renderAutoVectorGraphic = + List.generate( + 3, + (int index) => RenderAutoVectorGraphic( + pictureInfo, + 'test_$index', + null, + 1.0, + null, + 1.0, + ), + ); + + final FakePaintingContext context = FakePaintingContext(); + + for (final RenderAutoVectorGraphic renderVectorGraphic + in renderAutoVectorGraphic) { + renderVectorGraphic.layout(BoxConstraints.tight(const Size(50, 50))); + } + + // all image don't have raster cache + for (int i = 0; i < renderAutoVectorGraphic.length; i++) { + renderAutoVectorGraphic[i].paint(context, Offset.zero); + expect(context.canvas.lastImage, isNull); + context.canvas.resetImage(); + } + + await tester.pump(); + + // 2st frame: + await tester.pump(); + for (int i = 0; i < renderAutoVectorGraphic.length; i++) { + renderAutoVectorGraphic[i].paint(context, Offset.zero); + if (i == 0) { + expect(context.canvas.lastImage, isNotNull); + } else { + expect(context.canvas.lastImage, isNull); + } + context.canvas.resetImage(); + } + await tester.pump(); + + // 3st frame: + await tester.pump(); + + for (int i = 0; i < renderAutoVectorGraphic.length; i++) { + renderAutoVectorGraphic[i].paint(context, Offset.zero); + if (i <= 1) { + expect(context.canvas.lastImage, isNotNull); + } else { + expect(context.canvas.lastImage, isNull); + } + context.canvas.resetImage(); + } + await tester.pump(); + + await tester.pump(); + // 4st frame + for (int i = 0; i < renderAutoVectorGraphic.length; i++) { + renderAutoVectorGraphic[i].paint(context, Offset.zero); + expect(context.canvas.lastImage, isNotNull); + context.canvas.resetImage(); + renderAutoVectorGraphic[i].dispose(); + } + + // now new widget will get a reset delay time: + final RenderAutoVectorGraphic renderAutoVectorGraphicEnd = + RenderAutoVectorGraphic( + pictureInfo, + 'test_end', + null, + 1.0, + null, + 1.0, + ); + renderAutoVectorGraphicEnd.layout(BoxConstraints.tight(const Size(50, 50))); + expect(context.canvas.lastImage, isNull); + + renderAutoVectorGraphicEnd.paint(context, Offset.zero); + expect(context.canvas.lastImage, isNull); + + await tester.pump(); + await tester.pump(); + renderAutoVectorGraphicEnd.paint(context, Offset.zero); + expect(context.canvas.lastImage, isNotNull); + + renderAutoVectorGraphicEnd.dispose(); + }); + + testWidgets( + 'The raster size is increased by the device pixel ratio in auto startegy', + (WidgetTester tester) async { + final RenderAutoVectorGraphic renderAutoVectorGraphic = + RenderAutoVectorGraphic( + pictureInfo, + 'testPixelRatio', + null, + 2.0, + null, + 1.0, + ); + renderAutoVectorGraphic.layout(BoxConstraints.tight(const Size(50, 50))); + final FakePaintingContext context = FakePaintingContext(); + renderAutoVectorGraphic.paint(context, Offset.zero); + + await tester.pump(); + await tester.pump(); + renderAutoVectorGraphic.paint(context, Offset.zero); + expect(context.canvas.lastImage, isNotNull); + + expect(context.canvas.lastDst, const Rect.fromLTWH(0, 0, 50, 50)); + expect(context.canvas.lastSrc, const Rect.fromLTWH(0, 0, 100, 100)); + context.canvas.resetImage(); + + renderAutoVectorGraphic.devicePixelRatio = 3.0; + + renderAutoVectorGraphic.paint(context, Offset.zero); + expect(context.canvas.lastImage, isNull); + + await tester.pump(); + await tester.pump(); + renderAutoVectorGraphic.paint(context, Offset.zero); + + // devicePixelRatio change, raster cache will be a new one. + expect(context.canvas.lastDst, const Rect.fromLTWH(0, 0, 50, 50)); + expect(context.canvas.lastSrc, const Rect.fromLTWH(0, 0, 150, 150)); + + renderAutoVectorGraphic.dispose(); + }); + + testWidgets('Raster size scales when canvas is transformed', + (WidgetTester tester) async { + final RenderAutoVectorGraphic renderAutoVectorGraphic = + RenderAutoVectorGraphic( + pictureInfo, + 'testScaled', + null, + 1.0, + null, + 1.0, + ); + renderAutoVectorGraphic.layout(BoxConstraints.tight(const Size(50, 50))); + final FakePaintingContext context = FakePaintingContext(); + + renderAutoVectorGraphic.paint(context, Offset.zero); + await tester.pump(); + await tester.pump(); + expect(context.canvas.lastImage, isNull); + + renderAutoVectorGraphic.paint(context, Offset.zero); + expect(context.canvas.lastSrc, const Rect.fromLTWH(0, 0, 50, 50)); + expect(context.canvas.lastImage, isNotNull); + + context.canvas.scale(2.0, 2.0); + renderAutoVectorGraphic.paint(context, Offset.zero); + await tester.pump(); + await tester.pump(); + renderAutoVectorGraphic.paint(context, Offset.zero); + expect(context.canvas.lastSrc, const Rect.fromLTWH(0, 0, 100, 100)); + expect(context.canvas.lastImage, isNotNull); + + renderAutoVectorGraphic.dispose(); + }); + + testWidgets('Auto strategy reuses cache when creating image', + (WidgetTester tester) async { + final RenderAutoVectorGraphic renderAutoVectorGraphic = + RenderAutoVectorGraphic( + pictureInfo, + 'testCache', + null, + 1.0, + null, + 1.0, + ); + renderAutoVectorGraphic.layout(BoxConstraints.tight(const Size(50, 50))); + final FakePaintingContext context = FakePaintingContext(); + + renderAutoVectorGraphic.paint(context, Offset.zero); + await tester.pump(); + await tester.pump(); + expect(context.canvas.lastImage, isNull); + + renderAutoVectorGraphic.paint(context, Offset.zero); + expect(context.canvas.lastImage, isNotNull); + + final ui.Image? oldImage = context.canvas.lastImage; + context.canvas.resetImage(); + + final RenderAutoVectorGraphic renderAutoVectorGraphic2 = + RenderAutoVectorGraphic( + pictureInfo, + 'testCache', + null, + 1.0, + null, + 1.0, + ); + renderAutoVectorGraphic2.layout(BoxConstraints.tight(const Size(50, 50))); + renderAutoVectorGraphic2.paint(context, Offset.zero); + expect(context.canvas.lastImage, isNotNull); + expect(context.canvas.lastImage, equals(oldImage)); + + renderAutoVectorGraphic.dispose(); + renderAutoVectorGraphic2.dispose(); + }); + + testWidgets('Changing color filter does re-rasterize in auto strategy', + (WidgetTester tester) async { + final RenderAutoVectorGraphic renderAutoVectorGraphic = + RenderAutoVectorGraphic( + pictureInfo, + 'testColorFilter', + null, + 1.0, + null, + 1.0, + ); + renderAutoVectorGraphic.layout(BoxConstraints.tight(const Size(50, 50))); + final FakePaintingContext context = FakePaintingContext(); + + renderAutoVectorGraphic.paint(context, Offset.zero); + await tester.pump(); + await tester.pump(); + expect(context.canvas.lastImage, isNull); + + renderAutoVectorGraphic.paint(context, Offset.zero); + expect(context.canvas.lastImage, isNotNull); + + final ui.Image? oldImage = context.canvas.lastImage; + context.canvas.resetImage(); + + renderAutoVectorGraphic.colorFilter = + const ui.ColorFilter.mode(Colors.red, ui.BlendMode.colorBurn); + renderAutoVectorGraphic.paint(context, Offset.zero); + expect(context.canvas.lastImage, isNull); + + await tester.pump(); + await tester.pump(); + renderAutoVectorGraphic.paint(context, Offset.zero); + + expect(context.canvas.lastImage, isNotNull); + expect(context.canvas.lastImage, isNot(oldImage)); + + renderAutoVectorGraphic.dispose(); + }); + + testWidgets('Changing offset does not re-rasterize in auto strategy', + (WidgetTester tester) async { + final RenderAutoVectorGraphic renderAutoVectorGraphic = + RenderAutoVectorGraphic( + pictureInfo, + 'testOffset', + null, + 1.0, + null, + 1.0, + ); + renderAutoVectorGraphic.layout(BoxConstraints.tight(const Size(50, 50))); + final FakePaintingContext context = FakePaintingContext(); + + renderAutoVectorGraphic.paint(context, Offset.zero); + expect(context.canvas.lastImage, isNull); + + await tester.pump(); + await tester.pump(); + + renderAutoVectorGraphic.paint(context, Offset.zero); + expect(context.canvas.lastImage, isNotNull); + + final ui.Image? oldImage = context.canvas.lastImage; + context.canvas.resetImage(); + + renderAutoVectorGraphic.paint(context, const Offset(20, 30)); + expect(context.canvas.lastImage, isNotNull); + expect(context.canvas.lastImage, equals(oldImage)); + + renderAutoVectorGraphic.dispose(); + }); + + testWidgets('RenderAutoVectorGraphic disposal during rasterization is safe', + (WidgetTester tester) async { + final RenderAutoVectorGraphic renderAutoVectorGraphic = + RenderAutoVectorGraphic( + pictureInfo, + 'testDispose', + null, + 1.0, + null, + 1.0, + ); + renderAutoVectorGraphic.layout(BoxConstraints.tight(const Size(50, 50))); + final FakePaintingContext context = FakePaintingContext(); + + renderAutoVectorGraphic.paint(context, Offset.zero); + expect(context.canvas.lastImage, isNull); + await tester.pump(); + + renderAutoVectorGraphic.dispose(); + await tester.pump(); + await tester.pump(); + + renderAutoVectorGraphic.paint(context, Offset.zero); + expect(context.canvas.lastImage, isNull); + }); + + testWidgets('RenderAutoVectorGraphic re-rasterizes when opacity changes', + (WidgetTester tester) async { + final FixedOpacityAnimation opacity = FixedOpacityAnimation(0.0); + final RenderAutoVectorGraphic renderAutoVectorGraphic = + RenderAutoVectorGraphic( + pictureInfo, + 'testOpacity', + null, + 1.0, + opacity, + 1.0, + ); + + renderAutoVectorGraphic.layout(BoxConstraints.tight(const Size(50, 50))); + final FakePaintingContext context = FakePaintingContext(); + renderAutoVectorGraphic.paint(context, Offset.zero); + expect(context.canvas.lastImage, isNull); + + // full transparent means no raster cache. + await tester.pump(); + await tester.pump(); + renderAutoVectorGraphic.paint(context, Offset.zero); + expect(context.canvas.lastImage, isNull); + + opacity.value = 0.5; + opacity.notifyListeners(); + + // Changing opacity requires painting. + expect(renderAutoVectorGraphic.debugNeedsPaint, true); + + // Changing opacity need create new raster cache. + renderAutoVectorGraphic.paint(context, Offset.zero); + expect(context.canvas.lastImage, isNull); + + await tester.pump(); + await tester.pump(); + renderAutoVectorGraphic.paint(context, Offset.zero); + expect(context.canvas.lastImage, isNotNull); + + renderAutoVectorGraphic.dispose(); + }); + + testWidgets( + 'Identical widgets reuse raster cache when available in auto startegy', + (WidgetTester tester) async { + final RenderAutoVectorGraphic renderAutoVectorGraphic1 = + RenderAutoVectorGraphic( + pictureInfo, + 'testOffset', + null, + 1.0, + null, + 1.0, + ); + final RenderAutoVectorGraphic renderAutoVectorGraphic2 = + RenderAutoVectorGraphic( + pictureInfo, + 'testOffset', + null, + 1.0, + null, + 1.0, + ); + renderAutoVectorGraphic1.layout(BoxConstraints.tight(const Size(50, 50))); + renderAutoVectorGraphic2.layout(BoxConstraints.tight(const Size(50, 50))); + + final FakePaintingContext context = FakePaintingContext(); + + renderAutoVectorGraphic1.paint(context, Offset.zero); + renderAutoVectorGraphic2.paint(context, Offset.zero); + expect(context.canvas.lastImage, isNull); + + await tester.pump(); + await tester.pump(); + + renderAutoVectorGraphic1.paint(context, Offset.zero); + expect(context.canvas.lastImage, isNotNull); + final ui.Image? oldImage = context.canvas.lastImage; + + context.canvas.resetImage(); + + renderAutoVectorGraphic2.paint(context, Offset.zero); + expect(context.canvas.lastImage, isNotNull); + expect(context.canvas.lastImage, equals(oldImage)); + + renderAutoVectorGraphic1.dispose(); + renderAutoVectorGraphic2.dispose(); + }); } class FakeCanvas extends Fake implements Canvas { @@ -437,6 +974,8 @@ class FakeCanvas extends Fake implements Canvas { int saveCount = 0; int totalSaves = 0; int totalSaveLayers = 0; + double scaleX = 1.0; + double scaleY = 1.0; @override void drawImageRect(ui.Image image, Rect src, Rect dst, Paint paint) { @@ -481,6 +1020,28 @@ class FakeCanvas extends Fake implements Canvas { {ui.ClipOp clipOp = ui.ClipOp.intersect, bool doAntiAlias = true}) { lastClipRect = rect; } + + @override + void scale(double sx, [double? sy]) { + scaleX = sx; + scaleY = sx; + if (sy != null) { + scaleY = sy; + } + } + + @override + void translate(double dx, double dy) {} + + @override + Float64List getTransform() { + return Float64List.fromList( + [scaleX, 0, 0, 0, 0, scaleY, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]); + } + + void resetImage() { + lastImage = null; + } } class FakeHistoryCanvas extends Fake implements Canvas { diff --git a/packages/vector_graphics/test/vector_graphics_test.dart b/packages/vector_graphics/test/vector_graphics_test.dart index 6777355e44b..1a9ae2c9398 100644 --- a/packages/vector_graphics/test/vector_graphics_test.dart +++ b/packages/vector_graphics/test/vector_graphics_test.dart @@ -701,6 +701,35 @@ void main() { expect(find.text('Error is handled'), findsOneWidget); expect(tester.takeException(), isNull); }); + + testWidgets( + 'Construct vector graphic with auto strategy', + (WidgetTester tester) async { + final TestAssetBundle testBundle = TestAssetBundle(); + + await tester.pumpWidget( + DefaultAssetBundle( + bundle: testBundle, + child: Directionality( + textDirection: TextDirection.ltr, + child: createCompatVectorGraphic( + loader: const AssetBytesLoader('foo.svg'), + colorFilter: + const ColorFilter.mode(Colors.red, BlendMode.srcIn), + opacity: const AlwaysStoppedAnimation(0.5), + strategy: RenderingStrategy.auto), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(tester.layers.last, isA()); + // Opacity and color filter are drawn as savelayer + expect(tester.layers, isNot(contains(isA()))); + expect(tester.layers, isNot(contains(isA()))); + }, + skip: kIsWeb, + ); // picture rasterization works differently on HTML due to saveLayer bugs in HTML backend } class TestBundle extends Fake implements AssetBundle {