跳至主要內容

Flutter 图片加载方案分析之 Image

JI,XIAOYONG...大约 13 分钟

Flutter 默认提供了Image用于从网络、文件等加载图片,并且使用ImageCache统一管理图片缓存,但有时候并不能满足使用需求(比如网络图片没有磁盘缓存,导致每次 ImageCache 清除缓存之后又要从网络下载),所以又出现了flutter_cached_network_imageopen in new windowextended_imageopen in new window等基于 Flutter 原生的解决方案,以及power_imageopen in new window等基于混合开发的解决方案。

本文对 Flutter 中的 Image 加载过程、原理做一简单分析。

图片展示的流程

首先,简单梳理一下图片从加载到展示的过程。

Image

A widget that displays an image.

在查看 Image 具体实现之前,先了解几个基础方法:

  • ImageConfiguration createLocalImageConfiguration(BuildContext context, { Size? size }) :创建 ImageConfiguration,一般用于 state.didChangeDependencies 等依赖变化时会调用的地方,其创建的 ImageConfiguration 对象会传入 BoxPainter.paint 或者 ImageProvider.resolver 方法中以用来获取 ImageStream
  • Future<void> precacheImage(...) 预先加载 image 到 ImageCache 中,以便 Image、BoxDecoration、FadeInImage 等能够更快地加载 image。

Image 是 Flutter 中用于展示图片的 Widget,主要有如下用法:

支持的格式有:JPEG, PNG, GIF, Animated GIF, WebP, Animated WebP, BMP, and WBMP,以及依赖于特定设备的格式(Flutter 会尝试使用平台 API 解析未知格式)。

通过指定cacheWidthcacheHeight可以让引擎按照指定大小解码图片,可以降低 ImageCache 占用的内存。

Image(...)构造函数中只有一个必传项ImageProvider image用于获取图片,其余四种构造方法也都是在此方法的基础上分别指定了各自的 ImageProvider,以Image.network为例:

Image.network(
    String src, {
    Key? key,
    double scale = 1.0,
    ...,
    int? cacheWidth,
    int? cacheHeight,
  }) : image = ResizeImage.resizeIfNeeded(cacheWidth, cacheHeight, NetworkImage(src, scale: scale, headers: headers)),
       ...
       super(key: key);

上述代码中的 ResizeImage,NetworkImage 等都继承自 ImageProviderImageProvider.resolve方法创建并返回 ImageStreamImage 使用,内部通过ImageProvider.resolveStreamForKey方法从 ImageCache 或者子类指定的途径(比如 NetworkImage 会从网络)加载图片(并保存到 ImageCache)。

_ImageState

ImageStatefulWidget,处理 image 的主要逻辑在 _ImageState 中:其混入了WidgetsBindingObserver以便监听系统生命周期;在内部通过监听ImageStream获得ImageInfo并最终在_ImageState.build方法中创建RawImage;RawImage 是一个LeafRenderObjectWidget,会创建RenderImage并在RenderImage.paint根据之前获取的信息调用DecorationImagePainter.paintImage方法通过canvas.drawImageRect绘制图片。

_resolveImage

当依赖变化(didChangeDependencies())、Widget 变化(didUpdateWidget(Image oldWidget))、以及热更新(reassemble())时,_ImageState 会执行_resolveImage()方法通过 ImageProvider 获取 ImageStream:

void _resolveImage() {
  // ScrollAwareImageProvider 用于防止在快速滑动的时候加载图片
    final ScrollAwareImageProvider provider = ScrollAwareImageProvider<Object>(
      context: _scrollAwareContext,
      imageProvider: widget.image,// 用户/构造方法指定的 ImageProvider
    );
  // 通过 ImageProvider 获取 ImageStream
    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);
  }

我们还可以注意到,当在_resolveImage()中获取到 ImageStream 之后,会通过_updateSourceStream()更新 ImageStream。

_updateSourceStream

在此方法中,先是更新了ImageStream? _imageStream 对象,然后根据_isListeningToStream的值执行_imageStream!.addListener(_getListener())更新 ImageStream 的 Listener:

ImageStreamListener _getListener({bool recreateListener = false}) {
    if(_imageStreamListener == null || recreateListener) {
      _lastException = null;
      _lastStack = null;
      _imageStreamListener = ImageStreamListener(
        _handleImageFrame,// 图片加载成功,使用获得的 imageInfo 更新 RawImage
        onChunk: widget.loadingBuilder == null ? null : _handleImageChunk,// 展示 loading 动画
        onError: widget.errorBuilder != null || kDebugMode
            ? (Object error, StackTrace? stackTrace) {
               ...
              }
            : null,// 展示加载失败
      );
    }
    return _imageStreamListener!;
  }

