Bitmap版本演变&Glide对Bitmap的处理

Bitmap版本演变

Bitmap的处理是Android开发过程中无法避开的一项,也是内存占用的大户,常见的内存占用优化都涉及到Bitmap。

Android SDK各版本也一直在对Bitmap进行优化:

    Android3.0以前Bitmap像素存在Native区,生命周期不可控,需手动回收bitmap.recycle()

    Android3.0-8.0 Bitmap像素存在Java堆,虚拟机自动回收,但容易OOM

    Android8.0及以后Bitmap像素存在Native区域,配合NativeAllocationRegistry机制+系统支持,虚拟机自动回收

为什么会有这种变化?首先Bitmap对象占用内存很小,主要是像素信息占用内存很大。

Bitmap在3.0以前Native区的回收是依赖Java虚拟机的GC来回收的。Bitmap在Java堆上的内存占用很小,假如应用增加了很多图片,在这个过程中Java堆上的内存上涨是不明显的;在没有内存增长触发GC时,虚拟机自身的回收周期很漫长,这时就会出现系统内存已经不够用了,而Java堆依然无法触发GC,直到系统内存OOM。以前的手机大家可能遇到过应用无提示闪退、桌面崩溃、手机自动关机的情况,这大都是系统内存OOM造成的,系统内存OOM时,会杀后台进程,杀服务(部分厂商定制有问题导致手机关机),杀前台进程直到内存释放出来。所以3.0以前在不使用Bitmap时,需要我们手动调用recycle()释放Native内存。

但手动释放内存不符合JAVA一直宣扬的把程序员的双手从内存管理中解脱出来的气质啊。所以3.0版本把像素信息放到Java堆上了,这样就可以触发Java虚拟机的GC机制,不需要程序员手动调用recycle()释放,但是也更容易造成应用OOM了。我们知道虚拟机的内存使用上限都不大,3.0-8.0版本期间的手机虚拟机上限一般都是192M、256M。假如应用加载几张过大的图片或者图片引用有问题,直接就会导致OOM。

面对手机越来越大的内存配置,如果还用虚拟机内存限制应用开发,势必是争夺不过iOS的。所以8.0版本开始Google像iOS一样,把内存占用较大的像素信息重新放到Native区,配合NativeAllocationRegistry机制+系统支持做到自动回收。NativeAllocationRegistry机制在7.0版本已经引入了,但是系统不支持,所以8.0版本才把像素信息放到Native区。

还有一点是recycle()调用还有没有必要?如果图片确认不使用的话,还是有必要调用recycle()的,不管哪个系统版本,recycle()调用都会直接回收Native内存。

    Android3.0以前版本不用说必须调用

    Android3.0-8.0版本虽然像素信息放到Java堆中,但是Bitmap创建是在native层解码的,还是有少量内存占用在Native层

    Android8.0及以后版本系统对Bitmap的内存占用做了优化监听,但还是依赖GC来回收的,而GC是非实时回收的

另外一个变化比较大的是Bitmap的内存复用

    Android4.4之前版本内存复用要求width=width&&height=height&&inSampleSize = 1

    Android4.4及之后版本只要被复用bitmap内存大于所需内存即可

这里内存复用有个需要注意的地方,内存复用的是bitmap整块内存,而不是所需要的内存。

比如当前图片大小是1M,复用的Bitmap内存大小是4M,复用这块内存之后,当前图片所占内存为4M,只要当前图片没被释放,所占用的4M内存是无法被释放的。所以复用内存时要找大小相当的内存使用。

Glide对Bitmap的处理

Glide非常强大,基本上我们能做的图片优化,Glide默认都帮我们做了。

关于Glide对图片缓存的处理网上有很多文章。这里只说一下内存优化相关的三块:Bitmap自动适配Imageview、内存复用和Bitmap解码的处理。以Glide:4.9.0版本作为源码分析,因为Glide的框架很庞大,调用链很长(看过Glide源码的应该知道),就不贴调用链代码了,只把关键代码贴出来。

Glide中Bitmap的自动适配Imageview。

// 计算缩小比例是否大于2或者2的倍数,如果大于2或者2的倍数先设置inSampleSize属性
 
int outWidth = round(exactScaleFactor * sourceWidth);
int outHeight = round(exactScaleFactor * sourceHeight);
 
int widthScaleFactor = sourceWidth / outWidth;
int heightScaleFactor = sourceHeight / outHeight;
 
