BitmapPool 了解吗?Glide 是如何实现 Bitmap 复用的?

4,241 阅读7分钟

这个问题实际上是我前几天面试时遇到的一个问题,虽然我之前分析过 Glide 的源码,但是老实说,如果不是面试遇到这类问题,我根本不会留意 Glide 的 Bitmap 复用这块...不管怎么说,遇到了这个问题,我们就来看下 Glide 是如何实现 Bitmap 复用的吧~

1、“池化”以及对象复用

其实,说起“池化”以及对象复用,在 Android 中例子还是有这么几个的。典型的比如 Handler 中的 Message. 当我们使用 Message 的 obtain 获取消息的受,实际上是从 Message 池中获取的。Handler 中的 Message 是通过链表维护的数据结构,以此来构成一个 “Message 池”。这个池的最大数量由 MAX_POOL_SIZE 这个参数指定,即为 50.

那么,“池化”以及对象复用有什么好处呢?

这是因为对于 Message 这类频繁使用的对象,如果每次使用的时候直接创建一个对象,那么可能会因频繁创建和销毁导致虚拟机 GC,从而造成页面卡顿现象,尤其是在低端设备上面。“池化”之后每次从池子中获取已经创建的对象进行复用,从而避免了虚拟机频繁 GC.

对于 Bitmap 这类对象和图片相关、占用内存较大的对象,如果频繁创建和销毁,对虚拟机的影响可能比 Message 要大得多,因此 Bitmap 复用显得非常重要。

2、从 Bitmap 的回收说起

先看下 Bitmap 是如何进行回收的吧。

根据官方的建议,在 Android 2.3 及以下的版本中建议使用 recycle() 回收内存,防止 OOM. 但是,使用这个方法的前提是需要确保这个位图不再被使用,否则回收之后再使用将会导致运行时错误。所以,官方的建议是通过引用计数的方式统计位图的引用,只有当位图不再被引用的时候再真正调用该方法进行回收。

官方文档参考:developer.android.com/topic/perfo…

在 Android 3.0 上面引入了 BitmapFactory.Options.inBitmap 字段。如果设置了此选项,那么采用 Options 对象的解码方法会在加载内容时尝试重复使用现有位图。这样可以复用现有的 Bitmap,减少对象创建,从而减少发生 GC 的概率。不过,inBitmap 的使用方式存在某些限制。特别是在 Android 4.4(API 级别 19)之前,系统仅支持大小相同的位图。在 Android 4.4 之后的版本,只要内存大小不小于需求的 Bitmap 都可以复用。

所以,当我们需要在 Android 中使用 Bitmap 的时候,应该考虑进行 Bitmap 复用以提升应用性能。但是,这些复杂的逻辑要如何封装呢?官方的建议是使用比较成熟的图片加载框架,比如 Glide. 所以,接下来我们来分析下 Glide 是如何实现 Bitmap 复用的。

3、Glide 的 BitmapPool

我们直接从 Glide 的 BitmapPool 开始分析。BitmapPool 是一个接口,定义如下:

public interface BitmapPool {
  long getMaxSize();
  void setSizeMultiplier(float sizeMultiplier);
  // 往 pool 中插入 bitmap 以备复用
  void put(Bitmap bitmap);
  // 从 pool 中获取 bitmap 以复用
  @NonNull Bitmap get(int width, int height, Bitmap.Config config);
  @NonNull Bitmap getDirty(int width, int height, Bitmap.Config config);
  void clearMemory();
  void trimMemory(int level);
}

BitmapPool 通过定义一个 Pool 来让用户复用 Bitmap 对象。在 Glide 中,BitmapPool 有一个默认的实现 LruBitmapPool. 顾名思义,也是基于 LRU 的理念设计的。

前面我们提到过 inBitmap 以 Android 4.4 为分水岭,之前和之后的版本在使用上存在版本差异,那么 BitmapPool 是如何处理这个差异的呢?答案是策略模式。Glide 定义了 LruPoolStrategy 接口,该接口内部定义了增删相关操作。真实的 Bitmap 数据根据尺寸和颜色等映射关系存储到 LruPoolStrategy 中。BitmapPool 的 get 和 put 也是通过 LruPoolStrategy 的 get 和 put 完成的。

