diff --git a/cached_network_image/lib/src/image_provider/multi_image_stream_completer.dart b/cached_network_image/lib/src/image_provider/multi_image_stream_completer.dart index 80c1dac5..53c2fa5e 100644 --- a/cached_network_image/lib/src/image_provider/multi_image_stream_completer.dart +++ b/cached_network_image/lib/src/image_provider/multi_image_stream_completer.dart @@ -38,7 +38,7 @@ class MultiImageStreamCompleter extends ImageStreamCompleter { ); }); if (chunkEvents != null) { - chunkEvents.listen( + _chunkSubscription = chunkEvents.listen( reportImageChunkEvent, onError: (dynamic error, StackTrace stack) { reportError( @@ -65,10 +65,17 @@ class MultiImageStreamCompleter extends ImageStreamCompleter { // How many frames have been emitted so far. int _framesEmitted = 0; Timer? _timer; + StreamSubscription? _chunkSubscription; // Used to guard against registering multiple _handleAppFrame callbacks for the same frame. bool _frameCallbackScheduled = false; + /// We must avoid disposing a completer if it never had a listener, even + /// if all [keepAlive] handles get disposed. + bool __hadAtLeastOneListener = false; + + bool __disposed = false; + void _switchToNewCodec() { _framesEmitted = 0; _timer = null; @@ -159,6 +166,7 @@ class MultiImageStreamCompleter extends ImageStreamCompleter { @override void addListener(ImageStreamListener listener) { + __hadAtLeastOneListener = true; if (!hasListeners && _codec != null) _decodeNextFrameAndSchedule(); super.addListener(listener); } @@ -169,6 +177,56 @@ class MultiImageStreamCompleter extends ImageStreamCompleter { if (!hasListeners) { _timer?.cancel(); _timer = null; + __maybeDispose(); } } + + int __keepAliveHandles = 0; + + @override + ImageStreamCompleterHandle keepAlive() { + final delegateHandle = super.keepAlive(); + return _MultiImageStreamCompleterHandle(this, delegateHandle); + } + + void __maybeDispose() { + if (!__hadAtLeastOneListener || + __disposed || + hasListeners || + __keepAliveHandles != 0) { + return; + } + + __disposed = true; + + _chunkSubscription?.onData(null); + _chunkSubscription?.cancel(); + _chunkSubscription = null; + } +} + +class _MultiImageStreamCompleterHandle implements ImageStreamCompleterHandle { + _MultiImageStreamCompleterHandle(this._completer, this._delegateHandle) { + _completer!.__keepAliveHandles += 1; + } + + MultiImageStreamCompleter? _completer; + final ImageStreamCompleterHandle _delegateHandle; + + /// Call this method to signal the [ImageStreamCompleter] that it can now be + /// disposed when its last listener drops. + /// + /// This method must only be called once per object. + @override + void dispose() { + assert(_completer != null); + assert(_completer!.__keepAliveHandles > 0); + assert(!_completer!.__disposed); + + _delegateHandle.dispose(); + + _completer!.__keepAliveHandles -= 1; + _completer!.__maybeDispose(); + _completer = null; + } } diff --git a/cached_network_image/test/image_stream_completer_test.dart b/cached_network_image/test/image_stream_completer_test.dart index 2096c0fc..68ccda05 100644 --- a/cached_network_image/test/image_stream_completer_test.dart +++ b/cached_network_image/test/image_stream_completer_test.dart @@ -96,6 +96,37 @@ void main() { expect(tester.takeException(), 'failure message'); }); + test('Completer unsubscribes to chunk events when disposed', () async { + final codecStream = StreamController(); + final chunkStream = StreamController(); + + final MultiImageStreamCompleter completer = MultiImageStreamCompleter( + codec: codecStream.stream, + scale: 1.0, + chunkEvents: chunkStream.stream, + ); + + expect(chunkStream.hasListener, true); + + chunkStream.add( + const ImageChunkEvent(cumulativeBytesLoaded: 1, expectedTotalBytes: 3)); + + final ImageStreamListener listener = + ImageStreamListener((ImageInfo info, bool syncCall) {}); + // Cause the completer to dispose. + completer.addListener(listener); + completer.removeListener(listener); + + expect(chunkStream.hasListener, false); + + // The above expectation should cover this, but the point of this test is to + // make sure the completer does not assert that it's disposed and still + // receiving chunk events. Streams from the network can keep sending data + // even after evicting an image from the cache, for example. + chunkStream.add( + const ImageChunkEvent(cumulativeBytesLoaded: 2, expectedTotalBytes: 3)); + }); + testWidgets('Decoding starts when a listener is added after codec is ready', (WidgetTester tester) async { final codecStream = StreamController();