banner
jzman

jzman

Coding、思考、自觉。
github

Detailed Explanation of Image Loading in the Flutter Series

PS: Practicing self-discipline is really not as simple as imagined.

Flutter supports loading image types: JPEG, PNG, GIF, WebP, BMP, and WBMP. The required parameter for the Flutter Image component is an ImageProvider. ImageProvider is an abstract class, and the specific implementation for obtaining images is done by subclasses. This article will learn about image loading in Flutter from the following aspects:

  1. Image loading
  2. Image preloading
  3. Image caching
  4. Clearing image cache
  5. Image loading progress listening
  6. Image loading examples

Image Loading#

Flutter itself implements image loading, capable of loading images from the network, SD card, assets, and memory. Images can be generated using the following methods corresponding to image resources:

Image.network(String src,{...});
Image.file(File file,{...});
Image.asset(String name,{...});
Image.memory(Uint8List bytes,{...});

Below, we will introduce the process of loading network images in Flutter, looking at the source code for Image.network():

Image.network(
// ...
}) : image = NetworkImage(src, scale: scale, headers: headers),
   assert(alignment != null),
   assert(repeat != null),
   assert(matchTextDirection != null),
   super(key: key);

When using Image.network to generate an Image, a NetworkImage is created. The NetworkImage class is a subclass of ImageProvider, which is an abstract class that provides methods for resolving image resources, evicting images from the cache, and an abstract method load for loading images, which is implemented by subclasses. The source code analysis of ImageProvider is as follows:

/// ImageProvider is an abstract class, specific loading is implemented by subclasses
abstract class ImageProvider<T> {
  const ImageProvider();

  /// Generate ImageStream using the provided ImageConfiguration object
  ImageStream resolve(ImageConfiguration configuration) {
    assert(configuration != null);
    final ImageStream stream = ImageStream();
    T obtainedKey;

    //...code

    dangerZone.runGuarded(() {
      Future<T> key;
      try {
        // Obtain the key corresponding to the image resource
        key = obtainKey(configuration);
      } catch (error, stackTrace) {
        handleError(error, stackTrace);
        return;
      }
      key.then<void>((T key) {
        // Obtained the key corresponding to the image resource
        obtainedKey = key;
        // Get the ImageStreamCompleter corresponding to the key; if not in cache, call the provided loader callback
        // to load and add it to the cache
        final ImageStreamCompleter completer = PaintingBinding
            .instance.imageCache
            .putIfAbsent(key, () => load(key), onError: handleError);
        if (completer != null) {
          stream.setCompleter(completer);
        }
      }).catchError(handleError);
    });
    return stream;
  }

  /// Remove the image from the cache; a return value of true indicates successful removal
  Future<bool> evict(
      {ImageCache cache,
      ImageConfiguration configuration = ImageConfiguration.empty}) async {
    cache ??= imageCache;
    final T key = await obtainKey(configuration);
    return cache.evict(key);
  }

  /// Obtain the key for the corresponding image resource, implemented by subclasses
  Future<T> obtainKey(ImageConfiguration configuration);

  /// Load the image based on the key and convert it to ImageStreamCompleter, implemented by subclasses
  @protected
  ImageStreamCompleter load(T key);

  @override
  String toString() => '$runtimeType()';
}

In the resolve method, the image resource is parsed using the singleton of PaintingBinding to obtain the image cache imageCache and calls the putIfAbsent method, which implements the basic logic of LRU caching. It processes based on whether there is a cache; if there is a cache, it retrieves the corresponding image resource from the cache; otherwise, it calls the provided loader to load the image and adds the loaded image to the ImageCache.

Continuing to look at the implementation of the load method in the final loading network image ImageProvider implementation class NetworkImage:

@override
ImageStreamCompleter load(image_provider.NetworkImage key) {

    final StreamController<ImageChunkEvent> chunkEvents = StreamController<ImageChunkEvent>();
    // _loadAsync method
    return MultiFrameImageStreamCompleter(
      codec: _loadAsync(key, chunkEvents),
      chunkEvents: chunkEvents.stream,
      scale: key.scale,
      informationCollector: () {
        return <DiagnosticsNode>[
          DiagnosticsProperty<image_provider.ImageProvider>('Image provider', this),
          DiagnosticsProperty<image_provider.NetworkImage>('Image key', key),
        ];
      },
    );
}

