1. 引子
前几天跟服务端的一个妹子联调接口,服务器配置一张图片,几十KB就行,她问我图片从哪里找,我告诉她先随便在网上找个图片链接就行了。结果一运行程序,就崩溃了,出现了下面的异常。
java.lang.OutofMemoryError
内存溢出OOM,我当时一脸懵逼。
图-1 一脸懵逼
于是拿着后台返回的链接去查看了一下图片,是一张6M的壁纸。
图-2 我内心几乎是崩溃的
这只是一个简单的联调,而在联调过程中操作不当导致出现OOM问题,大家就当是个玩笑。其实在Android中很容易出现OOM的异常,特别是对图片操作的时候,所以当面对大图片,需要我们对图片进行适当的压缩,在不影响图片显示的情况下,尽量保证不出现OOM的异常。
2. 概述
在开发中,对于图片的操作,稍有不慎,可能就会消耗大量的内存,导致程序崩溃,所以了解一种通用的技术去处理和加载图片,同时保证UI流畅避免OOM现象,是非常有必要的。那么为什么在Android中对于图片的处理会如此棘手呢?主要有以下一些原因:
- 通常情况下,移动设备的内存资源是有限的,Android系统会根据手机的屏幕大小和密度,为每个程序设置一个最大内存限制,应用程序消耗的内存不能超过这个最大内存限制,否则就会出现OOM现象。当然,这个内存限制是跟手机配置相关联的。
- 图片的操作会消耗大量的内存,特别是细节丰富的图片,例如照片。以Galaxy Nexus相机为例子,它拍摄一张2592x1936像素的照片,如果使用的位图配置是ARGB_8888(默认从Android 2.3开始),那么这张照片加载到内存,大约会消耗19MB的内存(2592 x 1936 x 4字节),仅仅是图片消耗内存的数值可能已经超过了某些设备的内存限制
- Android的UI经常会一次加载多张图片,例如,ListView、GridView、ViewPager等等
图片有各种形状和大小。通常情况下,它们普遍比设备所需要的图片要大一些,例如手机相册显示手机拍摄的照片,而手机的相机分辨率大多时候是要高于手机屏幕的分辨率。鉴于手机的内存有限,我们只需要在内存中加载一个低分辨率的照片版本就可以了,而这个低分辨率的照片应该与显示它的控件相匹配,这就需要对图片进行压缩处理了。
Android中有两种压缩图片的方法。
- 第一种是针对图片的长宽进行压缩,在将图片加载到内存过程中将图片的长宽进行压缩,获取长宽压缩版的的图片
- 第二种是针对图片的像素进行压缩,图片加载到内存后,针对图片质量进行压缩,会导致图片质量下降。
3. 图片长宽压缩
3.1 获取加载图片的属性
Android中的BitmapFactory类提供了一些解码方法,decodeByteArray()、decodeFile()、decodeResource()等等,根据不通的图片源选择不同的解码方法加载图片创建出Bitmap。这些方法中都会传入一个BitmapFactory.Options
实例化对象,通过这个对象,可以更改一些加载图片的设置。由于这些解码方法用于解码加载图片,会占用内存构建Bitmap,因此很容易导致OOM的异常。
如果将options.inJustDecodeBounds
设置为true,在解码过程中就不会申请内存去创建Bitmap,返回的是一个空的Bitmap,但是可以获取图片的一些属性,例如图片宽高,图片类型等等。
BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true; // 设置为true,不将图片解码到内存中
BitmapFactory.decodeResource(getResources(), R.id.myimage, options);
int imageHeight = options.outHeight; // 图片高度
int imageWidth = options.outWidth; // 图片宽度
String imageType = options.outMimeType; // 图片类型
一般来说,为了避免OOM的异常,在加载图片到内存之前,会先检查图片的尺寸,除非你能确保图片源不会导致OOM。
3.2 缩小图片的长宽来压缩图片
我们知道图片的大小之后,就可以决定是否将完整的图片加载到内存或者加载压缩版的图片到内存。可以基于以下几点做出决定:
- 估计完整图片加载到内存中所使用内存
- 可分配给加载图片的内存
- 用于显示图片的控件的大小
- 当前设备的屏幕大小和密度
例如,如果显示图片的控件大小为128x96像素,就没有必要将一个1024x768像素的图片加载到内存中。
设置options.inSampleSize
的数值,来控制压缩图片程度。例如,将options.inSampleSize
设置为4,将一个2048x1536像素的图片解码加载到内存后产生的Bitmap大约为512x384像素,如果使用的位图配置是ARGB_8888,那么仅仅需要0.75M就加载了缩小版的图片到内存,而加载完整的图片需要12M。
也就是说,如果我们设置inSampleSize == 2
,解码出来的位图的宽高是原图的1/2,图片所占用内存缩小了1/4(1/2 x 1/2)。如果inSampleSize
设置的值小于等1,都会当做inSampleSize == 1
来解码加载图片。
于是我们可以在加载图片的时候,根据控件的大小(显示到屏幕上的大小)来计算出加压缩版图片的inSampleSize
值。
/**
* 计算inSampleSize值
*
* @param options
* 用于获取原图的长宽
* @param reqWidth
* 要求压缩后的图片宽度
* @param reqHeight
* 要求压缩后的图片长度
* @return
* 返回计算后的inSampleSize值
*/
public static int calculateInSampleSize(BitmapFactory.Options options, int reqWidth, int reqHeight) {
// 原图片的宽高
final int height = options.outHeight;
final int width = options.outWidth;
int inSampleSize = 1;
if (height > reqHeight || width > reqWidth) {
final int halfHeight = height / 2;
final int halfWidth = width / 2;
// 计算inSampleSize值
while ((halfHeight / inSampleSize) >= reqHeight
&& (halfWidth / inSampleSize) >= reqWidth) {
inSampleSize *= 2;
}
}
return inSampleSize;
}
有人可能会疑问为什么每次inSampleSize
都是乘以2,指数增长。这是因为在加载图片过程中,解析器使用的inSampleSize
都是2的指数倍,如果inSampleSize
是其他值,则找一个离这个值最近的2的指数值。
上面已经获取了inSampleSize
,然后就可以根据这个值来加载压缩版的图片了。
public static Bitmap decodeSampledBitmapFromResource(Resources res, int resId,
int reqWidth, int reqHeight) {
// 先将inJustDecodeBounds设置为true来获取图片的长宽属性
final BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeResource(res, resId, options);
// 计算inSampleSize
options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight);
// 加载压缩版图片
options.inJustDecodeBounds = false;
// 根据具体情况选择具体的解码方法
return BitmapFactory.decodeResource(res, resId, options);
}
获取到了压缩版的Bitmap之后就可以直接设置到屏幕的控件上了。
mImageView.setImageBitmap(
decodeSampledBitmapFromResource(getResources(), R.id.myimage, 100, 100));
4. 图片质量压缩
4.1 方法介绍
上面一种方法是通过缩放图片的大小来达到压缩效果,基本不会对图片的显示效果有影响。但是现在介绍的这一种方法,可能会导致图片质量下降。
使用的是下面这个方法来进行压缩。
Bitmap.compress(CompressFormat format, int quality, OutputStream stream)
这个方法有三个参数,是布尔类型的返回值
- CompressFormat 指定的Bitmap被压缩成的图片格式,只支持JPEG,PNG,WEBP三种
- quality 图片压缩质量的控制,范围为0~100,0表示压缩后体积最小,但是质量也是最差,100表示压缩后体积最大,但是质量也是最好的(个人认为相当于未压缩),有些格式,例如png,它是无损的,所以会忽略这个值。
- OutputStream 压缩后的数据会写入这个字节流中
- 返回值表示返回的字节流是否可以使用
BitmapFactory.decodeStream()
解码成Bitmap,至于返回值是怎么得到的,因为是Native的代码,没法找到逻辑。
4.2 色位深度介绍
接下来说说为什么用这个方法可能会导致图片质量下降。在Bitmap中有一个Config
的属性,这个属性是用来描述每个像素被储存的大小。目前Config
有四个值:ALPHA_8
、RGB_565
、ARGB_4444
、ARGB_8888
。这个说明一下(我个人的理解,真心不好解释),每一个像素会可能由四个属性组成,R(Red红色通道)、G(Green绿色通道)、B(Blue蓝色通道)、A(Alpha透明度通道)。
Config | 每个像素占用的字节 | 说明 |
---|---|---|
ALPHA_8 | 1 bytes | 每个像素仅仅储存透明度通道 |
RGB_565 | 2 bytes | 每个像素的RGB通道会保存,透明度不会保存,红色通道5位,有2^5=32种表现形式;绿色通道6位,有2^6=64种表现形式;蓝色通道5位,有2^5=32种表现形式 |
ARGB_4444 | 2 bytes | 每个像素的ARGB通道都会保存,透明度/红色/绿色/蓝色通道4位,有2^4=16种表现形式 |
ARGB_8888 | 4 bytes | 每个像素的ARGB通道都会保存,透明度/红色/绿色/蓝色通道8位,有2^8=256种表现形式 |
有什么区别呢?最简单的,当一个颜色表现形式越多,那么画面整体的色彩就会更丰富,图片质量就会越高,当然,图片占用的储存空间也越大。
4.3 图片质量下降原因介绍
前面提到过调用Bitmap.compress()
方法时候,会传入一个压缩后的图片格式,但是由于并不是所有的图片格式都支持上面说的Config
的所有通道,比如说,JPEG格式的图片,是不支持Alpha(透明度)属性的,这样将压缩后返回的字节流通过BitmapFactory.decodeStream()
转换成Bitmap的过程中,会将透明度属性给丢弃,导致图片质量下降。
4.4 压缩过程介绍
压缩过程如下,通过依次减少图片质量,将图片大小控制在限制值范围内。
/**
* 压缩图片
*
* @param bitmap
* 被压缩的图片
* @param sizeLimit
* 大小限制
* @return
* 压缩后的图片
*/
private Bitmap compressBitmap(Bitmap bitmap, long sizeLimit) {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int quality = 100;
bitmap.compress(Bitmap.CompressFormat.JPEG, quality, baos);
// 循环判断压缩后图片是否超过限制大小
while(baos.toByteArray().length / 1024 > sizeLimit) {
// 清空baos
baos.reset();
bitmap.compress(Bitmap.CompressFormat.JPEG, quality, baos);
quality -= 10;
}
Bitmap newBitmap = BitmapFactory.decodeStream(new ByteArrayInputStream(baos.toByteArray()), null, null);
return newBitmap;
}
5. 更近一步的优化
上面提到的很多压缩方法,如果是在UI线程执行的话,很有可能阻塞到主线程,这是在开发过程中非常不愿意见到的事情,所以我们需要在后台线程去执行这些压缩图片比较耗时的操作,然后获取到压缩后的图片,设置到屏幕中。使用AsyncTask
可以帮助我们很好的实现。
class BitmapWorkerTask extends AsyncTask<Integer, Void, Bitmap> {
private final WeakReference<ImageView> imageViewReference;
private int data = 0;
public BitmapWorkerTask(ImageView imageView) {
// 使用弱引用
imageViewReference = new WeakReference<ImageView>(imageView);
}
// 在后台线程压缩图片
@Override
protected Bitmap doInBackground(Integer... params) {
data = params[0];
return decodeSampledBitmapFromResource(getResources(), data, 100, 100));
}
// 压缩完成后,将图片设置到控件中
@Override
protected void onPostExecute(Bitmap bitmap) {
if (imageViewReference != null && bitmap != null) {
final ImageView imageView = imageViewReference.get();
if (imageView != null) {
imageView.setImageBitmap(bitmap);
}
}
}
}
最终的执行代码。
BitmapWorkerTask task = new BitmapWorkerTask(imageView);
task.execute(resId);
6. 总结
图片的处理,时刻都需要注意,因为机型配置的不同,以及现场设备内存使用的情况,都有可能导致OOM的现象,上述提到了压缩方法,基本适用与大部分图片压缩情况。当然如果对图片画质显示有要求,可能就需要特殊的处理了,这个就不在大部分场景的考虑内。
7. 高斯模糊的建议
我在项目中遇见的关于图片操作的OOM异常,有80%源自于高斯模糊。是的,有些产品经理为了和iOS保持一致,需要将某些页面背景设置成高斯模糊效果。
一般的做法是将上一个页面截图,然后做高斯模糊处理,设置成背景。正好我接触过这种需求,说一下自己对于高斯模糊的建议。
- 确定产品经理的需求,高斯模糊的效果是不是一定要上。之前遇见一个需求,需要高斯模糊,结果Android做出来效果很不理想,后来,我把背景直接设置成60%的透明度白色。产品经理看了之后,觉得Android的高斯模糊效果(其实是透明度)比iOS的要好一些,就让iOS改。所以,一定要首先确认产品经理的需求,产品经理想要的效果可能并不是他口中说出的效果,就像我遇见的这位,可能误将透明度和高斯模糊混合了。
- 如果高斯模糊效果一定要上。先将图片长宽缩小,然后压缩图片质量,再进行高斯模糊的渲染,最后将高斯模糊之后的效果图放大至控件大小,显示到屏幕。
- 缩放图片长宽
- 压缩图片质量
- 高斯模糊渲染
- 放大高斯模糊效果图
最后,希望Android工程师不要遇见高斯模糊的需求,因为,真的,很坑。但是如果遇见了,也不要怕,因为你已经知道该如何处理了。