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 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, 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. 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();

/// 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 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 view the implementation of the load method in the final 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 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. This ImageStreamCompleter is set to the ImageStream in the resolve method, and the resolve method returns this ImageStream. We can use this ImageStream to listen to the 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 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. The resolve method will be called in the corresponding lifecycle methods of the Image component, such as didChangeDependencies, didUpdateWidget, etc. During the component build, 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 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. The precacheImage method also resolves the image resource through the resolve method of ImageProvider 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 the ImageCache 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 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 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 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:

image

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