Realization of Loading and Caching Network Pictures in Flutter

  • 2021-11-02 02:33:24
  • OfStack

Preface

In application development, we often encounter the loading of network pictures. Usually, we cache the pictures so that we don't need to download the same picture again when loading the next time. In applications containing a large number of pictures, it will greatly improve the speed of picture display, improve the user experience and save traffic for users. Image Widget provided by Flutter itself has realized the function of loading network pictures, and has the mechanism of memory cache. Next, look at the realization of loading network pictures of Image.

Revisiting the widget Image

Several constructors are implemented in the common widget Image, which is enough for us to create Image objects in various scenarios in daily development.

Parameterized constructor:

Image(Key key, @required this.image, ...)

Developers can create Image from custom ImageProvider.

Named constructor:

Image.network(String src, ...)

src is the picture url address obtained according to the network.

Image.file(File file, ...)

file refers to a local picture file object, and android. permission. READ_EXTERNAL_STORAGE permission is required in Android.

Image.asset(String name, ...)

name refers to the image resource name added to the project, which is declared in the pubspec. yaml file in advance.

Image.memory(Uint8List bytes, ...)

bytes refers to image data in memory, which is converted into image objects.

Among them, Image. network is the focus of our sharing in this article-loading network pictures.

Image. network source code analysis

The following through the source we look at the Image. network loading network pictures of the specific implementation.


 Image.network(String src, {
  Key key,
  double scale = 1.0,
  .
  .
 }) : image = NetworkImage(src, scale: scale, headers: headers),
    assert(alignment != null),
    assert(repeat != null),
    assert(matchTextDirection != null),
    super(key: key);

 /// The image to display.
 final ImageProvider image;

First of all, when using Image. network named constructor to create Image object, the instance variable image will be initialized at the same time. image is an ImageProvider object, which is the provider of the pictures we need. It is an abstract class, and its subclasses include NetworkImage, FileImage, ExactAssetImage, AssetImage, MemoryImage, etc.

As an StatefulWidget, the state of Image is controlled by _ ImageState, and _ ImageState inherits from State class. Its life cycle methods include initState (), didChangeDependencies (), build (), deactivate (), dispose (), didUpdateWidget () and so on. We focus on the execution of functions in _ ImageState.

Since the initState () function is called first and then the didChangeDependencies () function is called when inserting the rendering tree, the initState () function is not overridden in _ ImageState, so the didChangeDependencies () function will be executed. Look at the contents in didChangeDependencies ()


@override
 void didChangeDependencies() {
  _invertColors = MediaQuery.of(context, nullOk: true)?.invertColors
   ?? SemanticsBinding.instance.accessibilityFeatures.invertColors;
  _resolveImage();

  if (TickerMode.of(context))
   _listenToStream();
  else
   _stopListeningToStream();

  super.didChangeDependencies();
 }

_resolveImage() Will be called, and the function content is as follows 

 void _resolveImage() {
  final ImageStream newStream =
   widget.image.resolve(createLocalImageConfiguration(
     context,
     size: widget.width != null && widget.height != null ? Size(widget.width, widget.height) : null
   ));
  assert(newStream != null);
  _updateSourceStream(newStream);
 }

Function first created an ImageStream object, this object is a picture resource handle, it holds the picture resource after loading the listening callback and the picture resource manager. Among them, ImageStreamCompleter object is a management class of picture resources, that is to say, _ ImageState is connected through ImageStream and ImageStreamCompleter management classes.

Looking back at 1, the ImageStream object is created by widget. image. resolve method, that is, the resolve method corresponding to NetworkImage. When we look at the source code of NetworkImage class, we find that there is no resolve method, so we find its parent class and find it in ImageProvider class.


 ImageStream resolve(ImageConfiguration configuration) {
  assert(configuration != null);
  final ImageStream stream = ImageStream();
  T obtainedKey;
  Future<void> handleError(dynamic exception, StackTrace stack) async {
   .
   .
  }
  obtainKey(configuration).then<void>((T key) {
   obtainedKey = key;
   final ImageStreamCompleter completer = PaintingBinding.instance.imageCache.putIfAbsent(key, () => load(key), onError: handleError);
   if (completer != null) {
    stream.setCompleter(completer);
   }
  }).catchError(handleError);
  return stream;
 }

Image manager ImageStreamCompleter in ImageStream via PaintingBinding. instance. imageCache. putIfAbsent (key, () = > load (key), onError: handleError); Method, imageCache is a singleton implemented in the Flutter framework for picture caching, see the putIfAbsent method


 ImageStreamCompleter putIfAbsent(Object key, ImageStreamCompleter loader(), { ImageErrorListener onError }) {
  assert(key != null);
  assert(loader != null);
  ImageStreamCompleter result = _pendingImages[key]?.completer;
  // Nothing needs to be done because the image hasn't loaded yet.
  if (result != null)
   return result;
  // Remove the provider from the list so that we can move it to the
  // recently used position below.
  final _CachedImage image = _cache.remove(key);
  if (image != null) {
   _cache[key] = image;
   return image.completer;
  }
  try {
   result = loader();
  } catch (error, stackTrace) {
   if (onError != null) {
    onError(error, stackTrace);
    return null;
   } else {
    rethrow;
   }
  }
  void listener(ImageInfo info, bool syncCall) {
   // Images that fail to load don't contribute to cache size.
   final int imageSize = info?.image == null ? 0 : info.image.height * info.image.width * 4;
   final _CachedImage image = _CachedImage(result, imageSize);
   // If the image is bigger than the maximum cache size, and the cache size
   // is not zero, then increase the cache size to the size of the image plus
   // some change.
   if (maximumSizeBytes > 0 && imageSize > maximumSizeBytes) {
    _maximumSizeBytes = imageSize + 1000;
   }
   _currentSizeBytes += imageSize;
   final _PendingImage pendingImage = _pendingImages.remove(key);
   if (pendingImage != null) {
    pendingImage.removeListener();
   }

   _cache[key] = image;
   _checkCacheSize();
  }
  if (maximumSize > 0 && maximumSizeBytes > 0) {
   _pendingImages[key] = _PendingImage(result, listener);
   result.addListener(listener);
  }
  return result;
 }

Through the above code can be seen through the key to find whether there is a cache, if there is return, if not there will be through the implementation of loader () method to create a picture resource manager, and then the cached picture resource monitoring method registered to the new picture manager in order to do cache processing after the picture is loaded.

Call PaintingBinding. instance. imageCache. putIfAbsent (key, () = > load (key), onError: handleError); See that the load () method is implemented by the ImageProvider object, here is the NetworkImage object, look at its specific implementation code


 @override
 ImageStreamCompleter load(NetworkImage key) {
  return MultiFrameImageStreamCompleter(
   codec: _loadAsync(key),
   scale: key.scale,
   informationCollector: (StringBuffer information) {
    information.writeln('Image provider: $this');
    information.write('Image key: $key');
   }
  );
 }

In the code, it is to create an MultiFrameImageStreamCompleter object and return it, which is a multi-frame picture manager, indicating that Flutter supports GIF pictures. The codec variable when the object is created is initialized by the return value of the _ loadAsync method. View the contents of this method


 static final HttpClient _httpClient = HttpClient();

 Future<ui.Codec> _loadAsync(NetworkImage key) async {
  assert(key == this);

  final Uri resolved = Uri.base.resolve(key.url);
  final HttpClientRequest request = await _httpClient.getUrl(resolved);
  headers?.forEach((String name, String value) {
   request.headers.add(name, value);
  });
  final HttpClientResponse response = await request.close();
  if (response.statusCode != HttpStatus.ok)
   throw Exception('HTTP request failed, statusCode: ${response?.statusCode}, $resolved');

  final Uint8List bytes = await consolidateHttpClientResponseBytes(response);
  if (bytes.lengthInBytes == 0)
   throw Exception('NetworkImage is an empty file: $resolved');

  return PaintingBinding.instance.instantiateImageCodec(bytes);
 }

Here is the key, that is, through the HttpClient object to the specified url download operation, download completed according to the picture binary data instantiation image codec object Codec, and then return.

Then how is the picture displayed on the interface after downloading? Let's look at the construction method of MultiFrameImageStreamCompleter


 MultiFrameImageStreamCompleter({
  @required Future<ui.Codec> codec,
  @required double scale,
  InformationCollector informationCollector
 }) : assert(codec != null),
    _informationCollector = informationCollector,
    _scale = scale,
    _framesEmitted = 0,
    _timer = null {
  codec.then<void>(_handleCodecReady, onError: (dynamic error, StackTrace stack) {
   reportError(
    context: 'resolving an image codec',
    exception: error,
    stack: stack,
    informationCollector: informationCollector,
    silent: true,
   );
  });
 }

