likes
comments
collection
share

也许,用 Flutter 框架获取图片尺寸并没有你想象中那么简单

作者站长头像
站长
· 阅读数 65

前两天看到一篇 Flutter 相关的技术文章,文章分享了一个获取图片尺寸比例的方法,看完之后先是虎躯一震,细品后如梗在喉,不吐不快。本篇文章将从这篇文章所分享的方法出发,来探索在 Flutter 中如何以最优雅的方式来获取图片尺寸。

找茬

废话不多说,直接上原文代码。

为避免拉踩嫌疑,这里就不放原文链接了,在保证逻辑不变的情况下,代码仅做了名称上的些许调整

import 'dart:async' show Completer;
import 'package:flutter/material.dart'
    show Image, ImageConfiguration, ImageStreamListener;

extension GetImageAspectRatio on Image {
  Future<double> getAspectRatio() {
    final completer = Completer<double>();
    image.resolve(const material.ImageConfiguration()).addListener(
      ImageStreamListener(
        (imageInfo, synchronousCall) {
          final aspectRatio = imageInfo.image.width / imageInfo.image.height;
          imageInfo.image.dispose();
          completer.complete(aspectRatio);
        },
      ),
    );
    return completer.future;
  }
}

如上所示,原文作者为 Image Widget 添加了一个 extension,在 extension 中为当前 Widget 实例的 image 属性(类型为:ImageProvider)添加了一个 listener ,类型为 ImageStreamListener 。图片加载完成就会调用 ImageStreamListener 的回调,回调参数便是 ImageInfo 实例,通过此实例便得到最终的尺寸比例。最后通过 Completer 将异步回调转为同步的 Future ,最后将 ImageInfo 内的图片 dispose 掉,一切看起来都是如此的「完美」。

不知道你发现了问题没有,没有的话停可以停下来思考一分钟。

一口大锅

下面重点来了!!!

你肯定知道在 Flutter 中有很多需要手动进行资源释放的类型,否则就会出现内存泄漏。作为开发人员一旦看/用到这些类型,你就应该保持清醒,检查他有没有进行手动释放。

看到 Timer 就应该想到它需要主动 cancel,看到 StreamSubscription 也应该想到它也要主动 cancel,看到 ChangeNotifieraddListener 方法就要检查对应的 removeListener 在哪里。这些应该做为 Flutter 开发的本能反应,肌肉记忆。

原文代码中出现了 addListener ,清醒的你就应该问问自己:它需要 removeListener 吗?

需不需要 removeListener 最直接的方式是看注释!

/// If a duplicate `listener` is registered N times, then it will be called N
/// times when the image stream completes (whether because a new image is
/// available or because an error occurs). Likewise, to remove all instances
/// of the listener, [removeListener] would need to called N times as well.
///
void addListener(ImageStreamListener listener) {
}

注意最后一行,翻译一下:addListener 几次就需要 removeListener 几次,同时多次 addListener 同一个 ImageStreamListener 也会触发多次调用。

好了,现在可以明确:

对于 ImageProvider.resolve 返回的 ImageStream 对象,使用 addListener 添加监听之后如果不再需要监听时必须removeListener

如果不 removeListener 会怎么样?后果会很严重吗?如果你非常清楚 Image Widget 加载流程,直接告诉你结论,应该不难理解。

如果 ImageStream 内的 addListenerremoveListener 在数量上不匹配,解码后的图片数据持有对象 ImageStreamCompleter 会一直保存在内存缓存中( ImageCache._liveImages 内),即便这张图不再显示也不会从内存缓存移除,从而造成严重的内存浪费,图片较大/多时还会引发 OOM

如果你不理解上面的结论,我们先简单回顾一下图片的加载流程,为节省篇幅这里只把简化后的关键代码贴上。

// Image Widget 的 State 对象
class _ImageState extends State<Image> with WidgetsBindingObserver {
  // 在生周期函数内触发 ImageProvider 中的加载逻辑,获取 ImageStream 用于监听图片数据
  void didChangeDependencies() {
    // ...
    final ImageStream newStream =
      provider.resolve(createLocalImageConfiguration(
      context,
      size: widget.width != null && widget.height != null ? Size(widget.width!, widget.height!) : null,
      ));
    //...
  }
}