The load method calls _loadAsync, which is the actual method for downloading the image, and it also decodes the image and returns it. The source code for the _loadAsync method is as follows:

/// Download the image and decode it
Future<ui.Codec> _loadAsync(
    NetworkImage key,
    StreamController<ImageChunkEvent> chunkEvents,
    ) async {
  try {
    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 image_provider.NetworkImageLoadException(statusCode: response.statusCode, uri: resolved);

    final Uint8List bytes = await consolidateHttpClientResponseBytes(
      response,
      onBytesReceived: (int cumulative, int total) {
        chunkEvents.add(ImageChunkEvent(
          cumulativeBytesLoaded: cumulative,
          expectedTotalBytes: total,
        ));
      },
    );
    if (bytes.lengthInBytes == 0)
      throw Exception('NetworkImage is an empty file: $resolved');
    // Decode the image into a binary Codec object
    return PaintingBinding.instance.instantiateImageCodec(bytes);
  } finally {
    chunkEvents.close();
  }
}

After downloading the image, it decodes the image into a binary corresponding Codec object, which is specifically decoded by native methods in the Flutter engine, as follows:

String _instantiateImageCodec(Uint8List list, _Callback<Codec> callback, _ImageInfo imageInfo, int targetWidth, int targetHeight)
  native 'instantiateImageCodec';

From the above process, we know that the image is decoded by native methods in the Flutter engine, ultimately returning an ImageStreamCompleter. This ImageStreamCompleter is set to the ImageStream in the resolve method, which returns this ImageStream. We can use this ImageStream to listen for image loading progress. The source code for ImageStream is as follows:

/// ImageStream is used to handle image resources, indicating that the image resource has not yet finished loading.

/// Once the image resource is loaded, the actual data object of ImageStream is constructed by dart.Image and scale as ImageInfo,

class ImageStream extends Diagnosticable {
ImageStream();

/// Manage the images being loaded, listen for image resource loading, such as loading success, loading, loading failure
ImageStreamCompleter get completer => _completer;
ImageStreamCompleter _completer;

List _listeners;

/// Set an image loading listener, usually automatically set by the ImageProvider that creates the ImageStream, and each ImageStream can only set once
void setCompleter(ImageStreamCompleter value) {
assert(_completer == null);
_completer = value;
if (_listeners != null) {
final List initialListeners = _listeners;
_listeners = null;
initialListeners.forEach(_completer.addListener);
}
}

/// Add an image loading listener
void addListener(ImageStreamListener listener) {
if (_completer != null) return _completer.addListener(listener);
_listeners ??= [];
_listeners.add(listener);
}

/// Remove an image loading listener
void removeListener(ImageStreamListener listener) {
if (_completer != null) return _completer.removeListener(listener);
assert(_listeners != null);
for (int i = 0; i < _listeners.length; i += 1) {
if (_listeners[i] == listener) {
_listeners.removeAt(i);
break;
}
}
}

Object get key => _completer ?? this;

@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
// ...
}
}


Thus, we know that the image resource will ultimately be converted into an `ImageStream`. The `resolve` method will be called in the corresponding lifecycle methods of the Image component, such as `didChangeDependencies`, `didUpdateWidget`, etc. When the component is built, `RawImage` will be created. Continuing to track the source code leads to `RenderImage`, which calls the `paintImage` method in its `paint` method, where the image configuration information is drawn through the canvas.

# Image Preloading

In Flutter, images can be preloaded using the `precacheImage` method, which adds images to the cache in advance. When images need to be loaded, they can be directly retrieved from the cache. The `precacheImage` method still resolves the image resource through the `ImageProvider`'s `resolve` method and adds it to the image cache. The source code for this method is as follows:

