Flutter 图片缓存问题分析

4,946 阅读6分钟

案件背景

在Flutter中加载图片(一般是网络图片),我们常常会遇到下面几个问题:

  1. 同页面内,加载过的图片,再次出现的时候,会重新加载,特别是列表的图片;
  2. 列表快速滑动时,加载完成再往回滑动,之前的图片还是需要重新加载;
  3. 有时返回上一页面时,上一页面已经加载完成的图片,会重新加载,假如没有占位图会特别明显的闪动。

iShot_2022-05-24_16.15.54.gif Android模拟机测试,所有图片均是加载完成过的。

在pub.dev中,常用的图片插件是cached_network_imageextended_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是图片的实时引用,它的ImageStreamCompleterlisteners不为空。

那嫌疑人按理来说就是_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可以根据机型(如内存大小)来判断,好一点的机型设置大一点,差一点的机型则小一点。

其次,若是图片没有进行剪裁压缩处理,则一定要设置memCacheWidthmemCacheHeight,这会控制图片最终解析后的内存大小为实际使用的大小,是影响图片内存占用的重要参数。

CachedNetworkImage({
  Key? key,
  required this.imageUrl,
  ···
  this.memCacheWidth,
  this.memCacheHeight,
  this.cacheKey,
  this.maxWidthDiskCache,
  this.maxHeightDiskCache,
  ImageRenderMethodForWeb imageRenderMethodForWeb =
      ImageRenderMethodForWeb.HtmlImage,
})

最后,ScrollAwareImageProvider主要是针对列表滑动的一种优化,屏蔽它虽然图片滑动加载会比较快,但确实会有一定的性能问题,图片内存较大的话则会卡顿,不建议屏蔽。

不过它的“有缓存则直接展示”的逻辑,主要是针对于内存缓存,也可以考虑结合本地缓存,会适当的提升一些体验。IO读取在滑动时,性能不是太差的机型,基本看不到占位图(加载时间)。

设置了maximumSizeBytes后,体验则比较好了 iShot_2022-05-24_18.42.09.gif (滑太快了,gif帧数看起来好像比较卡,实际还是比较流畅的)

就这样,溜了溜了~