// 图片文件数据(从网络、磁盘、内存)提供者,负责串起内存缓存查寻、更新流程
abstract class ImageProvider<T extends Object> {
  ImageStream resolve(ImageConfiguration configuration) {
    // 创建 ImageStream
    final ImageStream stream = createStream(configuration);
    // 调用内部函数
    resolveStreamForKey(configuration, stream, key, errorHandler);
    // ...
  }

  // 为 ImageStream 获取与设置 ImageStreamCompleter
  void resolveStreamForKey(ImageConfiguration configuration, ImageStream stream, T key, ImageErrorListener handleError) {
    // ...
    // 尝试从缓存中取 ImageStreamCompleter,缓存中没有则加载它
    final ImageStreamCompleter? completer = PaintingBinding.instance.imageCache.putIfAbsent(
      key,
      () {
        ImageStreamCompleter result = loadImage(key, PaintingBinding.instance.instantiateImageCodecWithSize);
        // ...
        return result;
      },
      onError: handleError,
    );
    // ...
  }    
}

// 内存缓存管理类
class ImageCache {
  
  //  从缓存中取 ImageStreamCompleter,取不到就调用 loader 生成并更新缓存
  ImageStreamCompleter? putIfAbsent(Object key, ImageStreamCompleter Function() loader, { ImageErrorListener? onError }) {
    // ...
    // 缓存中不存在则生成它,并将其添加到 _liveImages 中
    result = loader();
    _trackLiveImage(key, result, null);
    // ...
    return result;
  }

  void _trackLiveImage(Object key, ImageStreamCompleter completer, int? sizeBytes) {
    // 添加到 _liveImages 中
    _liveImages.putIfAbsent(key, () {
      // 用当前 ImageStreamCompleter 构造一个中马甲对象
      return _LiveImage(
        completer,
        () {
          _liveImages.remove(key);
        },
      );
    }).sizeBytes ??= sizeBytes;
  }
}

// 内存缓存中 「存活」 图片的包装类
class _LiveImage extends _CachedImageBase {
  // 构造函数
  _LiveImage(ImageStreamCompleter completer, VoidCallback handleRemove, {int? sizeBytes})
      : super(completer, sizeBytes: sizeBytes) {
    _handleRemove = () {
      handleRemove();
      dispose();
    };
    // 当缓存中的 ImageStreamCompleter 最后一个 listener 被移除时 
    // 当前 ImageStreamCompleter 才会从 _liveImages 里移除
    completer.addOnLastListenerRemovedCallback(_handleRemove);
  }
}

// 图片数据监听对象管理类
abstract class ImageStreamCompleter with Diagnosticable {
  void removeListener(ImageStreamListener listener) {
    // 查找并移除单个 listener 
    for (int i = 0; i < _listeners.length; i += 1) {
      if (_listeners[i] == listener) {
        _listeners.removeAt(i);
        break;
      }
    }
    if (_listeners.isEmpty) { 
      // 移除最后一个 _listeners 时触发所有回调
      // _onLastListenerRemovedCallbacks 通过 addOnLastListenerRemovedCallback 添加
      final List<VoidCallback> callbacks = _onLastListenerRemovedCallbacks.toList();
      for (final VoidCallback callback in callbacks) {
        callback();
      }
      // ...
    }
  }
}

整个 Image 的加载全流程都藏在上面 5 个类里,通过上面的关键代码可以折解出来:

  1. 获取图片数据其实就是获取 ImageStreamCompleter 对象,向 ImageStreamCompleter 添加 listener 就能获得图片数据。而 ImageStream.addListener 还是会调用到 ImageStreamCompleter,所以 ImageStream 只不过是它的马甲

  2. 整个过程始于 Widget 的生命周期开始之后,它通过 ImageProvider 尝试先从缓存(ImageCache)中取 ImageStreamCompleter 对象,缓存中不存在则通过 loadImage 生成它,并将其更新到缓存中

  3. 更新缓存(ImageCache)时会生成一个 _LiveImage 对象用于包装 ImageStreamCompleter 对象,最后添加到 _liveImages 内的是 _LiveImage 对象

  4. _LiveImage 创建时指定了一个从 _liveImages 删除的回调,该回调被添加到了当前的 ImageStreamCompleter 对象内,而 ImageStreamCompleter 触发回调的条件是所有 listener 都被移除

  5. 如果 ImageStreamCompleter 对象因为有一个 listener 没有被移除那回调永远都不会执行,这个 _LiveImage 永远也不会从 _liveImages 中删除,而 _LiveImage 持有 ImageStreamCompleterImageStreamCompleter 持有 ImageInfo,最终导致内存泄漏

从语义上讲,ImageCache 内的 _liveImages 保存的是当前还在显示的图片,当图片开始加载时会添加 listener,不再显示时会移除 listener。从 ImageCache 的视角看只能通过 ImageStreamCompleter 的 listener 是否为空来感知当前图片是否还显示,不显示意味着它不再存活。

等等,一个 ImageStreamCompleter 对象也没有占用很多内存,泄漏影响也没那么大吧?如果你这样想就是 too young too simple。前面提到 ImageStreamCompleter 持有 ImageInfo 对象,它保存的是图片解码后的数据,是解码后的数据哦!!!以一张 4k 图片举列,解码后的内存:384021604 = 31.6 MB。如此,一旦泄漏 OOM 就只是时间问题。

说了这么多你会现,文章开头获取图片比例的代码「仅仅」只是漏写了一行 removeListener。你现在就这知道这行代码造成的影响有多严重了,以后写代码应也不会再忘。

也许,用 Flutter 框架获取图片尺寸并没有你想象中那么简单

但问题真的「仅仅」是这样吗?

细节,细节,还 TM 是细节

原文真的只漏写一行 removeListener 吗?ImageStreamListener 用于监听图片加载结果,结果有成功必定也有失败。图片加载、解码都有可能失败,一旦失败 ImageStreamListener 对象还是会泻漏,只不过失败时没有图片数据,内存浪费相对没有那么严重罢了。

所以就算对原文代码补上了一行 removeListener,那也只处理图片加载成功的场景。还需要对图片加载失败补上一行 removeListener

class ImageStreamListener {
  const ImageStreamListener(
    this.onImage, {
    this.onChunk,
    this.onError, // error 回调的用于处理图片加载失败后的逻辑
  });
}

魔鬼都在细节中,一个不够再来一个!

图片有静态的,也有动态的,静态图片只有一帧,动态图片会有很多帧,GIF 不就是吗?还是 ImageStreamListener,对于动态图片它的 onImage 是会被调用多次的。

  /// Callback for getting notified that an image is available.
  ///
  /// This callback may fire multiple times (e.g. if the [ImageStreamCompleter]
  /// that drives the notifications fires multiple times). An example of such a
  /// case would be an image with multiple frames within it (such as an animated
  /// GIF).
  ///
  final ImageListener onImage;

还记得文原代码中的 Complete 吗?将异步获取图片数据的回调转换 Future,以便后续使用 async await 的同步写法。然而对于 Complete.complete ,它只能被调用一次,否则就是抛一个异常给你好看。

所以原文当中 onImage 被多次调用,内部的 Complete.complete 也会被多次调用,异常就此发生,这个锅你甩都甩不掉了!!!

其实只需要在 complete(value) 前加一个 isCompleted 判断就好。别忘了,成功回调加了,失败回调也要加上😯。

final comp = Completer();

// 遇到 complete 要加 isCompleted,得养成习惯
if (comp.isCompleted) {
  comp.complete(); 
}

另外,不知道你发现没有,获取图片数据的落脚点其实是在 ImageProvider,关我 Image 什么事?你找我 Image 还不是直接丢一个 ImageProvider 给你 ,你要数据找 ImageProvider 不就行了?它才是你的对接人,你 @ 错人了。(经典甩锅名场面)。

所以 extension 的对象应该是 ImageProvider,少一个吃干饭的 Image 中间类型更直接。

最后,原文的返回值是图片尺寸的比例值(AspectRatio),如果换成 Size 对象是不是有更好一点?因为 Size 内部不光同样有aspectRatio 属性,除此之外还有更多对尺寸的操作,使用起来更方便。

力挽狂澜

以上找出了这么多茬,要在生产上用原文的方法来获取图片尺寸怕是会背锅到背麻。现在只能背水一战挽狂澜把这些锅统统甩掉。(原文代码与修改后的代码都贴在一起,方便对比)

import 'dart:async' show Completer;
import 'package:flutter/widgets.dart';