可以看到,在 ImageStreamListener 中,根据 ImageStream 的不同状态分别更新 Image 的显示。

_handleImageFrame

_handleImageFrame()方法使用 ImageStream 中返回的ImageInfo,调用setState方法更新_ImageState 中的ImageInfo? **_imageInfo**属性,从而刷新 Image 展示。

void _handleImageFrame(ImageInfo imageInfo, bool synchronousCall) {
    setState(() {
      _replaceImage(info: imageInfo);// 在这里刷新 imageInfo,触发重建
      _loadingProgress = null;
      _lastException = null;
      _lastStack = null;
      _frameNumber = _frameNumber == null ? 0 : _frameNumber! + 1;
      _wasSynchronouslyLoaded = _wasSynchronouslyLoaded | synchronousCall;
    });
  }

void _replaceImage({required ImageInfo? info}) {
    _imageInfo?.dispose();
    _imageInfo = info;
  }

ImageInfo类内部持有ui.Image和其对应的scale,以及一个获取图片像素大小的sizeBytes方法。

// ImageInfo: a [dart:ui.Image] object with its corresponding scale.
ImageInfo({ required this.image, this.scale = 1.0, this.debugLabel })

int get sizeBytes => image.height * image.width * 4;

build

上面分析了_ImageState 如何监听使用 ImageProvider 获取到的 ImageStream,从中获取 ImageInfo 更新自己的 ImageInfo? _imageInfo 属性,那么这个属性是如何影响到我们的 Image 展示图片的呢,关键就在 build 方法中:

Widget build(BuildContext context) {
    if (_lastException != null) {
      if (widget.errorBuilder != null)
        return widget.errorBuilder!(context, _lastException!, _lastStack);
      if (kDebugMode)
        return _debugBuildErrorWidget(context, _lastException!);
    }

  // 注意_ImageState 内部其实是使用 ui.Image _imageInfo?.image 创建了 RawImage 来展示图片
    Widget result = RawImage(
      // Do not clone the image, because RawImage is a stateless wrapper.
      // The image will be disposed by this state object when it is not needed
      // anymore, such as when it is unmounted or when the image stream pushes
      // a new image.
      image: _imageInfo?.image,// 这里会在 ImageStream 获取到 ImageInfo 之后更新
      debugImageLabel: _imageInfo?.debugLabel,
      width: widget.width,
      height: widget.height,
      scale: _imageInfo?.scale ?? 1.0,
      color: widget.color,
      opacity: widget.opacity,
      colorBlendMode: widget.colorBlendMode,
      fit: widget.fit,
      alignment: widget.alignment,
      repeat: widget.repeat,
      centerSlice: widget.centerSlice,
      matchTextDirection: widget.matchTextDirection,
      invertColors: _invertColors,
      isAntiAlias: widget.isAntiAlias,
      filterQuality: widget.filterQuality,
    );

    if (!widget.excludeFromSemantics) {
      result = Semantics(
        container: widget.semanticLabel != null,
        image: true,
        label: widget.semanticLabel ?? '',
        child: result,
      );
    }

    if (widget.frameBuilder != null)
      result = widget.frameBuilder!(context, result, _frameNumber, _wasSynchronouslyLoaded);

    if (widget.loadingBuilder != null)
   // 如果有 loadingBuilder 就包裹 result,所以注意进度为 100% 时要切换回图片,
   // 否则会一直显示进度,而非加载的图片
      result = widget.loadingBuilder!(context, result, _loadingProgress);

    return result;
  }

ImageInfo.imageui.Image对象,是原始的 image 像素,通过RawImage传入到Canvas.drawImageRect或者Canvas.drawImageNine绘制图片。

RawImage

A widget that displays a [dart:ui.Image] directly.

RawImage 继承自 LeafRenderWidget,可以直接展示ui.Image的内容,后者是解码的图片数据的不透明句柄(Opaque handle to raw decoded image data (pixels))、是对_Image类的封装、对外提供宽高以及Image.toByteData(将ui.Image对象转化为ByteData,ByteData 可以直接传入Canvas.drawImageRect方法第一个参数)。

RawImage 主要逻辑就是创建/更新 RenderImage 的时候将从_ImageState.build方法获得的ui.Image? image 的 clone 传入(其实就是使用ui.Image? image对应的_Image _image 新建了一个 ui.Image,每一个 ui.Image 都是_image 的一个句柄,只有当没有 ui.Image 指向_image 时后者才会真正的 dispose)。