int scaleFactor = rounding == SampleSizeRounding.MEMORY
    ? Math.max(widthScaleFactor, heightScaleFactor)
    : Math.min(widthScaleFactor, heightScaleFactor);
 
int powerOfTwoSampleSize;
// BitmapFactory does not support downsampling wbmp files on platforms <= M. See b/27305903.
if (Build.VERSION.SDK_INT <= 23
    && NO_DOWNSAMPLE_PRE_N_MIME_TYPES.contains(options.outMimeType)) {
  powerOfTwoSampleSize = 1;
} else {
  powerOfTwoSampleSize = Math.max(1, Integer.highestOneBit(scaleFactor));
  if (rounding == SampleSizeRounding.MEMORY
      && powerOfTwoSampleSize < (1.f / exactScaleFactor)) {
    powerOfTwoSampleSize = powerOfTwoSampleSize << 1;
  }
}
options.inSampleSize = powerOfTwoSampleSize;
// 如果缩小2的倍数后仍需要缩小,利用inDensity和inTargetDensity继续缩小
 
int powerOfTwoWidth;
int powerOfTwoHeight;
if (imageType == ImageType.JPEG) {
  // libjpegturbo can downsample up to a sample size of 8. libjpegturbo uses ceiling to round.
  // After libjpegturbo's native rounding, skia does a secondary scale using floor
  // (integer division). Here we replicate that logic.
  int nativeScaling = Math.min(powerOfTwoSampleSize, 8);
  powerOfTwoWidth = (int) Math.ceil(sourceWidth / (float) nativeScaling);
  powerOfTwoHeight = (int) Math.ceil(sourceHeight / (float) nativeScaling);
  int secondaryScaling = powerOfTwoSampleSize / 8;
  if (secondaryScaling > 0) {
    powerOfTwoWidth = powerOfTwoWidth / secondaryScaling;
    powerOfTwoHeight = powerOfTwoHeight / secondaryScaling;
  }
} else if (imageType == ImageType.PNG || imageType == ImageType.PNG_A) {
  powerOfTwoWidth = (int) Math.floor(sourceWidth / (float) powerOfTwoSampleSize);
  powerOfTwoHeight = (int) Math.floor(sourceHeight / (float) powerOfTwoSampleSize);
} else if (imageType == ImageType.WEBP || imageType == ImageType.WEBP_A) {
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
    powerOfTwoWidth = Math.round(sourceWidth / (float) powerOfTwoSampleSize);
    powerOfTwoHeight = Math.round(sourceHeight / (float) powerOfTwoSampleSize);
  } else {
    powerOfTwoWidth = (int) Math.floor(sourceWidth / (float) powerOfTwoSampleSize);
    powerOfTwoHeight = (int) Math.floor(sourceHeight / (float) powerOfTwoSampleSize);
  }
} else if (
    sourceWidth % powerOfTwoSampleSize != 0 || sourceHeight % powerOfTwoSampleSize != 0) {
  // If we're not confident the image is in one of our types, fall back to checking the
  // dimensions again. inJustDecodeBounds decodes do obey inSampleSize.
  int[] dimensions = getDimensions(is, options, decodeCallbacks, bitmapPool);
  // Power of two downsampling in BitmapFactory uses a variety of random factors to determine
  // rounding that we can't reliably replicate for all image formats. Use ceiling here to make
  // sure that we at least provide a Bitmap that's large enough to fit the content we're going
  // to load.
  powerOfTwoWidth = dimensions[0];
  powerOfTwoHeight = dimensions[1];
} else {
  powerOfTwoWidth = sourceWidth / powerOfTwoSampleSize;
  powerOfTwoHeight = sourceHeight / powerOfTwoSampleSize;
}
 
double adjustedScaleFactor = downsampleStrategy.getScaleFactor(
    powerOfTwoWidth, powerOfTwoHeight, targetWidth, targetHeight);
 
// Density scaling is only supported if inBitmap is null prior to KitKat. Avoid setting
// densities here so we calculate the final Bitmap size correctly.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
  options.inTargetDensity = adjustTargetDensityForError(adjustedScaleFactor);
  options.inDensity = getDensityMultiplier(adjustedScaleFactor);
}
if (isScaling(options)) {
  options.inScaled = true;
} else {
  options.inDensity = options.inTargetDensity = 0;
}

