diff --git a/engine/src/flutter/lib/ui/painting.dart b/engine/src/flutter/lib/ui/painting.dart index d6e6f37dab..5905633ece 100644 --- a/engine/src/flutter/lib/ui/painting.dart +++ b/engine/src/flutter/lib/ui/painting.dart @@ -2630,7 +2630,12 @@ void decodeImageFromList(Uint8List list, ImageDecoderCallback callback) { Future _decodeImageFromListAsync(Uint8List list, ImageDecoderCallback callback) async { final Codec codec = await instantiateImageCodec(list); - final FrameInfo frameInfo = await codec.getNextFrame(); + final FrameInfo frameInfo; + try { + frameInfo = await codec.getNextFrame(); + } finally { + codec.dispose(); + } callback(frameInfo.image); } diff --git a/engine/src/flutter/lib/web_ui/lib/painting.dart b/engine/src/flutter/lib/web_ui/lib/painting.dart index 4c14c5b570..40ae02b0c9 100644 --- a/engine/src/flutter/lib/web_ui/lib/painting.dart +++ b/engine/src/flutter/lib/web_ui/lib/painting.dart @@ -695,7 +695,12 @@ void decodeImageFromList(Uint8List list, ImageDecoderCallback callback) { Future _decodeImageFromListAsync(Uint8List list, ImageDecoderCallback callback) async { final Codec codec = await instantiateImageCodec(list); - final FrameInfo frameInfo = await codec.getNextFrame(); + final FrameInfo frameInfo; + try { + frameInfo = await codec.getNextFrame(); + } finally { + codec.dispose(); + } callback(frameInfo.image); } diff --git a/packages/flutter/lib/src/painting/image_decoder.dart b/packages/flutter/lib/src/painting/image_decoder.dart index 5afd8555c8..d5b801f1eb 100644 --- a/packages/flutter/lib/src/painting/image_decoder.dart +++ b/packages/flutter/lib/src/painting/image_decoder.dart @@ -25,6 +25,11 @@ import 'binding.dart'; Future decodeImageFromList(Uint8List bytes) async { final ui.ImmutableBuffer buffer = await ui.ImmutableBuffer.fromUint8List(bytes); final ui.Codec codec = await PaintingBinding.instance.instantiateImageCodecWithSize(buffer); - final ui.FrameInfo frameInfo = await codec.getNextFrame(); + final ui.FrameInfo frameInfo; + try { + frameInfo = await codec.getNextFrame(); + } finally { + codec.dispose(); + } return frameInfo.image; } diff --git a/packages/flutter/lib/src/painting/image_stream.dart b/packages/flutter/lib/src/painting/image_stream.dart index 34067fce90..91d486c1cf 100644 --- a/packages/flutter/lib/src/painting/image_stream.dart +++ b/packages/flutter/lib/src/painting/image_stream.dart @@ -980,7 +980,8 @@ class MultiFrameImageStreamCompleter extends ImageStreamCompleter { /// Immediately starts decoding the first image frame when the codec is ready. /// /// The `codec` parameter is a future for an initialized [ui.Codec] that will - /// be used to decode the image. + /// be used to decode the image. This completer takes ownership of the passed + /// `codec` and will dispose it once it is no longer needed. /// /// The `scale` parameter is the linear scale factor for drawing this frames /// of this image at their intended size. @@ -1071,7 +1072,11 @@ class MultiFrameImageStreamCompleter extends ImageStreamCompleter { final int completedCycles = _framesEmitted ~/ _codec!.frameCount; if (_codec!.repetitionCount == -1 || completedCycles <= _codec!.repetitionCount) { _decodeNextFrameAndSchedule(); + return; } + + _codec!.dispose(); + _codec = null; return; } final Duration delay = _frameDuration! - (timestamp - _shownTimestamp); @@ -1105,6 +1110,11 @@ class MultiFrameImageStreamCompleter extends ImageStreamCompleter { ); return; } + if (_codec == null) { + // codec was disposed during getNextFrame + return; + } + if (_codec!.frameCount == 1) { // ImageStreamCompleter listeners removed while waiting for next frame to // be decoded. @@ -1119,6 +1129,9 @@ class MultiFrameImageStreamCompleter extends ImageStreamCompleter { ); _nextFrame!.image.dispose(); _nextFrame = null; + + _codec!.dispose(); + _codec = null; return; } _scheduleAppFrame(); @@ -1161,6 +1174,9 @@ class MultiFrameImageStreamCompleter extends ImageStreamCompleter { _chunkSubscription?.onData(null); _chunkSubscription?.cancel(); _chunkSubscription = null; + + _codec?.dispose(); + _codec = null; } } } diff --git a/packages/flutter/test/painting/image_stream_test.dart b/packages/flutter/test/painting/image_stream_test.dart index 50bbe2a1c8..58c2ad8c4a 100644 --- a/packages/flutter/test/painting/image_stream_test.dart +++ b/packages/flutter/test/painting/image_stream_test.dart @@ -41,10 +41,16 @@ class MockCodec implements Codec { int numFramesAsked = 0; + bool disposed = false; + Completer _nextFrameCompleter = Completer(); @override Future getNextFrame() { + if (disposed) { + throw StateError('Codec is disposed'); + } + numFramesAsked += 1; return _nextFrameCompleter.future; } @@ -59,7 +65,13 @@ class MockCodec implements Codec { } @override - void dispose() {} + void dispose() { + if (disposed) { + throw StateError('Codec is already disposed'); + } + + disposed = true; + } } class FakeEventReportingImageStreamCompleter extends ImageStreamCompleter { @@ -106,6 +118,7 @@ void main() { imageStream.addListener(ImageStreamListener(listener)); await tester.idle(); expect(mockCodec.numFramesAsked, 1); + expect(mockCodec.disposed, false); }); testWidgets('Decoding starts when a codec is ready after a listener is added', ( @@ -130,6 +143,7 @@ void main() { completer.complete(mockCodec); await tester.idle(); expect(mockCodec.numFramesAsked, 1); + expect(mockCodec.disposed, false); }); testWidgets('Decoding does not crash when disposed', (WidgetTester tester) async { @@ -156,7 +170,9 @@ void main() { final FrameInfo frame = FakeFrameInfo(const Duration(milliseconds: 200), image20x10); mockCodec.completeNextFrame(frame); + expect(mockCodec.disposed, false); imageStream.removeListener(streamListener); + expect(mockCodec.disposed, true); await tester.idle(); }); @@ -334,6 +350,7 @@ void main() { await tester.idle(); expect(tester.takeException(), 'frame completion error'); + expect(mockCodec.disposed, false); }); testWidgets('ImageStream emits frame (static image)', (WidgetTester tester) async { @@ -362,7 +379,9 @@ void main() { final FrameInfo frame = FakeFrameInfo(const Duration(milliseconds: 200), image20x10); mockCodec.completeNextFrame(frame); + expect(mockCodec.disposed, false); await tester.idle(); + expect(mockCodec.disposed, true); expect(emittedImages.every((ImageInfo info) => info.image.isCloneOf(frame.image)), true); @@ -420,7 +439,9 @@ void main() { // quit the test without pending timers. await tester.pump(const Duration(milliseconds: 400)); + expect(mockCodec.disposed, false); imageStream.removeListener(listener); + expect(mockCodec.disposed, true); imageCache.clear(); }); @@ -469,7 +490,9 @@ void main() { // quit the test without pending timers. await tester.pump(const Duration(milliseconds: 200)); + expect(mockCodec.disposed, false); imageStream.removeListener(listener); + expect(mockCodec.disposed, true); imageCache.clear(); }); @@ -505,7 +528,9 @@ void main() { await tester.pump(); // first animation frame shows on first app frame. mockCodec.completeNextFrame(frame2); await tester.idle(); // let nextFrameFuture complete + expect(mockCodec.disposed, false); await tester.pump(const Duration(milliseconds: 200)); // emit 2nd frame. + expect(mockCodec.disposed, true); mockCodec.completeNextFrame(frame1); // allow another frame to complete (but we shouldn't be asking for it as // this animation should not repeat. @@ -562,7 +587,9 @@ void main() { expect(mockCodec.numFramesAsked, 3); handle.dispose(); + expect(mockCodec.disposed, false); imageStream.removeListener(listener); + expect(mockCodec.disposed, true); imageCache.clear(); }); @@ -619,7 +646,9 @@ void main() { expect(emittedImages2[0].image.isCloneOf(frame1.image), true); expect(emittedImages2[1].image.isCloneOf(frame2.image), true); + expect(mockCodec.disposed, false); imageStream.removeListener(listener2); + expect(mockCodec.disposed, true); }); testWidgets('timer is canceled when listeners are removed', (WidgetTester tester) async { @@ -653,7 +682,9 @@ void main() { await tester.idle(); // let nextFrameFuture complete await tester.pump(); + expect(mockCodec.disposed, false); imageStream.removeListener(ImageStreamListener(listener)); + expect(mockCodec.disposed, true); // The test framework will fail this if there are pending timers at this // point. }); @@ -699,7 +730,9 @@ void main() { expect(mockCodec.numFramesAsked, 3); timeDilation = 1.0; // restore time dilation, or it will affect other tests + expect(mockCodec.disposed, false); imageStream.removeListener(listener); + expect(mockCodec.disposed, true); }); testWidgets('error handlers can intercept errors', (WidgetTester tester) async { @@ -734,6 +767,7 @@ void main() { // No exception is passed up. expect(tester.takeException(), isNull); expect(capturedException, 'frame completion error'); + expect(mockCodec.disposed, false); }); testWidgets( @@ -772,6 +806,7 @@ void main() { await tester.pump(); // first animation frame shows on first app frame. await tester.pump(const Duration(milliseconds: 200)); // emit 2nd frame. + expect(mockCodec.disposed, false); }, ); @@ -918,7 +953,9 @@ void main() { expect(onImageCount, 1); + expect(mockCodec.disposed, false); handle.dispose(); + expect(mockCodec.disposed, true); }); test('MultiFrameImageStreamCompleter - one frame image should only be decoded once', () async {