RenderImage

An image in the render tree.

RenderImage 作为一个 RenderBox,在从 RawImage 那里拿到ui.Image? _image之后,然后在其RenderImage.paint方法中,会调用paintImage方法绘制_image代表的图片。

ui.Image 实际是 ui._Image 的包装类,它的 width、height、toByteData 等方法最终都是调用 ui._Image 对应的实现。

paintImage 方法是位于 lib\src\painting\decoration_image.dart 的全局方法,在其内部调用 canvas 绘制_image 对应的图片。


至此,我们可以看到,在Image中,根据构造方法的不同创建了不同的ImageProvider对象作为Image.image参数;

然后在 _ImageState 中,使用ImageProvider.resolve方法创建并更新ImageStream? _imageStream,并且监听ImageStream以便在图片加载成功之后获取ImageInfo? _imageInfo

这个ImageInfo是对ui.Image的封装类,在_ImageState.build方法中被传入RawImage,后者则创建了RenderImage并最终将 ui.Image 的内容绘制在屏幕上面。


图片获取与缓存

到目前为止,我们大体梳理了图片展示的这部分流程,此外,还有一部分同样重要的流程——图片的获取与缓存。

ImageProvider

ImageProvider 是获取图片资源的基类,其他类可以调用ImageProvider.resolve方法获取 ImageStream ,此方法会调用ImageCache.putIfAbsent优先从 ImageCache 中获取,如果没有则调用ImageProvider.load方法获取并缓存到 ImageCache 中。

其子类一般只需要重写ImageProviderImageStreamCompleter load(T key, DecoderCallback decode)Future<T> obtainKey(ImageConfiguration configuration)方法即可。

NetworkImage加载网络图片的过程为例:

我们通过NetworkImage()方法获取的实际是network_image.NetworkImage对象。

_ImageState._resolveImage()方法调用ImageProvider.resolve方法时,内部会调用ImageProvider.resolveStreamForKey方法,在其内部会执行:

  • 通过ImageProvider.obtainKey获取图片对应的 key
  • 执行PaintingBinding.*instance*!.imageCache!.putIfAbsent(key,() => load(key, PaintingBinding.*instance*!.instantiateImageCodec),onError: handleError,)方法,优先从 imageCache 中获取缓存的图片,没有的话执行ImageProvider.load方法获取图片。

对于network_image.NetworkImage对象,他的obtainKey()load()方法实现如下:

class NetworkImage extends image_provider.ImageProvider<image_provider.NetworkImage> implements image_provider.NetworkImage {
 const NetworkImage(this.url, { this.scale = 1.0, this.headers })

 
  Future<NetworkImage> obtainKey(image_provider.ImageConfiguration configuration) {
    // 注意这里的 key 是 NetworkImage 对象,也就是说网络图片加载的 url,scale,
    // header 等一致的话才会被认为命中缓存
    return SynchronousFuture<NetworkImage>(this);
  }

 
  ImageStreamCompleter load(image_provider.NetworkImage key, image_provider.DecoderCallback decode) {
    // Ownership of this controller is handed off to [_loadAsync]; it is that
    // method's responsibility to close the controller's stream when the image
    // has been loaded or an error is thrown.
    final StreamController<ImageChunkEvent> chunkEvents = StreamController<ImageChunkEvent>();

    return MultiFrameImageStreamCompleter(
      codec: _loadAsync(key as NetworkImage, chunkEvents, decode),// 真正从网络加载图片的方法
      chunkEvents: chunkEvents.stream,
      scale: key.scale,
      debugLabel: key.url,
      informationCollector: () => <DiagnosticsNode>[
        DiagnosticsProperty<image_provider.ImageProvider>('Image provider', this),
        DiagnosticsProperty<image_provider.NetworkImage>('Image key', key),
      ],
    );
  }
}

在这其中,network_image.NetworkImage._loadAsync()方法才是真正使用HttpClient从网上获取图片资源的方法(实际上 AssetBundleImageProvider、FileImage 和 MemoryImage 等一众 ImageProvider 等都约定俗成在_loadAsync 中执行真正获取图片的逻辑),返回值 Future<ui.Codec>和 ui.Image 的关系如下:

Future<ui.Image> decodeImageFromList(Uint8List bytes) async {
  final ui.Codec codec = await PaintingBinding.instance.instantiateImageCodec(bytes);
  final ui.FrameInfo frameInfo = await codec.getNextFrame();
  return frameInfo.image;
}

ImageCache

Class for caching images.