// 修改前
extension GetImageAspectRatio on Image {
  Future<double> getAspectRatio() {
    final completer = Completer<double>();
    image.resolve(const ImageConfiguration()).addListener(
      ImageStreamListener(
        (imageInfo, synchronousCall) {
          final aspectRatio = imageInfo.image.width / imageInfo.image.height;
          imageInfo.image.dispose();
          completer.complete(aspectRatio);
        },
      ),
    );
    return completer.future;
  }
}

// 修改后
extension GetImageAspectRatio on ImageProvider {
  Future<Size?> getImageSize() {
    final completer = Completer<Size?>();
    ImageStream imageStream = resolve(const ImageConfiguration());
    ImageStreamListener? listener;
    listener = ImageStreamListener(
      (imageInfo, synchronousCall) {
        if (!completer.isCompleted) {
          completer.complete(Size(imageInfo.image.width.toDouble(),
           imageInfo.image.height.toDouble()));
        }
        WidgetsBinding.instance.addPostFrameCallback((_){
          imageInfo.dispose();
          imageStream.removeListener(listener!);
        });
      },
      onError: (exception, stackTrace) {
        if (!completer.isCompleted) {
          completer.complete();
        }
        imageStream.removeListener(listener!);
      },
    );
    imageStream.addListener(listener);
    return completer.future;
  }
}

你可能会奇怪为什么要在 addPostFrameCallback 回调内 removeListener,直接除移掉不行吗?addPostFrameCallback 添加的回调会在当前帧绘制结束后被调用,又因为往往获取图片尺寸之后一般紧跟图片显示,所以延迟到帧末尾 removeListener 能提高缓存命中率,减少重复的加载与解码。

梅开二度

还记得原文的初心吗?目的单纯只是想获取图片的尺寸,目前来看确实获取到了,异常也修复了,但总是感觉哪里不对...

隐约记得 PNG 图片尺寸信息可以直接通过图片文件本身得到,一查果然是: PNG 文件结构详解。其它的文件格式也是相同的原理,尺寸信息直接写在文件头内,直接读取即可。

即然图片文件头本身就包含了尺寸信息,上面的方式是不是有点太复杂了。完全没有必要进行图片解码呀,只需读取图片文件头若干个数据就可以得到尺寸信息了。要是解码才能得到图片尺寸数据,对于不需要显示的场景是不是就太浪费了?

// 与解码相关的逻辑(代码有简化)
class MultiFrameImageStreamCompleter extends ImageStreamCompleter {
   // 解码图片后通知所有 lisenter
   Future<void> _decodeNextFrameAndSchedule() async {
    _nextFrame?.image.dispose();
    _nextFrame = await _codec!.getNextFrame(); // 解码
    _emitFrame(ImageInfo( // 通知 lisener
      image: _nextFrame!.image.clone(),
      scale: _scale,
    ));
    _nextFrame!.image.dispose();
    _nextFrame = null;
  }
}

那 Flutter 中有没有提供直接获取尺寸的方案呢?有的,它就是 ImageDescriptor;

/// A descriptor of data that can be turned into an [Image] via a [Codec].
///
/// Use this class to determine the height, width, and byte size of image data
/// before decoding it.
abstract class ImageDescriptor {
  static Future<ImageDescriptor> encoded(ImmutableBuffer buffer) {}
}

注释的开头就说得很清楚了,它能在解码图片之前获取宽高,只需要塞给它文件数据即可。现在问题又转到了如何获取文件的 ImmutableBuffer 对象。

也许,用 Flutter 框架获取图片尺寸并没有你想象中那么简单

前面提到 ImageProvider 负责加载图片数据(从网络、磁盘、内存),并串起内存缓存查寻、更新流程。它本身是抽象类,提供数据的是它的实现类: FileImage,MemoryImage,NetworkImage,AssetImage;除此之外还有自定义的 ImageProvider,如第三方库 cached_network_image 内的 CachedNetworkImageProvider。这些 ImageProvider的实现类可以分为三大类型:本地(AssetImage,FileImage)、内存(MemoryImage)、网络(NetworkImage,CachedNetworkImageProvider);

这里面我们要关心的只有本地、内存两种,因为对于网络类型来讲如果只是想得到尺寸信息直接通过接口查询图片信息是更合理的方案,磁盘中缓存的网络图也可以转换为 FileImage