Look at the code block in the constructor. After the asynchronous method of codec is executed, the _ handleCodecReady function will be called. The content of the function is as follows


 void _handleCodecReady(ui.Codec codec) {
  _codec = codec;
  assert(_codec != null);

  _decodeNextFrameAndSchedule();
 }

The codec object is saved in the method, and then the picture frame is decoded


 Future<void> _decodeNextFrameAndSchedule() async {
  try {
   _nextFrame = await _codec.getNextFrame();
  } catch (exception, stack) {
   reportError(
    context: 'resolving an image frame',
    exception: exception,
    stack: stack,
    informationCollector: _informationCollector,
    silent: true,
   );
   return;
  }
  if (_codec.frameCount == 1) {
   // This is not an animated image, just return it and don't schedule more
   // frames.
   _emitFrame(ImageInfo(image: _nextFrame.image, scale: _scale));
   return;
  }
  SchedulerBinding.instance.scheduleFrameCallback(_handleAppFrame);
 }

If the picture is png or jpg only has 1 frame, the _emitFrame function is executed, the picture frame object is obtained from the frame data, and the ImageInfo object is created according to the scaling ratio, and then the displayed picture information is set


 void _emitFrame(ImageInfo imageInfo) {
  setImage(imageInfo);
  _framesEmitted += 1;
 }
 
 /// Calls all the registered listeners to notify them of a new image.
 @protected
 void setImage(ImageInfo image) {
  _currentImage = image;
  if (_listeners.isEmpty)
   return;
  final List<ImageListener> localListeners = _listeners.map<ImageListener>(
   (_ImageListenerPair listenerPair) => listenerPair.listener
  ).toList();
  for (ImageListener listener in localListeners) {
   try {
    listener(image, false);
   } catch (exception, stack) {
    reportError(
     context: 'by an image listener',
     exception: exception,
     stack: stack,
    );
   }
  }
 }

At this time, a new picture needs to be rendered according to the added listener. So when did this listener be added? Let's look back at the contents of didChangeDependencies () method in the _ ImageState class under 1 and finish executing _ resolveImage (); _ listenToStream () is executed; Method


@override
 void didChangeDependencies() {
  _invertColors = MediaQuery.of(context, nullOk: true)?.invertColors
   ?? SemanticsBinding.instance.accessibilityFeatures.invertColors;
  _resolveImage();

  if (TickerMode.of(context))
   _listenToStream();
  else
   _stopListeningToStream();

  super.didChangeDependencies();
 }

_resolveImage() Will be called, and the function content is as follows 

 void _resolveImage() {
  final ImageStream newStream =
   widget.image.resolve(createLocalImageConfiguration(
     context,
     size: widget.width != null && widget.height != null ? Size(widget.width, widget.height) : null
   ));
  assert(newStream != null);
  _updateSourceStream(newStream);
 }

0

This method adds the listener _ handleImageChanged to the ImageStream object as follows


@override
 void didChangeDependencies() {
  _invertColors = MediaQuery.of(context, nullOk: true)?.invertColors
   ?? SemanticsBinding.instance.accessibilityFeatures.invertColors;
  _resolveImage();

  if (TickerMode.of(context))
   _listenToStream();
  else
   _stopListeningToStream();

  super.didChangeDependencies();
 }

_resolveImage() Will be called, and the function content is as follows 

 void _resolveImage() {
  final ImageStream newStream =
   widget.image.resolve(createLocalImageConfiguration(
     context,
     size: widget.width != null && widget.height != null ? Size(widget.width, widget.height) : null
   ));
  assert(newStream != null);
  _updateSourceStream(newStream);
 }

1

Finally, the setState method is called to notify the interface to refresh, and the downloaded pictures are rendered to the interface.

Practical problems

From the above source code analysis, We should be clear about the process from loading to displaying the whole network pictures. However, in this native way, we found that the network pictures were only cached in memory. If you want to download the picture again after killing the application process and reopening it, For users, every time they open the application, they will still consume the traffic of downloading pictures, but we can learn some ideas from it to design our own network picture loading framework. The following author will simply make a transformation based on Image. network to increase the disk cache of pictures.

Solutions

Through source code analysis, we can see that when the picture is not found in the cache, it will be downloaded and obtained directly through the network, and the download method is in the NetworkImage class, so we can customize an ImageProvider with reference to NetworkImage.

Code implementation

Copy 1 copy of NetworkImage code to the newly created network_image. dart file. In the _ loadAsync method, we add the disk cached code.