interface LruPoolStrategy {
  void put(Bitmap bitmap);
  @Nullable Bitmap get(int width, int height, Bitmap.Config config);
  @Nullable Bitmap removeLast();
  String logBitmap(Bitmap bitmap);
  String logBitmap(int width, int height, Bitmap.Config config);
  int getSize(Bitmap bitmap);
}

LruPoolStrategy 默认提供了三个实现,分别是 AttributeStrategySizeConfigStrategySizeStrategy. 其中,AttributeStrategy 适用于 Android 4.4 以下的版本,SizeConfigStrategy 和 SizeStrategy 适用于 Android 4.4 及以上的版本。

AttributeStrategy 通过 Bitmap 的 width(图片宽度)、height(图片高度) 和 config(图片颜色空间,比如 ARGB_8888 等) 三个参数作为 Bitmap 的唯一标识。当获取 Bitmap 的时候只有这三个条件完全匹配才行。而 SizeConfigStrategy 使用 size(图片的像素总数) 和 config 作为唯一标识。当获取的时候会先找出 cofig 匹配的 Bitmap(一般就是 config 相同),然后保证该 Bitmap 的 size 大于我们期望的 size 并且小于期望 size 的 8 倍即可复用(可能是为了节省内存空间)。

所谓的 LRU 就是 BitmapPool 通过 LruPoolStrategy 实现的,具体操作是,在往 BitmapPool 中 put 数据之后会执行下面的操作调整空间大小:

private synchronized void trimToSize(long size) {
    while (currentSize > size) {
        // 移除尾部的
        final Bitmap removed = strategy.removeLast();
        if (removed == null) {
            currentSize = 0;
            return;
        }
        currentSize -= strategy.getSize(removed);
        // ...
        // 回收
        removed.recycle();
    }
}

4、Bitmap 加载和复用

下面我们来复习下一般的 Bitmap 加载的步骤。常规的图片加载过程如下,

// 设置 inJustDecodeBounds 为 true 来获取图片尺寸
BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeFile(file.getAbsolutePath(), options);

// 设置 inJustDecodeBounds 为 false 来真正加载
options.inSampleSize = calculateInSampleSize(options, maxWidth, maxHeight);
options.inJustDecodeBounds = false;
BitmapFactory.decodeFile(file.getAbsolutePath(), options);

也就是说,首先通过设置 options.inJustDecodeBounds 为 true 来获取图片真实的尺寸,以便设置采样率。因为我们一般不会直接加载图片的所有的像素,而是采样之后再按需加载,以减少图片的内存占用。当真正需要加载的时候,设置 options.inJustDecodeBounds 为 false,再调用 decode 相关的方法即可。

那么 Bitmap 复用是如何使用的呢?很简单,只需要在加载的时候通过 options 的 inBitmap 参数指定一个 Bitmap 对象再 decode 即可:

options.inBitmap = bitmapPool.getDirty(width, height, expectedConfig);

5、Glide 是如何加载 Bitmap 的

