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, 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, 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, which is implemented by subclasses. 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(); /// 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 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 look at the implementation of the
load
method in the final loading network imageImageProvider
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 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
. ThisImageStreamCompleter
is set to theImageStream
in theresolve
method, which returns thisImageStream
. We can use thisImageStream
to listen for 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 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 theImageCache
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 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 has been 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 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: