To Be An Artist Engineer.

0%

Flutter控件研究之Image组件

Flutter版本: Flutter (Channel master, v1.9.1, on Mac OS X 10.14.4 18E226, locale zh-Hans-CN)

基本用法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const Image({
Key key,
@required this.image,
this.frameBuilder,
this.loadingBuilder,
this.semanticLabel,
this.excludeFromSemantics = false,
this.width,
this.height,
this.color,
this.colorBlendMode,
this.fit,
this.alignment = Alignment.center,
this.repeat = ImageRepeat.noRepeat,
this.centerSlice,
this.matchTextDirection = false,
this.gaplessPlayback = false,
this.filterQuality = FilterQuality.low,
}) : assert(image != null),
assert(alignment != null),
assert(repeat != null),
assert(filterQuality != null),
assert(matchTextDirection != null),
super(key: key);

上面是Image Widget的构造方法,其中四种属性是最常被使用的:

imagewidthheightfit
  • width和height表示的是控件的大小,如果为null则自适应图片本身的宽高或者由更外层的控件决定(Tight Layout Constraints)。如果设定的尺寸大小和图片本身的大小不一致,那就要根据fit属性来决定最终的显示。

  • fit表示的是图片伸缩类型,和Android中的scaleType类似,包含以下几种:

    fill contain cover fitWidth fitHeight scaleDown none

  • image属性为ImageProvider类型,官方提供了四种供开发者直接使用:

    NetworkImage AssetImage MemoryImage FileImage
    它们分别对应于Image的四种工厂方法: + `Image.network("")`,加载网络图片
    • Image.asset("",package:""),加载本地资源文件,需要注意的是图片文件必须在pubspec.yaml中注册。如果希望Flutter在不同分辨率下使用不同大小的图片则需要在文件夹下创建2.0x,3.0x等子文件夹,在这些文件夹中放入同名文件并且在yaml中注册。至于package属性则是在lib下创建图片文件夹时才会用到,这些都可以在官网上看到(在Flutter中添加资源和图片),本文不再赘述。
    • Image.memory(Uint8List),平时开发用的较少,表示直接加载字节数组
    • Image.file(""),表示直接加载本地图片,一般会配合path_provider使用

图片加载

在整个图片加载的过程中起决定性作用的是ImageProvider类

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
abstract class ImageProvider<T> {

ImageStream resolve(ImageConfiguration configuration) {
assert(configuration != null);
//创建ImageStream
final ImageStream stream = ImageStream();
T obtainedKey;
///省略了error handle的逻辑
dangerZone.runGuarded(() {
Future<T> key;
try {
//获取ImageProvider对象
key = obtainKey(configuration);
} catch (error, stackTrace) {
handleError(error, stackTrace);
return;
}
key.then<void>((T key) {
obtainedKey = key;
//首先从缓存中去取,如果取不到就调用load(key)方法加载图片
final ImageStreamCompleter completer = PaintingBinding.instance
.imageCache.putIfAbsent(key, () => load(key), onError: handleError);
if (completer != null) {
stream.setCompleter(completer);
}
}).catchError(handleError);
});
return stream;
}

Future<T> obtainKey(ImageConfiguration configuration);

@protected
ImageStreamCompleter load(T key);
}

可以看到其中比较关键的几个方法:resolve, obtainKey, load

下面是上面代码块的拆解分析:

  • 生成ImageStream
  • 生成ImageProvider对象:key
  • 以key为键去ImageCache中查看是否已经有缓存(ImageStreamCompleter)对象,如果有就直接返回,如果没有就调用load(key)方法生成对应的ImageStreamCompleter并缓存起来
  • 把上一步获取的ImageStreamCompleter对象设置给ImageStream
1
2
3
4
5
6
7
8
9
10
11
12

///ImageStream setCompleter方法:

void setCompleter(ImageStreamCompleter value) {
assert(_completer == null);
_completer = value;
if (_listeners != null) {
final List<ImageStreamListener> initialListeners = _listeners;
_listeners = null;
initialListeners.forEach(_completer.addListener);
}
}

可以看到的是ImageStream把所有监听器都注册到ImageStreamCompleter,这样在图片加载成功后就可以回调给所有的监听者,这其中就包括_ImageState:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

