Flutter图片加载方案分析之Image

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

本文对 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为例:

1
2
3
4
5
6
7
8
9
10
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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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 展示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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方法。

1
2
3
4
// 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 方法中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
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()方法实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class NetworkImage extends image_provider.ImageProvider<image_provider.NetworkImage> implements image_provider.NetworkImage {
const NetworkImage(this.url, { this.scale = 1.0, this.headers })

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

@override
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 的关系如下:

1
2
3
4
5
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 方法获取。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
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 没有指定的图片(首次加载或者缓存被清空)则会再次从网络加载,这会导致多图列表的时候图片被频繁的回收/重新下载,从而影响用户体验。

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

  • extended_image 对官方 Image 的二次开发,增加了磁盘缓存。
  • flutter_cached_network_image 使用sqflite 数据库管理缓存的网络图片加载库,增加了磁盘缓存。
  • power_image 使用于混合项目的图片加载库,提供ffitexture两种图片展示方式,依赖于原生图片加载库(比如Glide)加载图片、管理缓存。

总结

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

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.dev
京东在Flutter加载大量图片导致的内存溢出的优化实践