动态详情

Flutter 图片解码与缓存管理研究

图片解码和缓存管理是渲染引擎的一个重要模块,这是因为图片解码的耗时很长,特别是对于设计为跨平台的通用渲染引擎来说,依赖于CPU来做图片解码,会消耗大量的CPU时间,并且图片解码后占用的内存很大,一张 1024x1024 分辨率的图片解码后就需要 4M 内存(除非硬件支持实时生成无损压缩格式纹理,通常这也不在通用渲染引擎的考虑范围之内)。所以一个设计良好的图片解码和缓存管理模块需要平衡很多不同的因素,包括内存占用,CPU占用,解码任务调度的及时性等。

在对 Flutter 的图片解码和缓存管理模块进行研究后,发现它跟 Chromium 有很大的差别。一方面它实现比较简单,给予了应用更直接的控制权,引擎本身只提供了最基本的支持,更契合 Native UI 的实际使用场景,另外一方面因为引擎本身缺少控制权,如果应用生成的 UI 界面较为极端,可能会导致比较灾难性的结果。

在这篇文章,我会先对 Flutter 的图片解码和缓存管理机制进行说明。然后再说明这种机制存在的一些问题。

Image Widget and Provider

class Image extends StatefulWidget {
  ...
  /// The image to display.
  final ImageProvider image;
}

abstract class NetworkImage extends ImageProvider {
  ...
}

Flutter 通过 Image.asset,Image.file,Image.network 等方法创建一个 Image Widget 来显示图片,方法名字说明了图片数据的来源,他们实际上是为 Image Widget 提供了不同的 ImageProvider,比如说 Image.network 创建的 Image Widget,它的 ImageProvider 就是 NetworkImage。因为 Image Widget 是一个 StatefulWidget,所以它核心的状态处理逻辑代码是位于 _ImageState 对象中,由它来创建真正显示图片的 RawImage Widget。

class _ImageState extends State with WidgetsBindingObserver {
  ...
  @override
  void didChangeDependencies() {
    _updateInvertColors();
    _resolveImage();

    if (TickerMode.of(context))
      _listenToStream();
    else
      _stopListeningToStream();

    super.didChangeDependencies();
  }

  void _resolveImage() {
    final ScrollAwareImageProvider provider = ScrollAwareImageProvider(
      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);
  }

  void _handleImageFrame(ImageInfo imageInfo, bool synchronousCall) {
    setState(() {
      _imageInfo = imageInfo;
      _loadingProgress = null;
      _frameNumber = _frameNumber == null ? 0 : _frameNumber + 1;
      _wasSynchronouslyLoaded |= synchronousCall;
    });
  }
}
ScrollAwareImageProvider 是新版本新增的优化,它包装了最初的 ImageProvider,用来避免在快速滚动的过程中加载图片,也就是说快速滚动过程新增的 Image Widget,它加载图片的时机会被延迟,如果它在滚动过程中移除屏幕然后被移除,就完全不会触发加载。

当 Image Widget 被加入到 UI 的 Widget 树时,Flutter 就会调用 _ImageState.didChangeDependencies,然后 _ImageState._resolveImage 被调用,最后调用 ImageProvider.resolve 来加载图片。ImageProvider.resolve 触发了一连串的事情发生,它会先在 ImageCache 中生成 Entry,然后开始加载数据(异步方法,由 ImageProvider 的子类提供),加载完数据后生成相应的 Codec 开始请求解码(异步方法,由 Native Engine 提供),解码完成后最终通知 _ImageState._handleImageFrame 改变状态,产生新的 child Widget 显示图片。

Flutter 单帧图片的解码是运行在 worker 线程池(可以并发),解码后的 GPU 纹理上传是 io 线程,多帧图片的解码和纹理上传都是在 io 线程。

也就是说:

  1. Flutter 图片解码的调度和图片缓存的管理都在 Widget 层,由 Image Widget 关联的 _ImageState 对象和 ImageProvider 对象负责;
  2. 图片缓存的实现是 ImageCache 对象,通过 PaintingBinding.instance.imageCache 访问,ImageProvider 封装了 ImageCache 的访问;
  3. 当 Image Widget 被加入 Widget 树,就会触发图片的加载,加载完后就会自动请求解码,加载和解码是连在一起不可分割的;
  4. 解码完成后 Image Widget 才会产生 RawImage 作为 child Widget 真正显示图片。

ImageCache 图片缓存

class ImageCache {
  final Map _pendingImages = {};
  final Map _cache = {};
  final Map _liveImages = {};


  ImageStreamCompleter putIfAbsent(Object key, ImageStreamCompleter loader(),
      {ImageErrorListener onError}) {
    ...
  }
}

当 ImageProvide.resolve 被调用时,它会去调用 ImageCache.putIfAbsent 生成 Cache Entry(Key 由 ImageProvider 产生),返回一个 ImageStreamCompleter 对象用于监听图片加载和解码完成的情况,如果已经有缓存的 Entry,则直接返回。