计算bitmap的缩放在com.bumptech.glide.load.resource.bitmap.Downsampler#calculateScaling方法中。Bitmap缩小计算主要分两步,首先计算缩小比例是否大于2或者2的倍数,如果大于2或者2的倍数,先利用inSampleSize属性进行缩小,如果缩小之后的尺寸仍然大于imageview尺寸,再利用inDensity和inTargetDensity继续缩小。通过这种方式可以严格缩小到我们所需要的尺寸。比如一张图片大小是1000 * 1000,我们展示的imageView大小是200 * 200,那么首先计算出inSampleSize = 4缩小到250 * 250,仍然大于我们需要的200 * 200,再利用inDensity和inTargetDensity计算出一个0.8的系数缩小到200 * 200。

Glide中Bitmap内存复用

Glide默认支持了Bitmap的内存复用。Glide内存复用的处理是这样的,在解码一张图片的尺寸后,先去bitmapPool中查找有没有可以复用的内存,如果有直接拿来复用,如果没有,会先申请一块没有像素信息的内存,然后把这块没有像素信息的bitmap内存复用给我们需要解码的图片。

public Bitmap getDirty(int width, int height, Bitmap.Config config) {
  Bitmap result = getDirtyOrNull(width, height, config);
  if (result == null) {
    result = createBitmap(width, height, config);
  }
  return result;
}

可以看到查找和创建时都传入了三个参数width、height和config,通过width、height和config中的outConfig计算来所需内存大小。后面有个Glide中Bitmap解码的坑会用到这块。

Glide中Bitmap解码

我们通过inPreferredConfig来配置Bitmap的解码方式,系统默认使用的是ARGB-8888解码方式,这个配置选项只是建议解码方式,系统真正解码不一定会按照我们配置的参数来解码,比如说如果图片有alpha通道,就会强制使用ARGB-8888解码;还有8.0版本之后的硬拉位图更为特殊,会使很多配置无法生效。

Glide4.0之前版本默认使用RGB-565解码,之后默认使用ARGB-8888解码。但是Glide提供了配置解码方式的api,如果我们想利用系统解码的特点通过配置RGB-565的方式来减少bitmap的内存占用,会发现一直无法“生效”。

下面来看一下Glide配置Bitmap解码方式的逻辑。

private void calculateConfig(
    InputStream is,
    DecodeFormat format,
    boolean isHardwareConfigAllowed,
    boolean isExifOrientationRequired,
    BitmapFactory.Options optionsWithScaling,
    int targetWidth,
    int targetHeight) {
 
  if (hardwareConfigState.setHardwareConfigIfAllowed(
      targetWidth,
      targetHeight,
      optionsWithScaling,
      format,
      isHardwareConfigAllowed,
      isExifOrientationRequired)) {
    return;
  }
 
  // Changing configs can cause skewing on 4.1, see issue #128.
  if (format == DecodeFormat.PREFER_ARGB_8888
      || Build.VERSION.SDK_INT == Build.VERSION_CODES.JELLY_BEAN) {
    optionsWithScaling.inPreferredConfig = Bitmap.Config.ARGB_8888;
    return;
  }
 
  boolean hasAlpha = false;
  try {
    hasAlpha = ImageHeaderParserUtils.getType(parsers, is, byteArrayPool).hasAlpha();
  } catch (IOException e) {
    if (Log.isLoggable(TAG, Log.DEBUG)) {
      Log.d(TAG, "Cannot determine whether the image has alpha or not from header"
          + ", format " + format, e);
    }
  }
 
  optionsWithScaling.inPreferredConfig =
      hasAlpha ? Bitmap.Config.ARGB_8888 : Bitmap.Config.RGB_565;
  if (optionsWithScaling.inPreferredConfig == Config.RGB_565) {
    optionsWithScaling.inDither = true;
  }
}

可以看到,首先判断是否有硬拉位图的配置,如果有直接返回。然后是否为ARGB-8888或者有alpha通道,如果没有alpha通道配置为RGB-565解码。我们配置解码方式为RGB-565并且使用一张没有alpha通道的图片,最终解码方式为RGB-565,看一下为什么不“生效”。

下面是Glide解码的大部分配置处理,包括尺寸缩小,第12行就是上面的配置解码方式。