之前分析 Glide 的源码的时候,注重的是整个流程,对于很多细节没用照顾到,这里我简化下逻辑。首先,Glide 的 Bitmap 加载流程位于 Downsampler 类中。当从其他渠道,比如网络或者磁盘中获取到一个输入流 InputStream 之后就可以进行图片加载了。下面是 Downsampler 的 decodeFromWrappedStreams 方法,这里是执行图片加载的流程,主要代码的逻辑和功能已经备注到了注释上面:

  private Bitmap decodeFromWrappedStreams(InputStream is,
      BitmapFactory.Options options, DownsampleStrategy downsampleStrategy,
      DecodeFormat decodeFormat, ...) throws IOException {
    long startTime = LogTime.getLogTime();
    // 通过设置 inJustDecodeBounds 读取图片的原始尺寸信息
    int[] sourceDimensions = getDimensions(is, options, callbacks, bitmapPool);
    int sourceWidth = sourceDimensions[0];
    int sourceHeight = sourceDimensions[1];
    String sourceMimeType = options.outMimeType;

    // ...

    // 读取图片的 exif 信息,如果需要的话,先对图片进行旋转
    int orientation = ImageHeaderParserUtils.getOrientation(parsers, is, byteArrayPool);
    int degreesToRotate = TransformationUtils.getExifOrientationDegrees(orientation);
    boolean isExifOrientationRequired = TransformationUtils.isExifOrientationRequired(orientation);
    int targetWidth = requestedWidth == Target.SIZE_ORIGINAL ? sourceWidth : requestedWidth;
    int targetHeight = requestedHeight == Target.SIZE_ORIGINAL ? sourceHeight : requestedHeight;

    ImageType imageType = ImageHeaderParserUtils.getType(parsers, is, byteArrayPool);

    // 根据要求计算需要记载的图片大小和 config,计算结果直接设置给 options 即可
    calculateScaling(imageType, is, ..., options);
    calculateConfig(is, ..., options, targetWidth, targetHeight);

    boolean isKitKatOrGreater = Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT;
    if ((options.inSampleSize == 1 || isKitKatOrGreater) && shouldUsePool(imageType)) {
      // ...
      // 根据图片的期望尺寸到 BitmapPool 中获取一个 Bitmap 以复用
      if (expectedWidth > 0 && expectedHeight > 0) {
        setInBitmap(options, bitmapPool, expectedWidth, expectedHeight);
      }
    }
    // 开始执行 decode 逻辑
    Bitmap downsampled = decodeStream(is, options, callbacks, bitmapPool);
    callbacks.onDecodeComplete(bitmapPool, downsampled);

    // ... 图片旋转等后续逻辑

    return rotated;
  }

上述代码中的 setInBitmap 方法中即调用了 BitmapPool 的 get 方法用来获取复用的 Bitmap 对象,其代码如下:

  private static void setInBitmap(
      BitmapFactory.Options options, BitmapPool bitmapPool, int width, int height) {
    @Nullable Bitmap.Config expectedConfig = null;
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
      if (options.inPreferredConfig == Config.HARDWARE) {
        return;
      }
      expectedConfig = options.outConfig;
    }
    if (expectedConfig == null) {
      expectedConfig = options.inPreferredConfig;
    }
    // 调用了 inBitmap
    options.inBitmap = bitmapPool.getDirty(width, height, expectedConfig);
  }

另外,通过查看 Bitmap 的 inBitmap 文档注释,我们可以看到可能存在一些情况导致 inBitmap 过程中出现异常,那么 Glide 会不会因为复用 Bitmap 而导致加载过程异常?Glide 又是如何进行处理的呢?参考上述代码,我们可以看到加载图片调用到了名为 decodeStream 方法。该方法经过我的简化之后大致如下:

  private static Bitmap decodeStream(InputStream is, BitmapFactory.Options options,
      DecodeCallbacks callbacks, BitmapPool bitmapPool) throws IOException {
    // ...
    final Bitmap result;
    TransformationUtils.getBitmapDrawableLock().lock();
    try {
      // 数据加载
      result = BitmapFactory.decodeStream(is, null, options);
    } catch (IllegalArgumentException e) {
      // ...
      if (options.inBitmap != null) {
        try {
          // 输入流重置
          is.reset();
          bitmapPool.put(options.inBitmap);
          // 清理掉 inBitmap 并进行第二次加载
          options.inBitmap = null;
          // 再次调用进行加载
          return decodeStream(is, options, callbacks, bitmapPool);
        } catch (IOException resetException) {
          throw bitmapAssertionException;
        }
      }
      throw bitmapAssertionException;
    } finally {
      TransformationUtils.getBitmapDrawableLock().unlock();
    }
    if (options.inJustDecodeBounds) {
      is.reset();
    }
    return result;
  }

也就是说,Glide 首先会通过设置 inBitmap 复用的方式加载图片。如果这个过程中出现了异常,因为此时 inBitmap 不为空,所以将会进入异常处理流程,此时会清理掉 inBitmap,再次调用 decodeStream 方法二次加载,这个时候就不是 Bitmap 复用的了。所以,Glide 内部会通过错误重试机制进行 Bitmap 复用,当复用并出现错误的时候,会降级为非复用的方式第二次进行加载。

总结

以上就是 Glide 中 Bitmap 复用的原理,希望本文对你有所帮助!