ImageCache 实际上有三个 Pool,分别是 Pending,Cache 和 Live Pool,一个新的 Entry 一开始会被加入到这三个 Pool 中。Pending Pool 用来跟踪正在加载和解码的图片,当图片加载和解码完成后,ImageCache 会自动移除 Pending Pool 相应的 Entry。Live Pool 是用来跟踪使用中的图片,当 Image Widget 移除或者更换图片,或者 Image Widget 自身被移除,ImageCache 会从 Live Pool 移除相应的 Entry。如果图片缓存的数量和内存占用大小没有超过 ImageCache 的上限,Cache Pool 就会一直保留 Cache Entry,如果超过则按 LRU 进行释放。只有 ImageCache 从所有 Pool 都释放了同一个图片的 Entry,该图片解码后生成的纹理内存才会真正被释放

我们可以通过一个实际的场景来说明 ImageCache 的处理逻辑。假设 ImageCache 缓存的限制是 100M(100M 也是 Flutter 的默认值),我们的 UI 陆续加入 200 个 Image Widget,每个 Image Widget 显示一个 512x512 的图片,每个图片解码后的纹理内存占用为 1M。

  1. 当 UI 加入 100 个 Image Widget 的时候,Live Pool 和 Cache Pool 都有对应 100 个 Entey,假设图片都已经加载和解码完毕,Pending Pool 里面的 Entry 被全部移除,当前总的图片纹理缓存占用为 100M;
  2. 当加入 101 个 Image Widget,并且图片加载和解码完毕的时候,Live Pool 里面有 101 个 Entry,但是 Cache Pool 因为超过上限,最初的 Entry 被移除,只保留了后面 100 个 Entry,当前总的图片纹理缓存占用为 101M;
  3. 当加入 200 个 Image Widget,并且图片加载和解码完毕的时候,Live Pool 里面有 200 个 Entry,但是 Cache Pool 因为超过上限,最初的 100 个 Entry 被移除,只保留了后面 100 个 Entry,当前总的图片纹理缓存占用为 200M;
  4. 我们移除这 200 个 Image Widget,Live Pool 的 Entry 被完全移除,但是 Cache Pool 没有超过上限,仍然保留,当前总的图片纹理缓存占用为 100M;
  5. 我们使用后 100 张同样的图片重新加入 100 个 Image Widget,因为图片已经存在于 Cache Pool,所以不需要重新加载和解码,ImageCache 会从 Cache Pool 里面取出对应 Entry,并且重新在 Live Pool 生成对应的 Entry,最后 Live Pool 和 Cache Pool 都包含同样的 100 个 Entry,当前总的图片纹理缓存占用为 100M;
  6. 我们继续使用前 100 张图片再加入 100 个 Image Widget,因为 Cache Pool 已经移除了对应的 Entry,所以需要重新加载和解码,最终 Live Pool 包含了 200 个 Entry,Cache Pool 包含 100 对应前 100 张图片的 Entry,当前总的图片纹理缓存占用为 200M;
  7. 我们再次移除所有的 Image Widget,并且手动设置 ImageCache 的内存上限为 0,这样 ImageCache 会移除 Live Pool 和 Cache Pool 的所有 Entry,当前总的图片纹理缓存占用为 0M;

Flutter 图片缓存设计的一些问题

应该说 Flutter 的图片缓存设计还是比较契合 Native UI 的使用场景的,但是对于一些设计比较糟糕的 UI,或者是自动生成的类 Web 的长页面,这样的设计可能会造成一些灾难性的后果。

  1. Flutter 解码的时机非常靠前,如果一次性加入大量的 Image Widget 对象,会马上产生相应数量的加载和解码任务,这可能造成系统较为严重的阻塞,并且部分 Image Widget 实际上可能距离可见区域较远,解码后产生的纹理暂时不会被使用,这造成了内存浪费;
  2. ImageCache 实际上是没有真正封顶的(Live Pool 是无上限的),如果当前的 Widget 树同时包含了大量的 Image Widget,内存峰值可能会非常夸张,很容易造成 OOM;
  3. ImageCache 是在 Framework 层的实现而不是 Engine 层,它的实例由 Widget 层产生,通过 PaintingBinding.instance.imageCache 访问,这意味着每个 FlutterView,每个 Root Isolate 都有一个不同的 ImageCache,如果是混合应用,同时展现多个 FlutterView,不但 ImageCache 的 Live Pool 没法控制,Cache Pool 也会处于叠加的状态,导致内存的峰值会更难以控制;
目前已经有不少尝试是先生成 DOM 树,然后再用不同的后端将 DOM 树转换成适合不同渲染引擎的产物,交给对应的引擎去渲染。比如生成真正的 Web DOM 交给 Web 引擎渲染,或者生成 Flutter Widget 树交给 Flutter 渲染。这种代码自动生成的 Widget 树可能存在的一个问题就是可能会一次性生成大量的 Widget,并且同时加入 Widget 树。