private Bitmap decodeFromWrappedStreams(InputStream is,
    BitmapFactory.Options options, DownsampleStrategy downsampleStrategy,
    DecodeFormat decodeFormat, boolean isHardwareConfigAllowed, int requestedWidth,
    int requestedHeight, boolean fixBitmapToRequestedDimensions,
    DecodeCallbacks callbacks) throws IOException {
  long startTime = LogTime.getLogTime();
 
  int[] sourceDimensions = getDimensions(is, options, callbacks, bitmapPool);
 
  ....
 
 calculateConfig(
    is,
    decodeFormat,
    isHardwareConfigAllowed,
    isExifOrientationRequired,
    options,
    targetWidth,
    targetHeight);
 
boolean isKitKatOrGreater = Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT;
  if ((options.inSampleSize == 1 || isKitKatOrGreater) && shouldUsePool(imageType)) {
    int expectedWidth;
    int expectedHeight;
    if (sourceWidth >= 0 && sourceHeight >= 0
        && fixBitmapToRequestedDimensions && isKitKatOrGreater) {
      expectedWidth = targetWidth;
      expectedHeight = targetHeight;
    } else {
      float densityMultiplier = isScaling(options)
          ? (float) options.inTargetDensity / options.inDensity : 1f;
      int sampleSize = options.inSampleSize;
      int downsampledWidth = (int) Math.ceil(sourceWidth / (float) sampleSize);
      int downsampledHeight = (int) Math.ceil(sourceHeight / (float) sampleSize);
      expectedWidth = Math.round(downsampledWidth * densityMultiplier);
      expectedHeight = Math.round(downsampledHeight * densityMultiplier);
 
      ....
 
    if (expectedWidth > 0 && expectedHeight > 0) {
      setInBitmap(options, bitmapPool, expectedWidth, expectedHeight);
    }
  }
  Bitmap downsampled = decodeStream(is, options, callbacks, bitmapPool);
  callbacks.onDecodeComplete(bitmapPool, downsampled);
 
  ....
 
  return rotated;
}

如果服务端返回的图片大小和前端所需展示的相差不大,那么这里inSampleSize就是1。然后会进入第41行,这个方法就是内存复用了。看一下内存复用的逻辑。

private static void setInBitmap(
    BitmapFactory.Options options, BitmapPool bitmapPool, int width, int height) {
  @Nullable Bitmap.Config expectedConfig = null;
  // Avoid short circuiting, it appears to break on some devices.
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
    if (options.inPreferredConfig == Config.HARDWARE) {
      return;
    }
    // On API 26 outConfig may be null for some images even if the image is valid, can be decoded
    // and outWidth/outHeight/outColorSpace are populated (see b/71513049).
    expectedConfig = options.outConfig;
  }
 
  if (expectedConfig == null) {
    // We're going to guess that BitmapFactory will return us the config we're requesting. This
    // isn't always the case, even though our guesses tend to be conservative and prefer configs
    // of larger sizes so that the Bitmap will fit our image anyway. If we're wrong here and the
    // config we choose is too small, our initial decode will fail, but we will retry with no
    // inBitmap which will succeed so if we're wrong here, we're less efficient but still correct.
    expectedConfig = options.inPreferredConfig;
  }
  // BitmapFactory will clear out the Bitmap before writing to it, so getDirty is safe.
  options.inBitmap = bitmapPool.getDirty(width, height, expectedConfig);
}

如果是8.0及以上系统并且是硬拉位图直接返回,否则使用options.outConfig。这里就要出问题了,options.outConfig这个值是非空的,而且是ARGB-8888,这个参数是什么时候赋值的呢?

private static int[] getDimensions(InputStream is, BitmapFactory.Options options,
    DecodeCallbacks decodeCallbacks, BitmapPool bitmapPool) throws IOException {
  options.inJustDecodeBounds = true;
  decodeStream(is, options, decodeCallbacks, bitmapPool);
  options.inJustDecodeBounds = false;
  return new int[] { options.outWidth, options.outHeight };
}

这个方法应该还记得吧,在上面解码方法的第一行就是这个方法用来获取图片的尺寸。而获取尺寸时传入的options是没有inPreferredConfig值的,后面会说到为什么。还记得上面说的如果没有配置解码方式,系统默认使用ARGB-8888解码吗,所以这里获取到尺寸后options的outConfig就被赋值了ARGB-8888。

回到上面来,8.0及以后版本,这个值肯定不为空,导致无法被赋值我们配置的inPreferredConfig。然后调用bitmapPool.getDirty(width, height, expectedConfig)去获取要复用内存的bitmap。上面说内存复用时提到过内存复用通过width、height和outConfig来计算需要的内存大小,这里计算的大小就是ARGB-8888解码4个字节的大小啦。还记得系统内存复用的机制吗,内存复用是复用整个bitmap的内存,所以这里的内存大小就是ARGB-8888解码格式的大小了。

