案件背景
在Flutter中加载图片(一般是网络图片),我们常常会遇到下面几个问题:
- 同页面内,加载过的图片,再次出现的时候,会重新加载,特别是列表的图片;
- 列表快速滑动时,加载完成再往回滑动,之前的图片还是需要重新加载;
- 有时返回上一页面时,上一页面已经加载完成的图片,会重新加载,假如没有占位图会特别明显的闪动。
Android模拟机测试,所有图片均是加载完成过的。
在pub.dev中,常用的图片插件是cached_network_image和extended_image,他们都会有同样的问题。
事件分析
网络图片的缓存一般分为两块,一个是本地文件缓存,一个是内存缓存。(官方的只有内存缓存)
也就是说,一个网络图片到最终展示的路程,从解析url之后,会有内存缓存的检查,和本地文件缓存的检查。简单的流程如下:
flowchart LR
解析Url -->
ImageProvider --> c{是否有内存缓存}
c --> |有| result[解码展示]
c --> |无| loadAsync --> CacheManager --> d{是否有本地缓存}
d --> |有| 存至内存缓存 --> result
d --> |无| 下载图片数据 --> 缓存至本地以及内存中 --> result
本地文件缓存
flutter官方的Image.network
不带有本地文件缓存,所以插件如cached_network_image主要是加入了本地文件缓存管理的功能,cached_network_image使用的CacheManeger是基于flutter_cache_manager
var stream = cacheManager is ImageCacheManager
? cacheManager.getImageFile(url,
maxHeight: maxHeight,
maxWidth: maxWidth,
withProgress: true,
headers: headers,
key: cacheKey)
: cacheManager.getFileStream(url,
withProgress: true, headers: headers, key: cacheKey);
await for (var result in stream) {
if (result is DownloadProgress) {
chunkEvents.add(ImageChunkEvent(
cumulativeBytesLoaded: result.downloaded,
expectedTotalBytes: result.totalSize,
));
}
if (result is FileInfo) {
var file = result.file;
var bytes = await file.readAsBytes();
var decoded = await decode(bytes);
yield decoded;
}
}
根据result是FileInfo
还是DownloadProgress
,就可以判断文件是否存在或者说是否已经下载完成。
其CacheManager默认的配置如下:
Config(
this.cacheKey, {
Duration? stalePeriod,
int? maxNrOfCacheObjects,
CacheInfoRepository? repo,
FileSystem? fileSystem,
FileService? fileService,
}) : stalePeriod = stalePeriod ?? const Duration(days: 30),
maxNrOfCacheObjects = maxNrOfCacheObjects ?? 200,
repo = repo ?? _createRepo(cacheKey),
fileSystem = fileSystem ?? IOFileSystem(cacheKey),
fileService = fileService ?? HttpFileService();
可以看到,其中maxNrOfCacheObjects
默认为200,这里是指最大的缓存文件数量。也就是说超过200个,本地文件的缓存即会被清理。可以相应的进行调整,主要影响的是重启app后是否可以快速的拿到缓存。
然而,不论是调整至无限大还是不设置本地缓存,对结果几乎都没有影响,重新加载的依旧重新加载。 因此,嫌疑人一号,本地缓存,对于图片莫名重新加载,应该没有直接的关系。
内存缓存
本地文件再快,依旧会有IO读取的时间,能否秒展示出来,还是取决于内存缓存。因此嫌疑人二号的嫌疑特别大。
不论是cached_network_image
还是extended_image
, 图片的加载管理都有个ImageProvider
, 其中获取图片Stream的方法为resolveStreamForKey
void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream, T key, ImageErrorListener handleError) {
// This is an unusual edge case where someone has told us that they found
// the image we want before getting to this method. We should avoid calling
// load again, but still update the image cache with LRU information.
if (stream.completer != null) {
final ImageStreamCompleter? completer = PaintingBinding.instance!.imageCache!.putIfAbsent(
key,
() => stream.completer!,
onError: handleError,
);
assert(identical(completer, stream.completer));
return;
}
final ImageStreamCompleter? completer = PaintingBinding.instance!.imageCache!.putIfAbsent(
key,
() => load(key, PaintingBinding.instance!.instantiateImageCodec),
onError: handleError,
);
if (completer != null) {
stream.setCompleter(completer);
}
}
Key
即为它们对于图片属性的Provider
并且可以看到flutter的图片内存缓存 -- ImageCache。
浅读一下ImageCache
代码,可以看到其中重点的三个Map
class ImageCache {
final Map<Object, _PendingImage> _pendingImages = <Object, _PendingImage>{};
final Map<Object, _CachedImage> _cache = <Object, _CachedImage>{};
/// ImageStreamCompleters with at least one listener. These images may or may
/// not fit into the _pendingImages or _cache objects.
///
/// Unlike _cache, the [_CachedImage] for this may have a null byte size.
final Map<Object, _LiveImage> _liveImages = <Object, _LiveImage>{};
...
其中,_pendingImages
是正在加载中的图片队列,_cache
是已缓存的图片列表,_liveImage
是图片的实时引用,它的ImageStreamCompleter
的listeners
不为空。
那嫌疑人按理来说就是_cache
!
在ImageCache
的文档介绍或者是从代码中,可以看到默认的缓存上限
const int _kDefaultSize = 1000;
const int _kDefaultSizeBytes = 100 << 20; // 100 MiB
1000个图片或者100MB内。
在每次_touch
缓存图片之后,都会_checkCacheSize
,检查缓存是否达到上限
/// Updates the least recently used image cache with this image, if it is
/// less than the [maximumSizeBytes] of this cache.
///
/// Resizes the cache as appropriate to maintain the constraints of
/// [maximumSize] and [maximumSizeBytes].
void _touch(Object key, _CachedImage image, TimelineTask? timelineTask) {
assert(timelineTask != null);
if (image.sizeBytes != null && image.sizeBytes! <= maximumSizeBytes && maximumSize > 0) {
_currentSizeBytes += image.sizeBytes!;
_cache[key] = image;
_checkCacheSize(timelineTask);
} else {
image.dispose();
}
}
// Remove images from the cache until both the length and bytes are below
// maximum, or the cache is empty.
void _checkCacheSize(TimelineTask? timelineTask) {
final Map<String, dynamic> finishArgs = <String, dynamic>{};
TimelineTask? checkCacheTask;
if (!kReleaseMode) {
checkCacheTask = TimelineTask(parent: timelineTask)..start('checkCacheSize');
finishArgs['evictedKeys'] = <String>[];
finishArgs['currentSize'] = currentSize;
finishArgs['currentSizeBytes'] = currentSizeBytes;
}
while (_currentSizeBytes > _maximumSizeBytes || _cache.length > _maximumSize) {
final Object key = _cache.keys.first;
final _CachedImage image = _cache[key]!;
_currentSizeBytes -= image.sizeBytes!;
image.dispose();
_cache.remove(key);
if (!kReleaseMode) {
(finishArgs['evictedKeys'] as List<String>).add(key.toString());
}
}
if (!kReleaseMode) {
finishArgs['endSize'] = currentSize;
finishArgs['endSizeBytes'] = currentSizeBytes;
checkCacheTask!.finish(arguments: finishArgs);
}
assert(_currentSizeBytes >= 0);
assert(_cache.length <= maximumSize);
assert(_currentSizeBytes <= maximumSizeBytes);
}
可以看到,检查缓存上限,是||
的方式,即使图片没有加载到100MB,加载到1000个图片,也会开始根据LRU的规则清理释放缓存。
在某些情况下,比如电商,一整个页面80%的元素都用图片占满,小到图标,大到广告banner,经过压缩剪裁,图片普遍都控制在几十kb甚至10kb以下,即使1000张也远远达不到内存的上限。并且100MB的上限对于某些机型来讲也相对较小了。
最后通过日志打印,在重新加载的情况下,的确containsKey
返回的结果为false
,因此,第一点和第三点的情况可以确定是_cache
内存缓存达到上限被清理了。
/// Returns whether this `key` has been previously added by [putIfAbsent].
bool containsKey(Object key) {
return _pendingImages[key] != null || _cache[key] != null;
}
列表中滑动过多的图片,可能是超出数量,也可能是超出Bytes上限,导致之前的图片被清理,所以要重新reload; 返回页面同理,LRU方案,上一页面或更早前的资源会最先被清理释放内存。
至于第二点,快速滑动再回去,按理来说既没有超出数量,也不会超过Bytes上限,缓存不会被清理,所以是原本就没有被缓存?这时第三名嫌疑人出现。
ScrollAwareImageProvider
第三个嫌疑人开始辩解,快速滑动过程中,路过的图片并不会加载完成和缓存,是因为在加载图片时,为了避免过快滑动,使得同时加载的图片过多导致卡顿甚至崩溃,所以大佬们加了我 ScrollAwareImageProvider进行限制,
void _resolveImage() {
final ScrollAwareImageProvider provider = ScrollAwareImageProvider<Object>(
context: _scrollAwareContext,
imageProvider: widget.image,
);
final ImageStream newStream =
provider.resolve(createLocalImageConfiguration(
context,
size: widget.width != null && widget.height != null ? Size(widget.width!, widget.height!) : null,
));
assert(newStream != null);
_updateSourceStream(newStream);
}
若是内存中已有缓存,则直接返回缓存,若是没有则判断是否在快速滑动Scrollable.recommendDeferredLoadingForContext(context.context!)
,若是正在快速滑动,则下一帧再加入队列处理,若是图片已经被移出屏幕(即没有在tree上),可能会被跳过。
void resolveStreamForKey(
ImageConfiguration configuration,
ImageStream stream,
T key,
ImageErrorListener handleError,
) {
// Something managed to complete the stream, or it's already in the image
// cache. Notify the wrapped provider and expect it to behave by not
// reloading the image since it's already resolved.
// Do this even if the context has gone out of the tree, since it will
// update LRU information about the cache. Even though we never showed the
// image, it was still touched more recently.
// Do this before checking scrolling, so that if the bytes are available we
// render them even though we're scrolling fast - there's no additional
// allocations to do for texture memory, it's already there.
if (stream.completer != null || PaintingBinding.instance!.imageCache!.containsKey(key)) {
imageProvider.resolveStreamForKey(configuration, stream, key, handleError);
return;
}
// The context has gone out of the tree - ignore it.
if (context.context == null) {
return;
}
// Something still wants this image, but check if the context is scrolling
// too fast before scheduling work that might never show on screen.
// Try to get to end of the frame callbacks of the next frame, and then
// check again.
if (Scrollable.recommendDeferredLoadingForContext(context.context!)) {
SchedulerBinding.instance!.scheduleFrameCallback((_) {
scheduleMicrotask(() => resolveStreamForKey(configuration, stream, key, handleError));
});
return;
}
// We are in the tree, we're not scrolling too fast, the cache doesn't
// have our image, and no one has otherwise completed the stream. Go.
imageProvider.resolveStreamForKey(configuration, stream, key, handleError);
}
因此,快速滑动过去后,中间的图片可能会被无视,再滑回去的时候才触发加载。造成了这个现象。
那看来ScrollAwareImageProvider
也是没有问题的。
总结
首先,对于“罪魁祸首”ImageCache
的缓存问题,我们可以通过设置PaintingBinding.instance?.imageCache
来缓解其问题,这个是flutter共用的ImageCache
。建议根据实际情况配置,比如仅设置其中一个为主要上限。设置maximumSizeBytes
大小,maximumSize
则设置为尽可能大
PaintingBinding.instance?.imageCache?.maximumSize =
1000000; // 2000 entries
PaintingBinding.instance?.imageCache?.maximumSizeBytes =
300 << 20; //
maximumSizeBytes
可以根据机型(如内存大小)来判断,好一点的机型设置大一点,差一点的机型则小一点。
其次,若是图片没有进行剪裁压缩处理,则一定要设置memCacheWidth
或memCacheHeight
,这会控制图片最终解析后的内存大小为实际使用的大小,是影响图片内存占用的重要参数。
CachedNetworkImage({
Key? key,
required this.imageUrl,
···
this.memCacheWidth,
this.memCacheHeight,
this.cacheKey,
this.maxWidthDiskCache,
this.maxHeightDiskCache,
ImageRenderMethodForWeb imageRenderMethodForWeb =
ImageRenderMethodForWeb.HtmlImage,
})
最后,ScrollAwareImageProvider
主要是针对列表滑动的一种优化,屏蔽它虽然图片滑动加载会比较快,但确实会有一定的性能问题,图片内存较大的话则会卡顿,不建议屏蔽。
不过它的“有缓存则直接展示”的逻辑,主要是针对于内存缓存,也可以考虑结合本地缓存,会适当的提升一些体验。IO读取在滑动时,性能不是太差的机型,基本看不到占位图(加载时间)。
设置了maximumSizeBytes
后,体验则比较好了
(滑太快了,gif帧数看起来好像比较卡,实际还是比较流畅的)
就这样,溜了溜了~