收拢图片,可以优化内存避免 OOM,但是收拢不是说说而已!(以Glide举例)

题图 by @rayyu

一. 序

图片一直是 App 中吃内存的大户,当我们做内存优化的时候,永远也绕不开对图片内存的优化。可能你很多其他方案一起上,最后还不如对 Bitmap 进行常规优化来的有效。

对图片的优化前提是对图片操作的收拢,这样我们才可以做整体的策略控制。例如对于一些低端设备,我们可以将图片格式从 ARGB_8888 变为 RGB_565,这样一个简单的调整,可以让图片内存的占用减少一半;又例如在适当的时机,主动回收掉一些图片缓存,避免被 Low Memory Kiiler 盯上。

但是这一切的前提,就是我们要收拢对图片的操作。通常我们会使用一些开源的图片库,来简化对图片的操作,例如 Glide、Fresco 或者其他一些自研的图片加载库。

我们当然不会在一个项目中重复集成多个图片加载库,但是很多时候我们会忽略掉一些 Android 下原生操作 Bitmap 的 API,例如 Bitmap.createBitmap() 、BitmapFactory 等。

这些系统提供的 API,也是我们收拢图片操作时需要注意的,否者必然有一些图片不是受约束的。那么接下来,我们就以最常用的 Glide 来举例,看看如何替换掉 Bitmap.createBitmap() 和 BitmapFactory 的相关操作,来收紧对这些 API 的操作。

ps: 本文内容,以 Glide v4.11.0 举例。

二. 收拢哪些 Bitmap 操作

2.1 替换 createBitmap()

Bitmap.createBitmap() 方法,从名字上就可以看出,它是为了创建一个 Bitmap 对象,这在我们做一些图片变换绘制时,经常会用到。而想要利用 Glide 的来优化此步骤,就需要用到 BitmapPool。

BitmapPool 本身是一个接口,我们通常会使用到它的实现类 LruBitmapPool,从名称就可以看出,它基于 LRU 的规则,在一定的内存限制下,缓存和管理一些可供重用的 Bitmap 对象。

接下来我们看看具体的使用。

1. 使用 BitmapPool

在 Glide 中,BitmapPool 渗透到逻辑代码的方方面面。我们想要拿到 BitmapPool 对象也非常的简单,只需要使用 getBitmapPool() 方法即可。

既然是一个池化的方案,那么肯定会有对应的 get()put() 方法。

val bitmapPool = Glide.get(this).bitmapPool
val bitmap = bitmapPool.get(100,100,Bitmap.Config.ARGB_8888)
// 处理 → 使用 bitmap
// ......

// 用完回收 bitmap
bitmapPool.put(bitmap)

没什么特殊的操作,只是将 Bitmap.createBitmap() 方法,替换成 bitmapPool.get() 方法,在使用完成后,再调用 put() 方法回收图片。

2. bitmapPool.get() 都做了什么?

为什么使用 bitmapPool.get() 替换掉 createBitmap() 就可以达到对图片内存的优化呢?

要知道所有的池化技术,都是基于享元模式,将一些比较重要的资源,最大限度的进行缓存,并以期待下一次的使用时可以直接复用。

所以实际上, bitmapPool.get() 并没有那么神奇的,它只是先从缓存池中找是否有对应可用的 Bitmap 资源,有就重用,没有时依然需要调用 Bitmap.createBitmap() 去创建一个图片。

// LruBitmapPool.java
public Bitmap get(int width, int height, Bitmap.Config config) {
  Bitmap result = getDirtyOrNull(width, height, config);
  if (result != null) {
    // 擦除"脏像素"
    result.eraseColor(Color.TRANSPARENT);
  } else {
    // 通过 Bitmap.createBitmap() 创建图片
    result = createBitmap(width, height, config);
  }
  return result;
}

这里会先尝试通过 getDirtyOrNull() 获取缓存池的 Bitmap 资源,如果没有可用的资源,依然是调用 createBitmap() 去构造一个新的 Bitmap 对象。

如果 getDirtyOrNull() 找到了可复用的 Bitmap 资源,则会调用 eraseColor() 方法,将 Bitmap 的"脏像素"进行擦除,以避免旧图对新图的影响。

3. BitmapPool 如何缓存 Bitmap?

一个图片资源,加载到内存中之后,其实是包含两部分内存占用的,一个是 Bitmap 对象引用,还有一部分是图片的像素数据,在 Android 不同版本的迭代过程中,图片的像素数据存放的位置是挪了又挪。

但是不管像素数据最终放在哪里,其实占用内存的大头一直的都是图片的像素数据,而像素数据占用的空间,又受到图片资源的像素尺寸以及单像素的占用的内存尺寸。

例如一个 ARGB_8888 的图片,它像素数据占用内存的计算方式:

BitmapRam = BitmapWidth * BitmapHeight * 4 bytes

其中 4 Bytes 就是 ARGB_8888 单像素占用的内存。

在 BitmapPool 中,也是基于这 3 个条件来唯一定位一个可用的图片资源,反映到代码中,就是图片的 width、height 以及 Bitmap.Config。

在 BitmapPool 中有一个 strategy 对象,它是一个 LruPoolStrategy 类型,这是一个接口,我们通常会用到它的实现类 SizeConfigStrategy。

// LruBitmapPool.java
private synchronized Bitmap getDirtyOrNull(
  int width, int height, @Nullable Bitmap.Config config) {
  final Bitmap result = strategy.get(width, height, config != null ? config : DEFAULT_CONFIG);
  // ...
  return result;
}

继续看看 SizeConfigStrategy,在其中维护了一个 groupedMap 结构,它的类型是 GroupedLinkedMap,我们可以将它简单的理解为一个 Key-Value 的键值对,同时它也实现了 LRU 算法。

// GroupedLinkedMap.java
public Bitmap get(int width, int height, Bitmap.Config config) {
  int size = Util.getBitmapByteSize(width, height, config);
  Key bestKey = findBestKey(size, config);

  Bitmap result = groupedMap.get(bestKey);
  // ...
  return result;
}

这里的 get() 方式,就是通过 width、height、config 找到一个对应的 Key,再从 groupMap 中基于此 Key 获取到缓存池中的图片。

4. BitmapPool 如何回收资源

在 Bitmap 使用完之后,我们还需要将其进行回收,回收资源就是调用 LruBitmapPool 的 put() 方法。

public synchronized void put(Bitmap bitmap) {
    // 验证 Bitmap 有效性代码,省略
  if (!bitmap.isMutable() || strategy.getSize(bitmap) > maxSize
      || !allowedConfigs.contains(bitmap.getConfig())) {
    // 不符合缓存条件,直接 recycle()
    bitmap.recycle();
    return;
  }

  final int size = strategy.getSize(bitmap);
  strategy.put(bitmap);
  // Other code ...
}

put() 方法中,会对待回收的 Bitmap 做一个基本的校验,例如是一个可变的 Bitmap;尺寸必须不能大于 maxSize 等。如果条件不满足,直接将图片回收( recycle )。

满足这些前置条件之后,会将其放入 strategy 进行缓存,这就是前面 get() 方法从缓存池中获取图片操作的数据结构,就不再赘述了。

5. 查缺补漏

前面也提到 BitmapPool 并没有什么神奇的,如果资源池中没有需要的 Bitmap,它依然会通过 createBitmap() 构造一个新的 Bitmap 对象。

但是在 Glide 的整个逻辑中,大量的使用到了 BitmapPool,所以可能你需要的 Bitmap 对象,之前被其他逻辑使用并回收。例如在 Glide 的 BitmapResource 中, recycle() 回收的逻辑,就是直接将图片尝试放入 BitmapPool 中。

// BitmapResource.java
public class BitmapResource implements Resource<Bitmap>,
    Initializable {
  @Override
  public void recycle() {
    bitmapPool.put(bitmap);
  }
}

退一步说,就算我们使用的 Bitmap 不在资源池中,我们只需要使用后,通过 put() 方法将其回收到资源池中,下次依然可以复用。

图片是一个占内存的大头,频繁的构造小尺寸 Bitmap,多数情况下是不会直接造成 OOM,但是可能会造成频繁的 GC,表现出来就是内存的抖动,这在 Dalvik 虚拟机上尤其明显。虽然在 ART 虚拟机上,对 GC 已经做了一些优化,但是资源的复用依然是一种提高效率的手段。

同时 BitmapPool 本身也会根据 onTrimMemory() 回调,来处理缓存的 Bitmap 的清理逻辑,这无需我们开发者再关心其回收的规则。
public void trimMemory(int level) {
  if (Log.isLoggable(TAG, Log.DEBUG)) {
    Log.d(TAG, "trimMemory, level=" + level);
  }
  if (level >= android.content.ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) {
    clearMemory();
  } else if (level >= android.content.ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN
             || level == android.content.ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL) {
    trimToSize(getMaxSize() / 2);
  }
}

另外,对 Bitmap 资源需要谨慎回收,一定要确保这个图片资源不被使用,再将其进行回收,否则会出现一些异常情况。

其实很好理解,BitmapPool 的 put() 回收资源时,可能两种操作, bitmap.recycle() 或者将其放入 BitmapPool 以待后续使用。

那么如果一个外部的 View 还在使用的 Bitmap 被 BitmapPool 回收,可能会出现 Cannot draw a recycled Bitmap 错误;还有个场景是 BitmapPool 持有了一个外部被回收的 Bitmap 后,下次使用时,会出现 Can't call reconfigure() on a recycled bitmap 错误。