在上面方分析 ImageProvider 的时候,我们注意到,每次通过ImageProvider.resolveStreamForKey方法获取 ImageStream 时,都会调用PaintingBinding.instance!.imageCache.putIfAbsent方法优先获取Image 对象的缓存,这就涉及到和 Image 缓存有关的类——ImageCache

ImageCache 对象全局唯一,使用 LRU 算法最多缓存1000 张或者最大 100MB 图片,可以分别使用maximumSizemaximumSizeBytes修改配置。

其内部维持有三个 Map:

  • Map<Object, PendingImage> _pendingImages 正在加载中的图片,可能可能同时也是_liveImage(对应的 ImageStream 已经被监听了)。
  • Map<Object, _CachedImage> _cache 缓存的图片,maximumSize 和 maximumSizeBytes 限制针对的是_cache
  • Map<Object, _LiveImage> _liveImages 正在使用的图片,他的 ImageStreamCompleters 至少有一个 listener,可能同时在_pendingImages(所以这里的_LiveImage 的sizeBytes可能为 null)或者_liveImages中。

_CachedImage_LiveImage都继承自_CachedImageBase,其内部持有ImageStreamCompleter,图片的 handlerImageStreamCompleterHandle,以及图片大小sizeBytes

ImageCacheStatus处理 ImageCache 缓存的图片状态:

  • pending,还没有加载完成的 image,如果被监听的话,还会是live
  • keepAlive,图片会被ImageCache._cache保存。可能是 live 的,但不会 pending 的。
  • live,图片会一直被持有,除非ImageStreamCompleter没有 listener 了。可能是 pending 的,也可能是 keepAlive 的
  • untracked,不会被缓存的图片(上述三值都为 false)。

可以使用ImageCache.statusForKey或者ImageProvider.obtainCacheStatus获取图片状态ImageCacheStatus

此外,ImageCache 还提供ImageCache.evict方法从缓存中清除指定图片。

putIfAbsent

当 ImageProvider 调用ImageCache.putIfAbsent方法获取 ImageStreamCompleter 时,会依次尝试从_pendingImages_cache_liveImages 中读取,如果都没有则会尝试执行传入的 loader 方法获取。

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

    ImageStreamCompleter? result = _pendingImages[key]?.completer;
    // Nothing needs to be done because the image hasn't loaded yet.
    // 1. 如果图片还在加载中,就直接返回
    if (result != null) {
      return result;
    }

    // Remove the provider from the list so that we can move it to the
    // recently used position below.
    // Don't use _touch here, which would trigger a check on cache size that is
    // not needed since this is just moving an existing cache entry to the head.
    final _CachedImage? image = _cache.remove(key);
    if (image != null) {
      // The image might have been keptAlive but had no listeners (so not live).
      // Make sure the cache starts tracking it as live again.
      // 2. 如果_cache 中已经有了,就将其加入_liveImages 并返回
      _trackLiveImage(
        key,
        image.completer,
        image.sizeBytes,
      );
      _cache[key] = image;
      return image.completer;
    }

    // 3. 如果_liveImages 中已经有了,而 cache 中没有,就加入_cache,
    // 此时会检测大小和数量(这种属于图片刚下载完,或者已有缓存被清理)
    final _LiveImage? liveImage = _liveImages[key];
    if (liveImage != null) {
      _touch(
        key,
        _CachedImage(
          liveImage.completer,
          sizeBytes: liveImage.sizeBytes,
        ),
        timelineTask,
      );
      if (!kReleaseMode) {
        timelineTask!.finish(arguments: <String, dynamic>{'result': 'keepAlive'});
      }
      return liveImage.completer;
    }

    // 4.1 如果_pendingImages、_cacheImages、_liveImages 中都没有,就去下载
    // 并加入到_liveImages 中,此时_LiveImage.sizeBytes 为 null
    // 注意这里只是加入到_liveImages 中追踪,并未使用_cache,
    // 故而也【不受其最大数量和最大总大小约束】
    try {
      result = loader();
      _trackLiveImage(key, result, null);
    } catch (error, stackTrace) {
      ...
    }

    // If we're doing tracing, we need to make sure that we don't try to finish
    // the trace entry multiple times if we get re-entrant calls from a multi-
    // frame provider here.
    bool listenedOnce = false;

    // We shouldn't use the _pendingImages map if the cache is disabled, but we
    // will have to listen to the image at least once so we don't leak it in
    // the live image tracking.
    // If the cache is disabled, this variable will be set.
    _PendingImage? untrackedPendingImage;
    // 图片加载过程中的回调
    void listener(ImageInfo? info, bool syncCall) {
      int? sizeBytes;
      if (info != null) {
        sizeBytes = info.sizeBytes;
        info.dispose();
      }
      final _CachedImage image = _CachedImage(
        result!,
        sizeBytes: sizeBytes,
      );

      _trackLiveImage(key, result, sizeBytes);

      // Only touch if the cache was enabled when resolve was initially called.
      // 4.2 图片加载成功,如果有缓存,就将此图片加入_cache 中,此时会检测大小和数量
      // 并且这里的 sizeBytes 是图片实际大小
      if (untrackedPendingImage == null) {
        _touch(key, image, listenerTask);
      } else {
        image.dispose();
      }

      final _PendingImage? pendingImage = untrackedPendingImage ?? _pendingImages.remove(key);
      if (pendingImage != null) {
        pendingImage.removeListener();
      }

      listenedOnce = true;
    }

    final ImageStreamListener streamListener = ImageStreamListener(listener);
    if (maximumSize > 0 && maximumSizeBytes > 0) {
      _pendingImages[key] = _PendingImage(result, streamListener);
    } else {
      untrackedPendingImage = _PendingImage(result, streamListener);
    }
    // Listener is removed in [_PendingImage.removeListener].
    result.addListener(streamListener);

    return result;
  }

