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?