///ImageStreamCompleter setImage方法:

void setImage(ImageInfo image) {
_currentImage = image;
if (_listeners.isEmpty)
return;
// Make a copy to allow for concurrent modification.
final List<ImageStreamListener> localListeners =
List<ImageStreamListener>.from(_listeners);
for (ImageStreamListener listener in localListeners) {
try {
listener.onImage(image, false);
} catch (exception, stack) {
reportError(
context: ErrorDescription('by an image listener'),
exception: exception,
stack: stack,
);
}
}
}

可以看到Flutter中Image的加载还是很简洁的,基本上可以简化为Image控件通过ImageStream注册监听器到真正的图片加载器ImageStreamCompleter,然后在图片成功获取后通过回调的方式返回给Image Widget

下面是笔者绘制的NetWorkImage图片加载UML图:(其他类型的ImageProvider加载过程都类似)

图片优化

Flutter中的Image组件使用十分简洁,但是也有一些缺陷。

1、没法定制Error Widget

Image没有提供error callback,如果开发者想定制错误布局需要自己自定义ImageProvider

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
class CustomNetworkImage extends ImageProvider<NetworkImageSSL> {
const CustomNetworkImage(this.url,
{this.scale = 1.0, this.headers, this.imageErrorListener})
: assert(url != null),
assert(scale != null);

final String url;

final double scale;

final Map<String, String> headers;

final ImageErrorListener imageErrorListener;

@override
ImageStreamCompleter load(NetworkImageSSL key) {
MultiFrameImageStreamCompleter multiFrameImageStreamCompleter = MultiFrameImageStreamCompleter(
codec: _loadAsync(key),
scale: key.scale,
informationCollector: () {
List<DiagnosticsNode> informationCollector;
informationCollector.add(DiagnosticsProperty('Image provider', this));
informationCollector.add(DiagnosticsProperty('Image key', key));
return informationCollector;
});

///添加Error Callback
ImageStreamListener listener;
listener = ImageStreamListener((imageInfo, ret) {
//移除监听器
multiFrameImageStreamCompleter.removeListener(listener);
}, onError: (ex, stack) {
multiFrameImageStreamCompleter.removeListener(listener);
//回调给上层控件
imageErrorListener?.call(ex, stack);
});

multiFrameImageStreamCompleter.addListener(listener);
return multiFrameImageStreamCompleter;
}
}

如果需要添加占位图,可以考虑使用Image控件的loadingBuilder属性,Flutter还提供了下载进度的回调通知

1
2
3
4
5
typedef ImageLoadingBuilder = Widget Function(
BuildContext context,
Widget child,
ImageChunkEvent loadingProgress,
);

2、缓存不友好

Flutter中ImageCache只实现了内存缓存(默认最多缓存1000张图片和100M内存),并且缓存的是ImageStreamCompleter(不是ui.Image)对象。

如果想要自定义缓存数量或者最大内存可以在Root Widget里定义:

1
2
3
4
5
6
7
@override
void initState() {
super.initState();
//最多100张图片和150M内存
PaintingBinding.instance.imageCache.maximumSize = 100;
PaintingBinding.instance.imageCache.maximumSizeBytes = 150<<20;
}

ImageCache是没有磁盘缓存的,这就意味着可能会重复加载图片,从而造成流量浪费。要添加磁盘缓存,需要定制ImageProvider(下面是伪码)

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
Future<ui.Codec> _loadAsync(NetworkImageSSL key) async {
assert(key == this);

/////////////////在加载前判断本地是否有缓存/////////////////
if(diskCached(key.url)){
return getBytes(key.url);
}
/////////////////在加载前判断本地是否有缓存/////////////////

final Uri resolved = Uri.base.resolve(key.url);
final HttpClientRequest request = await _httpClient.getUrl(resolved);
headers?.forEach((String name, String value) {
request.headers.add(name, value);
});
final HttpClientResponse response = await request.close();
if (response.statusCode != HttpStatus.ok) {
throw new Exception(
'HTTP request failed, statusCode: ${response?.statusCode}, $resolved');
}

final Uint8List bytes = await consolidateHttpClientResponseBytes(response);
if (bytes.lengthInBytes == 0) {
throw new Exception('NetworkImageSSL is an empty file: $resolved');
}

/////////////////缓存到本地/////////////////
saveBytesToDisk(key.url,bytes);
/////////////////缓存到本地/////////////////
return await ui.instantiateImageCodec(bytes);
}