所有 CDN 与对象存储服务商都提供了图片信息查询接口,接口只返回图片信息不返回图片数据,而图片信息中都包含了宽、高

如果只是本地、内存两个类型的 ImageProvider,如何获取 ImmutableBuffer 就很简单了,直接查看实现类的源码,copy 即可。

另外,做为一个工具类使用覆盖面要尽可能的广,因为场景用千变万化,把是否需要用免解码的方式来获取尺寸的选择权交给使用工具类的开发者是非常重要的。同时原文中的方案也可以作为兜底逻辑,以支持末知类型的 ImageProvider;

以下便是优化过后最终版的获取图片尺寸方案,与原文对比一下差别。

import 'dart:async' show Completer;
import 'dart:ui' show ImmutableBuffer, ImageDescriptor;
import 'package:flutter/widgets.dart';

// 最终版
extension GetImageSize on ImageProvider {
  Future<Size?> getImageSize({bool avoidDecode = false }) async {
    if (avoidDecode) { // 是否要免解码
      final cacheStatus = await obtainCacheStatus(configuration: const ImageConfiguration());
      final tracked = cacheStatus?.tracked ?? false;
      // 内存缓存中存在时优先从缓存中取,不在内存缓存中时转换为 ImageDescriptor
      if (!tracked) { 
        ImmutableBuffer? buffer;
        if (this is AssetBundleImageProvider) {
          final key = await obtainKey(const ImageConfiguration()) as AssetBundleImageKey;
          buffer = await key.bundle.loadBuffer(key.name);
        } else if (this is FileImage) {
          final file = (this as FileImage).file;
          final int lengthInBytes = await file.length();
          if (lengthInBytes > 0) {
            buffer = await ImmutableBuffer.fromFilePath(file.path);
          }
        } else if (this is MemoryImage) {
          final bytes = (this as MemoryImage).bytes;
          buffer = await ImmutableBuffer.fromUint8List(bytes);
        }
        if (buffer != null) {
          final descriptor = await ImageDescriptor.encoded(buffer);
          final size = Size(descriptor.width.toDouble(), descriptor.height.toDouble());
          buffer.dispose();
          descriptor.dispose();
          if (!size.isEmpty) return size;
        }
      }
    }
    
    // 免解码末开启或内存缓存已存在,最后再兜底上面未处理的情况
    final completer = Completer<Size?>();
    ImageStream imageStream = resolve(const ImageConfiguration());
    
    ImageStreamListener? listener;
    listener = ImageStreamListener(
      (imageInfo, synchronousCall) {
        if (!completer.isCompleted) {
          completer.complete(Size(imageInfo.image.width.toDouble(),imageInfo.image.height.toDouble()));
        }
        WidgetsBinding.instance.addPostFrameCallback((_){
          imageInfo.dispose();
          imageStream.removeListener(listener!);
        });
      },
      onError: (exception, stackTrace) {
        if (!completer.isCompleted) {
          completer.complete();
        }
        imageStream.removeListener(listener!);
      },
    );
    imageStream.addListener(listener);

    return completer.future;
  }
}

使用姿势:

void main() {
  // 本地资源
  const AssetImage('assets/images/4k.jpg').getImageSize(avoidDecode: true).then((s){
    print('the size is $s');
  });

  // 磁盘文件
  final imageFile = File('the/disk/image/path');
  FileImage(imageFile).getImageSize(avoidDecode: true).then((s){
    print('the size is $s');
  });
  
  // 内存数据
  Uint8List pngFileData = Uint8List.fromList([/*...*/]);
  MemoryImage(pngFileData).getImageSize(avoidDecode: true).then((s){
      print('the size is $s');
  });

  // 第三方库网络图,图片会被解码并缓存于内存
  CachedNetworkImageProvider('https://').getImageSize().then((s){
    print('the size is $s');
  });
}

好自为之

好了,本文其实是一个代码改造项目,虽然只是一个简单的功能,但需要对图片的加载流程要有比较清晰的认知,它背后要思考的东西也还挺多,一个不小心就会踩到坑里,拉都拉不起来那种。所以各位在改动或使用 Flutter 的图片相关代码时需要格外小心,切莫因小失大!

转载自:https://juejin.cn/post/7377578894591246346
评论
请登录