fix(mobile): handle image stream completion when no image is emitted (#25984)

* Fix image cancellation to be stream-scoped instead of widget-scoped

* fix(OneFramePlaceholderImageStreamCompleter): make onLastListenerRemoved callback synchronous with removing the last listener

* fix(OneFrameMultiImageStreamCompleter): remove unnecessary blank line in code

* fix(OneFramePlaceholderImageStreamCompleter): cancel pending requests when only cache listener remains

* fix(OneFrameMultiImageStreamCompleter): ensure onLastListenerRemoved callback is invoked only once
This commit is contained in:
Luis Nachtigall 2026-02-09 22:59:29 +01:00 committed by GitHub
parent 937bef9a4d
commit 561469b826
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 34 additions and 25 deletions

View file

@ -31,7 +31,7 @@ class LocalThumbProvider extends CancellableImageProvider<LocalThumbProvider>
DiagnosticsProperty<String>('Id', key.id), DiagnosticsProperty<String>('Id', key.id),
DiagnosticsProperty<Size>('Size', key.size), DiagnosticsProperty<Size>('Size', key.size),
], ],
onDispose: cancel, onLastListenerRemoved: cancel,
); );
} }
@ -76,7 +76,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
DiagnosticsProperty<String>('Id', key.id), DiagnosticsProperty<String>('Id', key.id),
DiagnosticsProperty<Size>('Size', key.size), DiagnosticsProperty<Size>('Size', key.size),
], ],
onDispose: cancel, onLastListenerRemoved: cancel,
); );
} }

View file

@ -9,7 +9,10 @@ import 'package:flutter/painting.dart';
/// An ImageStreamCompleter with support for loading multiple images. /// An ImageStreamCompleter with support for loading multiple images.
class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter { class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter {
void Function()? _onDispose; void Function()? _onLastListenerRemoved;
int _listenerCount = 0;
// True once setImage() has been called at least once.
bool didProvideImage = false;
/// The constructor to create an OneFramePlaceholderImageStreamCompleter. The [images] /// The constructor to create an OneFramePlaceholderImageStreamCompleter. The [images]
/// should be the primary images to display (typically asynchronously as they load). /// should be the primary images to display (typically asynchronously as they load).
@ -19,14 +22,18 @@ class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter {
Stream<ImageInfo> images, { Stream<ImageInfo> images, {
ImageInfo? initialImage, ImageInfo? initialImage,
InformationCollector? informationCollector, InformationCollector? informationCollector,
void Function()? onDispose, void Function()? onLastListenerRemoved,
}) { }) {
if (initialImage != null) { if (initialImage != null) {
didProvideImage = true;
setImage(initialImage); setImage(initialImage);
} }
_onDispose = onDispose; _onLastListenerRemoved = onLastListenerRemoved;
images.listen( images.listen(
setImage, (image) {
didProvideImage = true;
setImage(image);
},
onError: (Object error, StackTrace stack) { onError: (Object error, StackTrace stack) {
reportError( reportError(
context: ErrorDescription('resolving a single-frame image stream'), context: ErrorDescription('resolving a single-frame image stream'),
@ -40,12 +47,24 @@ class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter {
} }
@override @override
void onDisposed() { void addListener(ImageStreamListener listener) {
final onDispose = _onDispose; super.addListener(listener);
if (onDispose != null) { _listenerCount = _listenerCount + 1;
_onDispose = null; }
onDispose();
@override
void removeListener(ImageStreamListener listener) {
super.removeListener(listener);
_listenerCount = _listenerCount - 1;
final bool onlyCacheListenerLeft = _listenerCount == 1 && !didProvideImage;
final bool noListenersAfterImage = _listenerCount == 0 && didProvideImage;
final onLastListenerRemoved = _onLastListenerRemoved;
if (onLastListenerRemoved != null && (noListenersAfterImage || onlyCacheListenerLeft)) {
_onLastListenerRemoved = null;
onLastListenerRemoved();
} }
super.onDisposed();
} }
} }

View file

@ -32,7 +32,7 @@ class RemoteImageProvider extends CancellableImageProvider<RemoteImageProvider>
DiagnosticsProperty<ImageProvider>('Image provider', this), DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('URL', key.url), DiagnosticsProperty<String>('URL', key.url),
], ],
onDispose: cancel, onLastListenerRemoved: cancel,
); );
} }
@ -76,7 +76,7 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
DiagnosticsProperty<ImageProvider>('Image provider', this), DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Asset Id', key.assetId), DiagnosticsProperty<String>('Asset Id', key.assetId),
], ],
onDispose: cancel, onLastListenerRemoved: cancel,
); );
} }

View file

@ -17,7 +17,7 @@ class ThumbHashProvider extends CancellableImageProvider<ThumbHashProvider>
@override @override
ImageStreamCompleter loadImage(ThumbHashProvider key, ImageDecoderCallback decode) { ImageStreamCompleter loadImage(ThumbHashProvider key, ImageDecoderCallback decode) {
return OneFramePlaceholderImageStreamCompleter(_loadCodec(key, decode), onDispose: cancel); return OneFramePlaceholderImageStreamCompleter(_loadCodec(key, decode), onLastListenerRemoved: cancel);
} }
Stream<ImageInfo> _loadCodec(ThumbHashProvider key, ImageDecoderCallback decode) { Stream<ImageInfo> _loadCodec(ThumbHashProvider key, ImageDecoderCallback decode) {

View file

@ -233,16 +233,6 @@ class _ThumbnailState extends State<Thumbnail> with SingleTickerProviderStateMix
@override @override
void dispose() { void dispose() {
final imageProvider = widget.imageProvider;
if (imageProvider is CancellableImageProvider) {
imageProvider.cancel();
}
final thumbhashProvider = widget.thumbhashProvider;
if (thumbhashProvider is CancellableImageProvider) {
thumbhashProvider.cancel();
}
_fadeController.removeStatusListener(_onAnimationStatusChanged); _fadeController.removeStatusListener(_onAnimationStatusChanged);
_fadeController.dispose(); _fadeController.dispose();
_stopListeningToStream(); _stopListeningToStream();