这些都是直接的错误,如果图片没有被回收( recycle ),而是被重用了,也可能会导致外部某个 View 展示的图像,被刷新了,这虽然不会直接抛异常,但是依然是一个逻辑错误。

所以谨记,在通过 BitmapPool 回收图片资源时,一定要确保外部没有使用此 Bitmap 的地方,最好立即切断其引用,避免不必要的错误。

2.2 替换 BitmapFactory

说完 Bitmap.createBitmap() 再就是到了 BitmapFactory 了,它的替换比较简单。

BitmapFactory 最主要的功能,就是利用 decodeXxx() 方法,通过不同的源来加载 Bitmap 资源。

而在 Glide 中,我们使用最多的就是从某个源中加载图片,并直接显示在 ImageView 上。

Glide.with(fragment)
    .load(url)
    .into(imageView);

如果想通过 Glide 直接加载图片,并获得 Bitmap 对象,需要用到 asXxx() 的方法和 Target,我们接下来就来看看,从不同的源加载 Bitmap 的情况,以及同步和异步的区别。

1. 同步加载 Bitmap 对象

有时我们需要在子线程中获取 Bitmap 对象,就需要同步获取的方式。

val bitmap = Glide.with(activity)
    .asBitmap()
    .load(imageUrl)
    .submit().get()

借助 asBitmap()submit().get() 就可以从某个源中,直接获得 Bitmap 对象。

submit() 还可以约束加载图片的尺寸,方便我们处理。

FutureTarget<TranscodeType> submit()
FutureTarget<TranscodeType> submit(int width, int height)

2. 异步加载 Bitmap 对象

Glide 也支持异步加载 Bitmap,异步加载,就涉及到线程的切换问题。

Glide.with(activity)
    .asBitmap()
    .load(imageUrl)
    .into(object:CustomTarget<Bitmap>(){
      override fun onLoadCleared(placeholder: Drawable?) {
      }

      override fun onResourceReady(resource: Bitmap, 
                                   transition: Transition<in Bitmap>?) {
        val loadBitmap = resource
      }
    })

异步加载,需要用到 Target,这里直接使用 Glide 提供的 CustomTarget。

3. 加载图片的 File

我们知道用 Glide 加载的图片,在缓存容量允许的范围内,Glide 都会帮我们将图片文件缓存到本地磁盘。

那么我们如何通过 Glide 加载一个图片资源,然后获得缓存的图片文件呢?

其实只需要将上面的 asBitmap() 换成 asFile() 即可。

// sync
val bitmapFile = Glide.with(this)
    .asFile()
    .load(imageUrl)
    .submit().get()

// async
Glide.with(this)
    .asFile()
    .load(imageUrl)
    .into(object:CustomTarget<File>(){
      override fun onLoadCleared(placeholder: Drawable?) {
      }

      override fun onResourceReady(resource: File, transition: 
                                   Transition<in File>?) {
          val bitmapFile = resource
      }
    })

除了 asBitmap()asFile() ,还有一些其他的方法,例如 asDrawable() 等,有兴趣可以自行了解。

4. 查缺补漏

Glide 的 load() 方法,本身就支持很多图片资源的加载,我们只需要使用标准的 API 即可。相比于 BitmapFacory 的源来说,还有 InputStream 这个是 Glide 没有支持的。

这也很好理解,既然是一个 Stream,那么它前身肯定是一个本地的文件或者一个网络数据流,最终体现出来就是一个 File 或者一个 Uri,这些都是 Glide 支持的。

如果实在对 InputStream 的输入有要求,可以自行实现 Glide 的 ModelLoader。

参考: https://muyangmin.github.io/glide-docs-cn/tut/custom-modelloader.html

三. 小结

今天我们强化了在 Android 中,收拢图片调用的概念,不仅仅是限制一个项目中只使用一个图片加载库,而是要对一些系统 Api 进行收拢,例如 BitmapFactory 和 Bitmap.createBitmap() 等。

  • 对于 BitmapFactory,我们只需要利用 asBitmap()/asFile() 配合 submit()/CustomTarget 就可以替换。

  • 对于 Bitmap.createBitmap() 则需要用到 Glide 的 BitmapPool 即可,用 bitmapPool.get() 替换 createBitmap() ,使用完成后通过 bitmap.put() 将图片回收。另外我们还聊了 BitmapPool 的部分逻辑,让我们使用的更放心。

今天就到这里,本文对你有帮助吗? 留言、转发、点好看 是最大的支持,谢谢!

公众号后台回复成长『 成长 』,将会得到我准备的学习资料。

我来评几句
登录后评论

已发表评论数()

相关站点

+订阅
热门文章