经过上述分析可以知道,当_cache、_liveImages、_pendingImages 中都没有指定图片时,会从网络下载(或者磁盘、asset 等),而在图片完全加载完成之前,_pendingImages 中下载图片所占大小是没有被 ImageCache 追踪的,也就是说ImageCache._cache 的最大个数和总大小限制都不会管理这部分图片;故而面对大量高清大图加载的场景(比如,五列 1:1 网格加载平均大小几 Mb 的网络图片),如果快速滑动会导致_pendingImages 急速增大,这样下载中且还未完全下载的图片所占用的内存会逐渐累计,从而导致 Flutter APP内存暴增,页面卡顿等(本地资源不容易出现是因为从 load 到图片加载完成间隔比较短,而网络图片由于网速等导致_pendingImages 中会累计很多正在下载中的图片,会比较明显)。

那些在 Flutter 中加载图片并且完全采用 ImageCache 管理图片内存的图片加载框架比如 Image/ExtendedImage/CachedNetworkImage 等都存在此问题;阿里的 PowerImage 由于将图片下载这个过程交给了原生成熟的图片加载库处理,使得 ImageCache 只管理已经加载完成的图片,从而避免了上述情况。


可以看到,以从网络加载图片为例,Flutter 原生提供的 Image 只有内存中的 ImageCache 一级缓存,如果 ImageCache 没有指定的图片(首次加载或者缓存被清空)则会再次从网络加载,这会导致多图列表的时候图片被频繁的回收/重新下载,从而影响用户体验。

为了解决上述问题,涌现了很多第三方图片加载控件:

总结

简单总结一下 Flutter 原生 Image 组件加载图片的流程:

flutter_image_class_structure.png
flutter_image_class_structure.png

简单来说如下:

  • 用户通过 Image Widget 的各个构造方法创建指定的 ImageProvider;
  • 在_ImageState 中使用ImageProvider.resolve(ImageConfiguration)获取并监听 ImageStream(listener 为 ImageStreamListener);
  • ImageProvider 会按照传入的 ImageConfiguration 生成的 key 在 ImageCache 中查找对应的缓存,没有的话则先加载再缓存;
  • 当 ImageProvider 成功加载图片时,ImageStreamListener 获得 ImageInfo 时,并触发_ImageState.build()方法将ui.Image _imageInfo?.image传入 RawImage 中;
  • 作为一个 LeafRenderObjectWidget,RawImage 创建 RenderImage 并传入ui.Image? image?.clone()作为RenderImage.image,此后再在RenderImage.paint方法中调用系统的paintImage()方法通过canvas.drawImageRect绘制图片内容。

参考资料

Image_api.flutter.devopen in new window
京东在 Flutter 加载大量图片导致的内存溢出的优化实践open in new window

文章标题:《Flutter 图片加载方案分析之 Image》
本文作者: JI,XIAOYONG
发布时间: 2022/07/27 15:31:03 UTC+8
更新时间: 2023/12/30 16:17:02 UTC+8
written by human, not by AI
本文地址: https://jixiaoyong.github.io/blog/posts/1912667a.html
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 许可协议。转载请注明出处!
你认为这篇文章怎么样?
  • 0
  • 0
  • 0
  • 0
  • 0
  • 0
评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v2.15.8