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:
- Image loading
- Image preloading
- Image caching
- Clearing image cache
- Image loading progress listening
- Image loading examples
Image Loading#
Flutter itself implements image loading and can load images from the network, SD card, assets, and memory. Images can be generated using the following methods:
Image.network(String src,{...}); Image.file(File file,{...}); Image.asset(String name,{...}); Image.memory(Uint8List bytes,{...});
Below, we will introduce the image loading process in Flutter using the example of loading a network image. The source code for
Image.network()
is as follows: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, aNetworkImage
is created. TheNetworkImage
class is a subclass ofImageProvider
, which is an abstract class that provides methods for resolving image resources, evicting images from the cache, and an abstract methodload
for loading images. The source code analysis ofImageProvider
is as follows:/// ImageProvider is an abstract class, specific loading is implemented by subclasses abstract class ImageProvider<T> { const ImageProvider(); /// Generates an 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 { // Get 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; } /// Removes 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); } /// Obtains the key for the corresponding image resource; specific implementation by subclasses Future<T> obtainKey(ImageConfiguration configuration); /// Loads the image based on the key and converts it to ImageStreamCompleter; specific implementation by subclasses @protected ImageStreamCompleter load(T key); @override String toString() => '$runtimeType()'; }
In the
resolve
method, the image resource is parsed using the singleton ofPaintingBinding
to obtain the image cacheimageCache
and calls theputIfAbsent
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 providedloader
to load the image and adds the loaded image to theImageCache
.Continuing to view the implementation of the
load
method in the finalImageProvider
implementation classNetworkImage
:@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 decoding it. The source code for the_loadAsync
method is as follows:/// Downloads the image and decodes 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 understand that the image is decoded by native methods in the Flutter engine, ultimately returning an
ImageStreamCompleter
. ThisImageStreamCompleter
is set to theImageStream
in theresolve
method, and theresolve
method returns thisImageStream
. We can use thisImageStream
to listen to the image loading progress. The source code forImageStream
is as follows:/// ImageStream is used to handle image resources, indicating that the image resource has not yet been loaded. Once the image resource is loaded, /// the actual data object of ImageStream is composed of dart:ui.Image and scale, forming ImageInfo. class ImageStream extends Diagnosticable { ImageStream(); /// Manages the images currently being loaded, listens for image resource loading, such as successful loading, loading in progress, loading failure ImageStreamCompleter get completer => _completer; ImageStreamCompleter _completer; List<ImageStreamListener> _listeners; /// Sets 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<ImageStreamListener> initialListeners = _listeners; _listeners = null; initialListeners.forEach(_completer.addListener); } } /// Adds an image loading listener void addListener(ImageStreamListener listener) { if (_completer != null) return _completer.addListener(listener); _listeners ??= <ImageStreamListener>[]; _listeners.add(listener); } /// Removes 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
. Theresolve
method will be called in the corresponding lifecycle methods of the Image component, such asdidChangeDependencies
,didUpdateWidget
, etc. During the component build,RawImage
will be created. Continuing to track the source code leads toRenderImage
, which calls thepaintImage
method in itspaint
method, where the image configuration information is drawn using the canvas.Image Preloading#
In Flutter, images can be preloaded using the
precacheImage
method, which adds images to the cache in advance. When an image needs to be loaded, it can be directly retrieved from the cache. TheprecacheImage
method also resolves the image resource through theresolve
method ofImageProvider
and adds it to the image cache. The source code for this method is as follows:/// 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>(); // Resolves the image resource and adds 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 image, 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 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. Of course, the maximum cache item value_maximumSize
and the maximum cache size_maximumSizeBytes
can be set according to project needs. For specific details, refer to the comments in theImageCache
source code, as follows:const int _kDefaultSize = 1000; const int _kDefaultSizeBytes = 100 << 20; // 100 MiB /// Image cache implemented using LRU. A maximum of 100 images, with a maximum cache size of 100 MB, managed by ImageProvider and its subclasses /// The cache instance is held by the singleton of PaintingBinding class ImageCache { // Queue of images currently loading 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; /// Sets 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; /// Sets the 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; /// Clears the cache void clear() { _cache.clear(); _pendingImages.clear(); _currentSizeBytes = 0; } /// Removes the cache based on the corresponding key; returns true if removal is successful; otherwise, it will also remove images that are still loading /// and remove the corresponding image loading listener to avoid adding it 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 cache API /// /// If the cache is available, retrieves ImageStreamCompleter from the cache based on the given key; otherwise, uses /// the provided loader() callback to obtain ImageStreamCompleter and return it, moving 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 has not yet finished loading, return directly if (result != null) return result; // If there is a corresponding cache, remove it from the cache 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, increase the cache size to be smaller than the image 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 _checkCacheSize(); } // Add the image currently loading 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 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 currently loading class 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 invoke theputIfAbsent
method, which is the entry point for caching. If there is already a cache, it retrieves it from the cache; otherwise, it calls the correspondingImageProvider
'sload
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 ofPaintingBinding
and call itsclear
method, as follows:/// Clear cache _clearCache(BuildContext context) { PaintingBinding.instance.imageCache.clear(); Toast.show("Cache cleared", context); }
Image Loading Progress Listening#
From the previous sections, we know that the
resolve
method returns the corresponding image'sImageStream
. We can set an image loading listener through thisImageStream
, which is essentially adding anImageStreamListener
, 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 anImageStreamListener
, 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 implements loading images from the network, SD card, assets, and memory by default. For images from the SD card and memory, we use
FutureBuilder
to handle asynchronous tasks and return 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 in 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 cleared", context); } }
The execution effect of the above code is as follows: