7mada123 / disposable_cached_images

MIT License
9 stars 4 forks source link

Using Flutters ImageProvider Logic #4

Open Klabauterman opened 2 years ago

Klabauterman commented 2 years ago

I was using cached_network_image and I need to switch for my project, because it is constantly crashing on iOS. The problem with that library was that network requests are never cancelled which is a problem if the user scrolls through a long list of images.

So I started to compare implementations of Image caching libraries. The pros of this library is that it uses isolates and it cancels network requests. Compared to other libraries it comes with one great disadvantage: It does not provide ImageProviders which seems to be the flutter standard when handling images. ImageProviders also use the flutter ImageCache as a in-memory-cache which already works well. It would be really great to get the best of both worlds.

I am thinking about writing a proof of concept.

7mada123 commented 2 years ago

I tried to use ImageProvider before but I found some issues especially with memory usage, that why I am using Image directly from dart: ui.

I also think the package should use ImageProvider because it is the standard and many widgets depend on it but it will take some time to get the same functionality due to the restrictions that we have on ImageProvider class.

Klabauterman commented 2 years ago

@7mada123 I think I know what you mean. I am trying to understand the framework around ImageProvider ImageStream and ImageStreamCompleter. I started playing around by copying the code from painting/_network_image_io.dart and changed the loading code. The problem seems to be that an ImageStreamCompleter which handles the loading is disposed as soon as there are no more listeners on it. That would be the point to cancel a network request. But one of the listeners is the ImageCache, so there is still one listener left, when the actual image widget gets disposed and we cannot cancel the request.

If I find a nice way around that, I'll let you know.

Klabauterman commented 1 year ago

In case you are interested, I figured it out, how it works with the ImageProviders. It is kind of hacky, but it works.

We need to implement a custom ImageStreamCompleter which is returned by our custom ImageProvider that keeps track of its listeners and its loading state. The flutter framework uses an ImageCache as a singleton. It keeps track of currently loading images and caches loaded images in memory. We need to take into account that the first listener of ImageStreamCompleter is always the ImageCache, so if there is only 1 listener left during the loading of the image. It is not used anymore and we can cancel the request.

The completer could look like this:

class DisposableImageCompleter extends MultiFrameImageStreamCompleter {
  final Object key;
  DisposableImageCompleter({
    required this.key,
    required Future<ui.Codec> codec,
    required double scale,
    String? debugLabel,
    Stream<ImageChunkEvent>? chunkEvents,
    InformationCollector? informationCollector,
  }) : super(
            codec: codec,
            scale: scale,
            debugLabel: debugLabel,
            chunkEvents: chunkEvents,
            informationCollector: informationCollector);

  int listenerCount = 0;
  bool loaded = false;

  @override
  void setImage(ImageInfo image) {
    loaded = true;
    super.setImage(image);
  }

  @override
  void addListener(ImageStreamListener listener) {
    //print("ADD LISTENER");
    super.addListener(listener);
    listenerCount++;
  }

  @override
  void removeListener(ImageStreamListener listener) {
    //print("REMOVE LISTENER");
    super.removeListener(listener);
    listenerCount--;
    if (listenerCount == 1 && !loaded) {
      // ONLY ImageCache is subscribed -> remove it if it has not finished
      PaintingBinding.instance.imageCache.evict(key);
    }
  }

  @override
  void reportError(
      {DiagnosticsNode? context,
      required Object exception,
      StackTrace? stack,
      InformationCollector? informationCollector,
      bool silent = false}) {
    //DO NOT REPORT ANYTHING WHEN THE IMAGE LOAD FAILED OR WAS CANCELLED
  }
}

In the ImageProvider we could use the event stream as an indicator when we can cancel the request.

class CachingNetworkImageProvider extends ImageProvider<NetworkImage> implements NetworkImage {
  const CachingNetworkImageProvider(this.url, {this.scale = 1.0, this.headers});

  @override
  final String url;

  @override
  final double scale;

  @override
  final Map<String, String>? headers;

  @override
  Future<NetworkImage> obtainKey(ImageConfiguration configuration) {
    return SynchronousFuture<NetworkImage>(this);
  }

  @override
  bool operator ==(Object other) {
    if (other.runtimeType != runtimeType) {
      return false;
    }
    return other is NetworkImage && other.url == url && other.scale == scale;
  }

  @override
  int get hashCode => Object.hash(url, scale);

  @override
  String toString() => '${objectRuntimeType(this, 'NetworkImage')}("$url", scale: $scale)';

  @override
  ImageStreamCompleter load(NetworkImage key, DecoderCallback decode) {
    // Ownership of this controller is handed off to [_loadAsync]; it is that
    // method's responsibility to close the controller's stream when the image
    // has been loaded or an error is thrown.
    final StreamController<ImageChunkEvent> chunkEvents = StreamController<ImageChunkEvent>();

    return DisposableImageCompleter(
      key: key,
      codec: _loadAsync(key, chunkEvents, decode),
      chunkEvents: chunkEvents.stream,
      scale: key.scale,
      debugLabel: key.url,
    );
  }

  Future<ui.Codec> _loadAsync(
    NetworkImage key,
    StreamController<ImageChunkEvent> chunkEvents,
    DecoderCallback decode,
  ) async {
    try {
      final networkLoader =
          NetworkLoader(url: key.url, headers: key.headers, chunkEvents: chunkEvents);
      final fileCache = FileLoader(url: key.url);

      chunkEvents.onCancel = () {
        networkLoader.cancel();
        fileCache.cancel();
      };

      var bytes = await fileCache.load();
      if (bytes == null) {
        bytes = await networkLoader.load();
        if (bytes != null) {
          fileCache.store(url, bytes);
        }
      }

      if (bytes == null) throw Exception("No image returned");
      return decode(bytes);
    } finally {
      chunkEvents.close();
    }
  }
}
7mada123 commented 1 year ago

That's great @Klabauterman, thanks for the clarification, This may take a while to be implemented and tested, I think I'll be able to work on it next week, if you have some spare time please contribute so we can get things done faster.

ps6067966 commented 1 year ago

Please add this feature.