注意到上述代码最后一个过程:return await ui.instantiateImageCodec(bytes);

这段代码的含义是解析图片字节数组获取ui.Codec对象,但是可惜的是Flutter不会根据实际显示图片的大小进行解码。举个栗子,假如我要加载一张1000x1000大小的图片,而显示区域只有100x100,那解码所耗费的内存是按1000x1000算的,这显然会造成不必要的浪费。

笔者用一张图片做了测试:

优化的方案很简单:

1
2
3
4
5
6
7
8
Future<Codec> instantiateImageCodec(Uint8List list, {
int targetWidth,
int targetHeight,
}) {
return _futurize(
(_Callback<Codec> callback) => _instantiateImageCodec(list, callback, null, targetWidth ?? _kDoNotResizeDimension, targetHeight ?? _kDoNotResizeDimension)
);
}

只要在解码时传入targetWidth和targetHeight即可(Flutter 版本至少要大于1.5.4,低于此版本就没法使用该优化方案)

3、无法降低的内存占用

Flutter中的Image内存似乎存在着内存泄漏的危险,笔者做了一个简单的实验,即一张空白页面加载一张网络图片,下面是实验过程(数据均来自Dart Observatory):

打开页面

可以看到新生代内存占用为2.9M,老年代为0

销毁页面

可以看到新生代内存占用为0,老年代为2.9M,至此都是正常的,因为Dart VM的老年代就是存储较大内存对象的,然后笔者进行了手动GC操作,发现内存占用没发生变化,即老年代里的Image对象没有被回收。

销毁页面+手动清缓存
1
2
3
4
5
6
7
@override
void initState() {
super.initState();
//禁止缓存
PaintingBinding.instance.imageCache.maximumSize = 0;
PaintingBinding.instance.imageCache.maximumSizeBytes = 0;
}

起初笔者考虑到问题可能出在图片缓存上,因为缓存持有了图片对象可能导致内存无法被彻底回收,但是结果是
一样的,Image对象依旧无法被回收。

调用native方法回收

ImageStreamCompleter->ImageInfo->ui.Image->dispose->native 'Image_dispose'

笔者尝试了在页面销毁的时候销毁ui.Image对象,并且手动GC,结果是

可以看到已经看不到图片对象了,但是需要注意的是该方法会彻底回收ui.Image对象,所以如果应用到生产项目需要考虑到其他可能引用到ui.Image的地方,毕竟这个方案有点过于简单粗暴了。下面是参考伪码

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
class CacheableNetworkImage extends ImageProvider<NetworkImageSSL> {
CacheableNetworkImage(this.url,
{this.scale = 1.0, this.headers})
: assert(url != null),
assert(scale != null);

final String url;

final double scale;

final Map<String, String> headers;

///////////保存imageInfo对象////////////
ImageInfo imageInfo;
///////////保存imageInfo对象////////////

////////////页面销毁时调用///////////////
void dispose() {
imageInfo?.image?.dispose();
}
////////////页面销毁时调用///////////////

@override
ImageStreamCompleter load(NetworkImageSSL key) {
var multiFrameImageStreamCompleter = new MultiFrameImageStreamCompleter(
codec: _loadAsync(key),
scale: key.scale,
informationCollector: () {
List<DiagnosticsNode> informationCollector;
informationCollector.add(DiagnosticsProperty('Image provider', this));
informationCollector.add(DiagnosticsProperty('Image key', key));
return informationCollector;
});

///添加Error Callback
ImageStreamListener listener;
listener = ImageStreamListener((imageInfo, ret) {

/////////保存ImageInfo对象/////////
this.imageInfo = imageInfo;
/////////保存ImageInfo对象/////////

multiFrameImageStreamCompleter.removeListener(listener);
}, onError: (ex, stack) {
multiFrameImageStreamCompleter.removeListener(listener);
imageErrorListener?.call(ex, stack);
});

multiFrameImageStreamCompleter.addListener(listener);
return multiFrameImageStreamCompleter;
}
......................
}