最近碰到一个问题,自己使用 AssetBundle
加载 asset 图片去绘制的时候,不能自动加载到正确分辨率下的图片。于是好奇想一探究竟—— ImageAsset
究竟做了什么,能自动适配不同分辨率的图片加载。
研究 ImageAsset
就自然要从 ImageProvider
看起,那么今天的两个问题就上线了:
- ImageProvider 的图片加载流程
- ImageAsset 如何做到不同分辨率的适配
我们说过带问题读源码的思路是什么?一概览,二找入口,三顺藤摸瓜对不对。
所以先从 image_provider.dart
文件看起,概览一下它有哪些类,类的大致结构怎样。
一、类的结构
先看看文件里有哪些类
- ImageConfiguration
- ImageProvider 抽象基类
- Key 系
- ImageProvider 系
- 其它
看起来东西不多,还是先扫一眼,大致了解每个类的内容和作用,然后从我们的目标ImageProvider
的用法入手,一点点往里剖析。
1. ImageConfiguration
看起来是和平台环境有关的内容,应该是用来作加载目标判定的。
const ImageConfiguration({
this.bundle,
this.devicePixelRatio,
this.locale,
this.textDirection,
this.size,
this.platform,
});
2. ImageProvider抽象基类
这个类的注释阿拉巴啦讲了很多,我们先不看。因为大多数人其实对 ImageProvider
特性还算了解,我们先看看它的构造,然后可以猜猜它的工作流程,我们先自己思考思考。最后再借他的注释帮我们理顺思路,查漏补缺。这样印象能更加深刻。
我们看看它的方法签名和注释。
2.1 关键方法 resolve
ImageStream resolve(ImageConfiguration configuration);
This is the public entry-point of the [ImageProvider] class hierarchy.
注释说,这个方法是 ImageProvider
家族的public的入口,返回值是 ImageStream
。就是说所有的 ImageProvider
都是调这个方法来加载图片流。
既然这个方法是入口,主要流程应该都在这个方法里。一会儿我们来主要分析这个方法。
继续看注释:
Subclasses should implement [obtainKey] and [load], which are used by this method. If they need to change the implementation of [ImageStream] used, they should override [createStream]. If they need to manage the actual resolution of the image, they should override [resolveStreamForKey].
子类应该实现 obtainKey
和 load
方法。
如果你想改变 ImageStream
的实现,重写 createStream
。
如果你要管理图片实际要使用的分辨率,重写 resolveStreamForKey
。
2.2 其它方法
这些方法我们也大致猜测一下。
// 上面提到的`createStream`方法
ImageStream createStream(ImageConfiguration configuration);
// 缓存相关
Future<ImageCacheStatus> obtainCacheStatus({
@required ImageConfiguration configuration,
ImageErrorListener handleError,
})
// 和异常捕获相关,注释说用来保证捕获创建key期间的所有一场,「包括同步和异步」。大概率会用到zone相关的内容。
_createErrorHandlerAndKey(
ImageConfiguration configuration,
_KeyAndErrorHandlerCallback<T> successCallback,
_AsyncKeyErrorHandler<T> errorCallback,
)
// 根据key获取stream,提到来key,想必是和缓存相关了
void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream, T key, ImageErrorListener handleError);
// evict[驱逐] 带ImageCache参数,应当是从缓存里移除之类的
Future<bool> evict({ ImageCache cache, ImageConfiguration configuration = ImageConfiguration.empty });
// 这俩是实现 [ImageProvider] 必须实现的方法,应该是获取 key 和加载流的关键方法了。
Future<T> obtainKey(ImageConfiguration configuration);
ImageStreamCompleter load(T key, DecoderCallback decode);
2.3 作一些猜测
看完上面的这些方法应该能了解到这几个关键字:
- ImageConfiguration 平台环境参数
- ImageStream 最终返回的图片数据流
- key 大概率是缓存键
- 必须实现 load方法和obtainKey方法
这样是不是可以大致猜测出主要流程了?
入口是以 ImageConfiguration
为参数调用 ImageProvider.resolve
方法
- 调用
createStream
创建 ImageStream - 调用
obtainKey
方法获取资源的 缓存键 key - 以 key 和 stream 为参数调用
resolveStreamForKey
方法- 去缓存中查询是否有key对应的缓存
- 若有缓存,使用缓存
- 若无缓存,调用
load
方法加载资源
3. ResizeImage/_SizeAwareCacheKey
分别是区分Asset资源的key,和区分尺寸的key
4. NetworkImage/FileImage/MemoryImage
这几个类既是 ImageProvider
的实现类,又是缓存键类
5. AssetBundleImageKey/AssetBundleImageProvider/ExactAssetImage
AssetBundleImageKey
是缓存键, AssetBundleImageProvider
是抽象类,实现了读取 Asset
资源的 load
方法, ExactAssetImage
继承自 AssetBundleImageProvider
,构造方法:
const ExactAssetImage(
this.assetName, {
this.scale = 1.0,
this.bundle,
this.package,
})
有个 scale
参数,很可能和我想要的按分辨率加载相关。
二、ImageProvider 的主要工作流程分析
我们上一节说了,关键流程在它的关键方法 resolve
里,为了展示得比较清楚,这里不得不搬运些代码了。
我这里删除了不必要的代码,只留下关键部分。如果你仔细读了上面,应该会发现这些代码一点都不陌生了。
我直接把说明写到代码注释里,看完应该就很清楚了。
ImageStream resolve(ImageConfiguration configuration) {
assert(configuration != null);
// [1]
final ImageStream stream = createStream(configuration);
// [2]
_createErrorHandlerAndKey(
configuration,
(T key, ImageErrorListener errorHandler) {
// [3]
resolveStreamForKey(configuration, stream, key, errorHandler);
},
(T key, dynamic exception, StackTrace stack) async {
// key 创建失败的处理,不是关键
);
return stream;
}
- 创建 ImageStream
final ImageStream stream = createStream(configuration);
createStream 上面我们说过,如果你想使用不同的 ImageStream 实现,重写这个 [createStream] 方法就行了 这里创建了 [ImageStream] 实例,是我们最终要返回的结果,也是下面流程要用到的关键对象。
- 创建缓存键 key
_createErrorHandlerAndKey(
configuration,
(T key, ImageErrorListener errorHandler) {
// 成功回调
},
(T key, dynamic exception, StackTrace stack) async {
// 失败回调
);
_createErrorHandlerAndKey
我们也说过了,用来创建 key
,同时保证无论创建 key
的方法是异步还是同步,都能捕获到异常。
他有三个参数,1. ImageConfiguration
2. key
创建成功的回调 3. key
创建失败的回调
这个方法的实现和我们猜测的一样,使用了 zone
机制,不在今天的范围内,就不描述了。
key
创建成功后走缓存策略
缓存策略是在 resolveStreamForKey
方法里实现。
void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream, T key, ImageErrorListener handleError) {
if (stream.completer != null) {
// 分支 1
final ImageStreamCompleter completer = PaintingBinding.instance.imageCache.putIfAbsent(
key,
() => stream.completer,
onError: handleError,
);
assert(identical(completer, stream.completer));
return;
}
// 分支 2
final ImageStreamCompleter completer = PaintingBinding.instance.imageCache.putIfAbsent(
key,
() => load(key, PaintingBinding.instance.instantiateImageCodec),
onError: handleError,
);
if (completer != null) {
stream.setCompleter(completer);
}
}
这个方法也很简单,总共就两个分支
- 如果
stream.completer
已经设置过了,那么重新往ImageCache
里put一下 - 如果没设置过,调
load
方法获取新的ImageStreamCompleter
方法,然后put到ImageCache
里,再把它设置给stream.completer
。
到这里基本就理清了,和我们当初的猜测基本一致。
再回顾一遍最初的猜测:
- 调用
createStream
创建 ImageStream - 调用
obtainKey
方法获取资源的 缓存键 key - 以 key 和 stream 为参数调用
resolveStreamForKey
方法- 去缓存中查询是否有key对应的缓存
- 若有缓存,使用缓存
- 若无缓存,调用
load
方法加载资源
** 你可能不清楚的小知识点
如果上面有些概念你不清楚,这里稍微介绍一下:
ImageCache
是啥呢,一个图片的 LRU
缓存类, LRU
: least-recently-used
。
ImageCahce.putIfAbsent
是啥, Absent
意思是缺席、不存在,就是说如果缓存里现在没有,就put一下。当然如果有了也不是啥都不干,它会把命中的目标放到 most recently used
位置。
ImageStream
是啥,有两个成员: ImageStreamCompleter
和 List<ImageStreamListener> _listeners
。
做一件事, 设置 completer
时,会把所有已有的 listener
添加到 completer
里。
ImageStreamCompleter
又是啥,相当于观察者模式里的可订阅对象。
它又一个 ImageInfo
成员,设置这个成员时,会去通知从 ImageStream
里设置的 listener
。
在今天的场景里就是,当图片在 load
设置的加载方法中真正加载完成,会依次去通知 completer.listener
→ ImageStream.listener
→ load
方法设置的 listener
。
三、AssetImage 如何自动适配不同分辨率加载图片?
终于回到了最初的问题,分析思路是什么?找到入口,然后顺藤摸瓜对吧。
继承关系:
ImageProvider → AssetBundleImageProvider → AssetImage
我们上面一张提到过, ImageProvider
的实现类里,有两个必须要实现的方法 obtainKey
和 load
,其中实际在做加载图片操作的是哪个方法? load
对吧,那我们就从这个方法入手,看看它到底是做了什么,来适应不同的分辨率。
AssetImage
本身只重写了 obtainKey
方法, load
在它的父亲 AssetBundleImageProvider
里重写了。
先看看 load 方法:
// class AssetBundleImageProvider
@override
ImageStreamCompleter load(AssetBundleImageKey key, DecoderCallback decode) {
InformationCollector collector;
return MultiFrameImageStreamCompleter(
codec: _loadAsync(key, decode),
scale: key.scale,
informationCollector: collector
);
}
可以看到 load
方法返回了一个 MultiFrameImageStreamCompleter
实例,这个类的构造方法中调用了 codec.then(xxx)
,也就是 _loadAsync
方法。
_loadAsync 方法:
@protected
Future<ui.Codec> _loadAsync(AssetBundleImageKey key, DecoderCallback decode) async {
ByteData data;
try {
data = await key.bundle.load(key.name);
} on FlutterError {
// xxxxx
}
if (data == null) {
// xxxxx
}
return await decode(data.buffer.asUint8List());
}
_loadAsync
中做了两件事,
- key.bundle.load(key.name);
- decode(data.buffer.asUint8List());
加载过程是第一步里做的,他用到了 key
里的两个属性, key.bundle
和 [key.name](http://key.name)
,上面说了 key
是哪来的? AssetImage
重写了 obtainKey
对不对。那我们只要看这个方法,看看这两个成员是如何赋值的就能找到答案了对不对。
先做猜测:
还是先来猜一下,这里有两个可能性,
- 方法里对
[key.name](http://key.name)
进行了替换,自动加上了2.0x/
或3.0x/
之类的前缀。 - 方法里对
key.bundle
进行了替换,换成了一个拥有适配分辨率能力的AssetBundle
。
到 obtainKey 方法里找答案:
// class AssetImage
Future<AssetBundleImageKey> obtainKey(ImageConfiguration configuration) {
**final AssetBundle chosenBundle = bundle ?? configuration.bundle ?? rootBundle;
// xxxxx**
chosenBundle.loadStructuredData<Map<String, List<String>>>(_kAssetManifestFileName, _manifestParser).then<void>(
(Map<String, List<String>> manifest) {
**final String chosenName = _chooseVariant(
keyName,
configuration,
manifest == null ? null : manifest[keyName],
);**
final double chosenScale = _parseScale(chosenName);
final AssetBundleImageKey key = AssetBundleImageKey(
**bundle: chosenBundle,
name: chosenName,**
scale: chosenScale,
);
// 分发结果 xxxxx
}
).catchError((dynamic error, StackTrace stack) {
// 处理错误 xxxxx
});
// 返回结果 xxxxx
}
我把关键部分加粗了,回忆一下我们的目的是什么?找到 [key.name](http://key.name)
和 key.bundle
是如何赋值的,哪个更可能和分辨率有关。
最后赋值的时候两个参数 chosenBundle
和 chosenName
,前者很简单:
**final AssetBundle chosenBundle = bundle ?? configuration.bundle ?? rootBundle;**
结果会依次从这三个候选参数中选择, bundle
是实例化 AssetBundle
作为参数传入的,我们知道不传这个参数,对适配没有影响,可以排除。
configuration.bundle
是调用 ImageProvider.resolve(ImageConfiguration)
时传入的,一般这个使用 DefaultAssetBundle.of(context),
一般来说它也会返回 rootBundle
,我们知道 rootBundle
本身没有适配分辨率的能力。
基于此,基本可以排除第二个猜测——包装了一个适配分辨率的 AssetBundle
——是错误的。
那么可能性就是第一个猜测了——方法里对 [key.name](http://key.name)
进行了替换,自动加上了 2.0x/
或 3.0x/
之类的前缀。
chosenName 如何赋值:
final String chosenName = _chooseVariant(
keyName,
configuration,
manifest == null ? null : manifest[keyName],
);
阅读 _chooseVariant
代码发现中确实对分辨率进行了处理,这部分就是一些计算逻辑了,我就不再罗列代码,把它的大体步骤分享一下就好:
在之前我还是先说明几个参数:
keyName
: AssetImage(keyName)
构造方法传入。
configuration
: 调用 ImageProvider.resolve
时传入,一般是使用的 widget
比如 Image
来初始化。
manifest
: pubspec.yaml
编译时生成的中间文件信息,包括你定义的图片路径等
- 从
manifest
获取对应文件所有分辨率下的路径 - 如果获取到的路径为空或
configuration.devicePixelRatio == null
,返回原keyName
- 遍历路径列表
- 从路径中
_parseScale
,获取倍数 - 以倍数为键,路径为值,存入
SplayTreeMap<double, String> mapping
- 从路径中
- 从
mapping
中,找到和configuration.devicePixelRatio
最接近的倍数对应的路径并返回- 寻找规则是就近规则,和安卓系统的规则相同
这样子,找到了正确分辨率下的图片, AssetBundleImageKey
就赋值完成。
回到 AssetBundleImageProvider._loadAsync
方法中:
data = await key.bundle.load(key.name);
是不是一下就通了呢?
四、总结
今天学到了这么几点:
- 实现一个
ImageProvider
很简单,只需要实现load
和obtainKey
方法 - 不要再简单地使用
rootBundle.load(path)
来加载文件,因为它并不会自动适配各类分辨率。
正确的加载图片的方法是:
/// 加载图片
static Future<ui.Image> _loadImage(BuildContext context, String path) async {
Completer<ui.Image> completer = Completer();
AssetImage(path)
.resolve(createLocalImageConfiguration(context))
.addListener(ImageStreamListener((image, _) {
completer.complete(image.image);
}, onError: (_, __) {}));
return completer.future;
}