@override
 void didChangeDependencies() {
  _invertColors = MediaQuery.of(context, nullOk: true)?.invertColors
   ?? SemanticsBinding.instance.accessibilityFeatures.invertColors;
  _resolveImage();

  if (TickerMode.of(context))
   _listenToStream();
  else
   _stopListeningToStream();

  super.didChangeDependencies();
 }

_resolveImage() Will be called, and the function content is as follows 

 void _resolveImage() {
  final ImageStream newStream =
   widget.image.resolve(createLocalImageConfiguration(
     context,
     size: widget.width != null && widget.height != null ? Size(widget.width, widget.height) : null
   ));
  assert(newStream != null);
  _updateSourceStream(newStream);
 }

2

The comments in the code have indicated the new code block based on the original code. CacheFileImage is a file cache class defined by itself. The complete code is as follows


import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';

import 'package:crypto/crypto.dart';
import 'package:path_provider/path_provider.dart';

class CacheFileImage {

 ///  Get url String MD5 Value 
 static String getUrlMd5(String url) {
  var content = new Utf8Encoder().convert(url);
  var digest = md5.convert(content);
  return digest.toString();
 }

 ///  Get the picture cache path 
 Future<String> getCachePath() async {
  Directory dir = await getApplicationDocumentsDirectory();
  Directory cachePath = Directory("${dir.path}/imagecache/");
  if(!cachePath.existsSync()) {
   cachePath.createSync();
  }
  return cachePath.path;
 }

 ///  Judge whether there is a corresponding picture cache file 
 Future<Uint8List> getFileBytes(String url) async {
  String cacheDirPath = await getCachePath();
  String urlMd5 = getUrlMd5(url);
  File file = File("$cacheDirPath/$urlMd5");
  print(" Read a file :${file.path}");
  if(file.existsSync()) {
   return await file.readAsBytes();
  }

  return null;
 }

 ///  Cache the downloaded picture data to the specified file 
 Future saveBytesToFile(String url, Uint8List bytes) async {
  String cacheDirPath = await getCachePath();
  String urlMd5 = getUrlMd5(url);
  File file = File("$cacheDirPath/$urlMd5");
  if(!file.existsSync()) {
   file.createSync();
   await file.writeAsBytes(bytes);
  }
 }
}

In this way, the function of file cache is added. The idea is very simple, that is, before obtaining the network picture, check whether there is a cached file in the local file cache directory. If there is, you don't need to download it, otherwise, download the picture, and immediately cache the downloaded picture to the file for the next time you need it.

The following dependency libraries need to be added to pubspec. yaml of the project


@override
 void didChangeDependencies() {
  _invertColors = MediaQuery.of(context, nullOk: true)?.invertColors
   ?? SemanticsBinding.instance.accessibilityFeatures.invertColors;
  _resolveImage();

  if (TickerMode.of(context))
   _listenToStream();
  else
   _stopListeningToStream();

  super.didChangeDependencies();
 }

_resolveImage() Will be called, and the function content is as follows 

 void _resolveImage() {
  final ImageStream newStream =
   widget.image.resolve(createLocalImageConfiguration(
     context,
     size: widget.width != null && widget.height != null ? Size(widget.width, widget.height) : null
   ));
  assert(newStream != null);
  _updateSourceStream(newStream);
 }

4

Custom ImageProvider usage

When creating the image Widget, use an unnamed constructor with parameters, and specify the image parameter as a custom ImageProvider object. The code example is as follows


@override
 void didChangeDependencies() {
  _invertColors = MediaQuery.of(context, nullOk: true)?.invertColors
   ?? SemanticsBinding.instance.accessibilityFeatures.invertColors;
  _resolveImage();

  if (TickerMode.of(context))
   _listenToStream();
  else
   _stopListeningToStream();

  super.didChangeDependencies();
 }

_resolveImage() Will be called, and the function content is as follows 

 void _resolveImage() {
  final ImageStream newStream =
   widget.image.resolve(createLocalImageConfiguration(
     context,
     size: widget.width != null && widget.height != null ? Size(widget.width, widget.height) : null
   ));
  assert(newStream != null);
  _updateSourceStream(newStream);
 }

5

Write at the end

Above, the source code analysis of the network picture loading process of Image widget in Flutter is carried out. After understanding the design idea of the source code, we added a simple local file cache function, which made our network picture loading have both memory cache and file cache capabilities, which greatly improved the user experience. If other students have a better plan, they can leave a message for the author.


Related articles: