[深入学习Flutter] ImageProvider工作流程和AssetImage 的自动分辨率适配原理

flutter Nov 17, 2020

最近碰到一个问题,自己使用 AssetBundle 加载 asset 图片去绘制的时候,不能自动加载到正确分辨率下的图片。于是好奇想一探究竟—— ImageAsset 究竟做了什么,能自动适配不同分辨率的图片加载。

研究 ImageAsset 就自然要从 ImageProvider 看起,那么今天的两个问题就上线了:

  1. ImageProvider 的图片加载流程
  2. ImageAsset 如何做到不同分辨率的适配

我们说过带问题读源码的思路是什么?一概览,二找入口,三顺藤摸瓜对不对。

所以先从 image_provider.dart 文件看起,概览一下它有哪些类,类的大致结构怎样。

一、类的结构

先看看文件里有哪些类

Untitled

  • 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].

子类应该实现 obtainKeyload 方法。

如果你想改变 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 作一些猜测

看完上面的这些方法应该能了解到这几个关键字:

  1. ImageConfiguration 平台环境参数
  2. ImageStream 最终返回的图片数据流
  3. key 大概率是缓存键
  4. 必须实现 load方法和obtainKey方法

这样是不是可以大致猜测出主要流程了?

入口是以 ImageConfiguration 为参数调用 ImageProvider.resolve 方法

  1. 调用 createStream 创建 ImageStream
  2. 调用 obtainKey 方法获取资源的 缓存键 key
  3. 以 key 和 stream 为参数调用 resolveStreamForKey 方法
    1. 去缓存中查询是否有key对应的缓存
    2. 若有缓存,使用缓存
    3. 若无缓存,调用 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;
  }
  1. 创建 ImageStream
final ImageStream stream = createStream(configuration);

createStream 上面我们说过,如果你想使用不同的 ImageStream 实现,重写这个 [createStream] 方法就行了
这里创建了 [ImageStream] 实例,是我们最终要返回的结果,也是下面流程要用到的关键对象。

  1. 创建缓存键 key
_createErrorHandlerAndKey(
      configuration,
      (T key, ImageErrorListener errorHandler) {
			// 成功回调
      },
      (T key, dynamic exception, StackTrace stack) async {
      // 失败回调
    );

_createErrorHandlerAndKey 我们也说过了,用来创建 key,同时保证无论创建 key的方法是异步还是同步,都能捕获到异常。
他有三个参数,1. ImageConfiguration 2. key创建成功的回调 3. key创建失败的回调
这个方法的实现和我们猜测的一样,使用了 zone 机制,不在今天的范围内,就不描述了。

  1. 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);
    }
  }

这个方法也很简单,总共就两个分支

  1. 如果 stream.completer 已经设置过了,那么重新往 ImageCache 里put一下
  2. 如果没设置过,调 load 方法获取新的 ImageStreamCompleter 方法,然后put到 ImageCache 里,再把它设置给 stream.completer

到这里基本就理清了,和我们当初的猜测基本一致。

再回顾一遍最初的猜测:

  1. 调用 createStream 创建 ImageStream
  2. 调用 obtainKey 方法获取资源的 缓存键 key
  3. 以 key 和 stream 为参数调用 resolveStreamForKey 方法
    1. 去缓存中查询是否有key对应的缓存
    2. 若有缓存,使用缓存
    3. 若无缓存,调用 load 方法加载资源

** 你可能不清楚的小知识点

如果上面有些概念你不清楚,这里稍微介绍一下:

ImageCache 是啥呢,一个图片的 LRU 缓存类, LRUleast-recently-used

ImageCahce.putIfAbsent 是啥, Absent 意思是缺席、不存在,就是说如果缓存里现在没有,就put一下。当然如果有了也不是啥都不干,它会把命中的目标放到 most recently used 位置。

ImageStream 是啥,有两个成员: ImageStreamCompleterList<ImageStreamListener> _listeners

做一件事, 设置 completer 时,会把所有已有的 listener 添加到 completer 里。

ImageStreamCompleter 又是啥,相当于观察者模式里的可订阅对象。

它又一个 ImageInfo 成员,设置这个成员时,会去通知从 ImageStream 里设置的 listener

在今天的场景里就是,当图片在 load 设置的加载方法中真正加载完成,会依次去通知 completer.listenerImageStream.listenerload 方法设置的 listener

三、AssetImage 如何自动适配不同分辨率加载图片?

终于回到了最初的问题,分析思路是什么?找到入口,然后顺藤摸瓜对吧。

继承关系:

ImageProvider → AssetBundleImageProvider → AssetImage

我们上面一张提到过, ImageProvider 的实现类里,有两个必须要实现的方法 obtainKeyload ,其中实际在做加载图片操作的是哪个方法? 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 中做了两件事,

  1. key.bundle.load(key.name);
  2. decode(data.buffer.asUint8List());

加载过程是第一步里做的,他用到了 key 里的两个属性, key.bundle[key.name](http://key.name) ,上面说了 key 是哪来的? AssetImage 重写了 obtainKey 对不对。那我们只要看这个方法,看看这两个成员是如何赋值的就能找到答案了对不对。

先做猜测:

还是先来猜一下,这里有两个可能性,

  1. 方法里对 [key.name](http://key.name) 进行了替换,自动加上了 2.0x/3.0x/ 之类的前缀。
  2. 方法里对 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 是如何赋值的,哪个更可能和分辨率有关。

最后赋值的时候两个参数 chosenBundlechosenName ,前者很简单:

**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 代码发现中确实对分辨率进行了处理,这部分就是一些计算逻辑了,我就不再罗列代码,把它的大体步骤分享一下就好:

在之前我还是先说明几个参数:

keyNameAssetImage(keyName) 构造方法传入。

configuration: 调用 ImageProvider.resolve 时传入,一般是使用的 widget比如 Image 来初始化。

manifest : pubspec.yaml 编译时生成的中间文件信息,包括你定义的图片路径等

  1. manifest 获取对应文件所有分辨率下的路径
  2. 如果获取到的路径为空或 configuration.devicePixelRatio == null ,返回原 keyName
  3. 遍历路径列表
    1. 从路径中 _parseScale ,获取倍数
    2. 以倍数为键,路径为值,存入 SplayTreeMap<double, String> mapping
  4. mapping 中,找到和 configuration.devicePixelRatio 最接近的倍数对应的路径并返回
    1. 寻找规则是就近规则,和安卓系统的规则相同

这样子,找到了正确分辨率下的图片, AssetBundleImageKey 就赋值完成。

回到 AssetBundleImageProvider._loadAsync 方法中:

data = await key.bundle.load(key.name);

是不是一下就通了呢?

四、总结

今天学到了这么几点:

  1. 实现一个 ImageProvider 很简单,只需要实现 loadobtainKey 方法
  2. 不要再简单地使用 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;
}
/* 看板娘 */