```dart
/// precacheImage
Future<void> precacheImage(
  ImageProvider provider,
  BuildContext context, {
  Size size,
  ImageErrorListener onError,
}) {
  final ImageConfiguration config = createLocalImageConfiguration(context, size: size);
  final Completer<void> completer = Completer<void>();
  // Resolve the image resource and add it to the cache
  final ImageStream stream = provider.resolve(config);
  ImageStreamListener listener;
  listener = ImageStreamListener(
    // omitted...
    },
  );
  stream.addListener(listener);
  return completer.future;
}

When using it, select different ImageProvider based on the image source to cache the corresponding images, as shown below:

// Pre-cache image
precacheImage(new AssetImage("images/cat.jpg"), context);

Image Caching#

ImageCache is a cache implementation based on the LRU algorithm provided by Flutter, which can cache up to 1000 images by default, with a maximum cache size of 100 MB. When the cache exceeds any of the limits, the least recently used cache items will be removed from the cache. Of course, the maximum cache item _maximumSize and maximum cache size _maximumSizeBytes can be set according to project needs. For specific details, refer to the comments in the ImageCache source code, as follows:

const int _kDefaultSize = 1000;
const int _kDefaultSizeBytes = 100 << 20; // 100 MiB

/// Image cache implemented using LRU. Up to 100 images, maximum cache size of 100 MB, cached by ImageProvider and its subclasses
/// The cache instance is held by the singleton of PaintingBinding
class ImageCache {
  // Queue of images being loaded
  final Map<Object, _PendingImage> _pendingImages = <Object, _PendingImage>{};

  // Cache queue
  final Map<Object, _CachedImage> _cache = <Object, _CachedImage>{};

  /// Maximum number of cache items
  int get maximumSize => _maximumSize;
  int _maximumSize = _kDefaultSize;

  /// Set the maximum number of cache items
  set maximumSize(int value) {
    assert(value != null);
    assert(value >= 0);
    if (value == maximumSize) return;
    _maximumSize = value;
    if (maximumSize == 0) {
      clear();
    } else {
      _checkCacheSize();
    }
  }

  /// Current number of cache items
  int get currentSize => _cache.length;

  /// Maximum cache size (bytes)
  int get maximumSizeBytes => _maximumSizeBytes;
  int _maximumSizeBytes = _kDefaultSizeBytes;

  /// Set cache size
  set maximumSizeBytes(int value) {
    assert(value != null);
    assert(value >= 0);
    if (value == _maximumSizeBytes) return;
    _maximumSizeBytes = value;
    if (_maximumSizeBytes == 0) {
      clear();
    } else {
      _checkCacheSize();
    }
  }

  /// Current cache size (bytes)
  int get currentSizeBytes => _currentSizeBytes;
  int _currentSizeBytes = 0;

  /// Clear cache
  void clear() {
    _cache.clear();
    _pendingImages.clear();
    _currentSizeBytes = 0;
  }

  /// Remove cache based on the corresponding key; returns true if removal is successful; otherwise, images that are still loading will also be removed along with their loading listeners to avoid adding them to the cache
  bool evict(Object key) {
    final _PendingImage pendingImage = _pendingImages.remove(key);
    if (pendingImage != null) {
      pendingImage.removeListener();
      return true;
    }
    final _CachedImage image = _cache.remove(key);
    if (image != null) {
      _currentSizeBytes -= image.sizeBytes;
      return true;
    }
    return false;
  }

  /// Entry point for the cache API
  ///
  /// If the cache is available, return the ImageStreamCompleter from the given key; otherwise, use the provided callback loader() to obtain the ImageStreamCompleter and return it, both will move the key to the most recently used position
  ImageStreamCompleter putIfAbsent(Object key, ImageStreamCompleter loader(),
      {ImageErrorListener onError}) {
    assert(key != null);
    assert(loader != null);
    ImageStreamCompleter result = _pendingImages[key]?.completer;
    // If the image is still loading, return directly
    if (result != null) return result;
    // If there is a corresponding cache, remove it first, then add it to the most recently used position
    final _CachedImage image = _cache.remove(key);
    if (image != null) {
      _cache[key] = image;
      return image.completer;
    }
    // If unable to obtain the corresponding cache, directly use the load method in the corresponding ImageProvider to load the image
    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 will not occupy 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 size exceeds the cache size, and the cache size is not 0, then increase the cache to be smaller than the image cache size
      if (maximumSizeBytes > 0 && imageSize > maximumSizeBytes) {
        _maximumSizeBytes = imageSize + 1000;
      }
      _currentSizeBytes += imageSize;
      // Remove the loaded image from the queue of images being loaded and set the removal listener
      final _PendingImage pendingImage = _pendingImages.remove(key);
      if (pendingImage != null) {
        pendingImage.removeListener();
      }
      // Add the loaded image to the cache
      _cache[key] = image;
      // Cache check; if it exceeds the cache limit, remove the least recently used cache item from the cache
      _checkCacheSize();
    }

    // Add the image being loaded to _pendingImages and set the loading image listener
    if (maximumSize > 0 && maximumSizeBytes > 0) {
      final ImageStreamListener streamListener = ImageStreamListener(listener);
      _pendingImages[key] = _PendingImage(result, streamListener);
      // Listener is removed in [_PendingImage.removeListener].
      result.addListener(streamListener);
    }
    return result;
  }

  // Cache check; if it exceeds the cache limit, remove the least recently used cache item from the cache
  void _checkCacheSize() {
    while (
        _currentSizeBytes > _maximumSizeBytes || _cache.length > _maximumSize) {
      final Object key = _cache.keys.first;
      final _CachedImage image = _cache[key];
      _currentSizeBytes -= image.sizeBytes;
      _cache.remove(key);
    }
    assert(_currentSizeBytes >= 0);
    assert(_cache.length <= maximumSize);
    assert(_currentSizeBytes <= maximumSizeBytes);
  }
}
// Cached image class
class _CachedImage {
  _CachedImage(this.completer, this.sizeBytes);

  final ImageStreamCompleter completer;
  final int sizeBytes;
}

// Image class being loaded
class _PendingImage {
  _PendingImage(this.completer, this.listener);

  final ImageStreamCompleter completer;
  final ImageStreamListener listener;

  void removeListener() {
    completer.removeListener(listener);
  }
}

The above code shows the entire caching logic. When the resolve method is called, it will call the putIfAbsent method, which is the entry point for caching. If there is already a cache, it retrieves it from the cache; otherwise, it calls the corresponding ImageProvider's load method to load the image and adds it to the cache.

Clearing Image Cache#

To clear the image cache, simply obtain the ImageCache through the singleton of PaintingBinding and call its clear method, as follows:

/// Clear cache
_clearCache(BuildContext context) {
  PaintingBinding.instance.imageCache.clear();
  Toast.show("Cache has been cleared", context);
}

Image Loading Progress Listening#

From the previous sections, we know that the resolve method returns the corresponding image's ImageStream. We can set an image loading listener through this ImageStream, which is essentially adding an ImageStreamListener, as follows:

/// Image
Image image = Image.network(
  "https://cdn.nlark.com/yuque/0/2019/jpeg/644330/1576812507787-bdaeaf42-8317-4e06-a489-251686bf7b91.jpeg",
  width: 100,
  height: 100,
  alignment: Alignment.topLeft,
);

// Image loading listener
image.image.resolve(ImageConfiguration()).addListener(
        ImageStreamListener((ImageInfo imageInfo, bool synchronousCall) {
      completer.complete(imageInfo.image);
    }, onChunk: (event) {
      int currentLength = event.cumulativeBytesLoaded;
      int totalLength = event.expectedTotalBytes;
      print("$currentLength/$totalLength from network");
    }, onError: (e, trace) {
      print(e.toString());
    }));

The most commonly used method in development is the following way, which adds a listener for image loading progress through the loadingBuilder property. In fact, what is ultimately set is also an ImageStreamListener, as follows:

/// Image loading listener
class ImageLoadListenerSamplePage extends StatefulWidget {
  @override
  State<StatefulWidget> createState() {
    return _ImageState();
  }
}
/// _ImageState
class _ImageState extends State<ImageLoadListenerSamplePage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("Image Load Listener"),
        centerTitle: true,
      ),
      body: Image.network(
        "https://cdn.nlark.com/yuque/0/2019/jpeg/644330/1576812507787-bdaeaf42-8317-4e06-a489-251686bf7b91.jpeg",
        width: 100,
        height: 100,
        loadingBuilder: (BuildContext context, Widget child,
            ImageChunkEvent loadingProgress) {
          if (loadingProgress == null) return child;
          int currentLength = loadingProgress.cumulativeBytesLoaded;
          int totalLength = loadingProgress.expectedTotalBytes;
          print("$currentLength/$totalLength from network");
          return CircularProgressIndicator(
            value: loadingProgress.expectedTotalBytes != null
                ? loadingProgress.cumulativeBytesLoaded /
                    loadingProgress.expectedTotalBytes
                : null,
          );
        },
      ),
    );
  }
}

Image Loading Examples#

As mentioned earlier, Flutter has implemented loading images from the network, SD card, assets, and memory. For loading images from the SD card and memory, we use FutureBuilder to handle asynchronous tasks returning an Image. Without further ado, let's look at the code:

/// Load Image
class ImageLoadSamplePage extends StatefulWidget {
  @override
  State<StatefulWidget> createState() {
    return _ImageSampleSate();
  }
}
/// _ImageSampleSate
class _ImageSampleSate extends State<ImageLoadSamplePage> {

  Future<Directory> _externalStorageDirectory;
  Future<Uint8List> _imageUint8List;

  /// Get file directory
  void _requestExternalStorageDirectory() {
    setState(() {
      _externalStorageDirectory = getExternalStorageDirectory();
    });
  }

  /// Convert file to bytes
  void _requestBytes() {
    setState(() {
      File file = new File("/storage/emulated/0/owl.jpg");
      _imageUint8List = file.readAsBytes();
    });
  }

  @override
  Widget build(BuildContext context) {
  
    _requestExternalStorageDirectory();
    _requestBytes();

    return Scaffold(
      appBar: AppBar(
        title: Text("Image Sample"),
        centerTitle: true,
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          _clearCache(context);
        },
        child: Icon(Icons.clear),
      ),
      body: ListView(
        scrollDirection: Axis.vertical,
        children: <Widget>[
          Text(
            "from network...",
            style: TextStyle(fontSize: 16),
          ),
          Image.network(
            "https://cdn.nlark.com/yuque/0/2019/jpeg/644330/1576812507787-bdaeaf42-8317-4e06-a489-251686bf7b91.jpeg",
            width: 100,
            height: 100,
            alignment: Alignment.topLeft,
          ),
          Text(
            "from file...",
            style: TextStyle(fontSize: 16),
          ),
          FutureBuilder<Directory>(
            future: _externalStorageDirectory,
            builder: _buildFileDirectory,
          ),
          Text(
            "from asset...",
            style: TextStyle(fontSize: 16),
          ),
          Image.asset(
            'images/cat.jpg',
            width: 100,
            height: 100,
            alignment: Alignment.topLeft,
          ),
          Text(
            "from memory...",
            style: TextStyle(fontSize: 16),
          ),
          FutureBuilder<Uint8List>(
            future: _imageUint8List,
            builder: _buildMemoryDirectory,
          ),
        ],
      ),
    );
  }

  /// Asynchronously get SD card image
  Widget _buildFileDirectory(
      BuildContext context, AsyncSnapshot<Directory> snapshot) {
    Text text = new Text("default");
    if (snapshot.connectionState == ConnectionState.done) {
      if (snapshot.hasData) {
        File file = new File("${snapshot.data.path}/owl.jpg");
        return Image.file(
          file,
          width: 100,
          height: 100,
          alignment: Alignment.topLeft,
        );
      } else if (snapshot.hasError) {
        text = new Text(snapshot.error);
      } else {
        text = const Text("unknown");
      }
    }
    print(text.data);
    return text;
  }


  /// Asynchronously get image from memory
  Widget _buildMemoryDirectory(
      BuildContext context, AsyncSnapshot<Uint8List> snapshot) {
    Text text = new Text("default");
    if (snapshot.connectionState == ConnectionState.done) {
      if (snapshot.hasData) {
        return Image.memory(
          snapshot.data,
          width: 100,
          height: 100,
          alignment: Alignment.topLeft,
        );
      } else if (snapshot.hasError) {
        text = new Text(snapshot.error);
      } else {
        text = const Text("unknown");
      }
    }
    return text;
  }

  /// Clear cache (for testing cache)
  _clearCache(BuildContext context) {
    PaintingBinding.instance.imageCache.clear();
    print("---_clearCache-->");
    Toast.show("Cache has been cleared", context);
  }
}

The execution effect of the above code is as follows:

image

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.