下面来看一下我们配置的inPreferredConfig为什么在我们解码尺寸的时候没有被使用到呢?

public Resource decode(InputStream is, int requestedWidth, int requestedHeight,
    Options options, DecodeCallbacks callbacks) throws IOException {
  Preconditions.checkArgument(is.markSupported(), "You must provide an InputStream that supports"
      + " mark()");
 
  byte[] bytesForOptions = byteArrayPool.get(ArrayPool.STANDARD_BUFFER_SIZE_BYTES, byte[].class);
  BitmapFactory.Options bitmapFactoryOptions = getDefaultOptions();
  bitmapFactoryOptions.inTempStorage = bytesForOptions;
 
  DecodeFormat decodeFormat = options.get(DECODE_FORMAT);
  DownsampleStrategy downsampleStrategy = options.get(DownsampleStrategy.OPTION);
  boolean fixBitmapToRequestedDimensions = options.get(FIX_BITMAP_SIZE_TO_REQUESTED_DIMENSIONS);
  boolean isHardwareConfigAllowed =
    options.get(ALLOW_HARDWARE_CONFIG) != null && options.get(ALLOW_HARDWARE_CONFIG);
 
  try {
    Bitmap result = decodeFromWrappedStreams(is, bitmapFactoryOptions,
        downsampleStrategy, decodeFormat, isHardwareConfigAllowed, requestedWidth,
        requestedHeight, fixBitmapToRequestedDimensions, callbacks);
    return BitmapResource.obtain(result, bitmapPool);
  } finally {
    releaseOptions(bitmapFactoryOptions);
    byteArrayPool.put(bytesForOptions);
  }
}
 
 
private static synchronized BitmapFactory.Options getDefaultOptions() {
  BitmapFactory.Options decodeBitmapOptions;
  synchronized (OPTIONS_QUEUE) {
    decodeBitmapOptions = OPTIONS_QUEUE.poll();
  }
  if (decodeBitmapOptions == null) {
    decodeBitmapOptions = new BitmapFactory.Options();
    resetOptions(decodeBitmapOptions);
  }
 
  return decodeBitmapOptions;
}
 
 
private static void resetOptions(BitmapFactory.Options decodeBitmapOptions) {
  decodeBitmapOptions.inTempStorage = null;
  decodeBitmapOptions.inDither = false;
  decodeBitmapOptions.inScaled = false;
  decodeBitmapOptions.inSampleSize = 1;
  decodeBitmapOptions.inPreferredConfig = null;
  decodeBitmapOptions.inJustDecodeBounds = false;
  decodeBitmapOptions.inDensity = 0;
  decodeBitmapOptions.inTargetDensity = 0;
  decodeBitmapOptions.outWidth = 0;
  decodeBitmapOptions.outHeight = 0;
  decodeBitmapOptions.outMimeType = null;
  decodeBitmapOptions.inBitmap = null;
  decodeBitmapOptions.inMutable = true;
}

在我们解码方法的上游decode方法中在调用decodeFromWrappedStreams之前通过getDefaultOptions()来获取BitmapFactory.Options,然后在getDefaultOptions()方法里实例化了一个新的BitmapFactory.Options,并通过resetOptions方法进行初始化,初始化时inPreferredConfig值为空,为了使用内存复用机制设置了inMutable = true。之后再调用decodeFromWrappedStreams方法时传入这个options,这就是为什么解码尺寸时,没有inPreferredConfig的原因了。

总结:整体看下来为什么会出现这个问题呢,Glide使用了Glide.Options和BitmapFactory.Options两套来维护解码方式(当然Glide.Options还做了很多其他事情),一直到decode方法时才实例化BitmapFactory.Options。在decodeFromWrappedStreams方式解码时,先调用了解码图片尺寸,这时给BitmapFactory.Options赋值了ARGB-8888解码,然后才做calculateConfig解码方式的配置,导致在获取复用内存时获取到了ARGB-8888解码的内存。

我们配置了inPreferredConfig = RGB-565解码,复用了ARGB-8888解码的内存,最后我们的图片是什么格式呢?

是RGB-565格式的,实际我们图片内存只有真实占用内存的一半,但整块内存是无法被释放的,不知道这算不算Glide一个bug?

你可能感兴趣的:(Bitmap版本演变&Glide对Bitmap的处理)