Android性能优化-Bitmap内存优化

Android中的bitmap是比较占用内存的,bitmap的大小直接影响到了应用占用内存的大小。Bitmap内存优化属于性能优化中较为重要的点。如何更好的使用 bitmap,减少其对 App内存的使用,是我们开发中不可回避的问题。
为了解决这个问题,就出现了Bitmap 的高效加载策略。其实核心思想很简单。假设通过InmageView 来显示图片,很多时候 ImageVIew并没有原始图片的尺寸那么大,这个时候把整个图片加载进来再设置ImageView,显示是没有必要的,因为ImageView根本没办法显示原始图片。这时候就可以按一定的采样率来将图片缩小后在加载进来,这样图片既能在ImageView显示出来,又能降低内存占用从而在一定程度上避免OOM,提高了Bitmap加载时的性能。

基础了解

我们先了解一下,Bitmap到底占用多大的内存。

Bitmap作为位图,需要读入一张图片每一个像素点的数据,其主要占用内存的地方也正是这些像素数据。对于像素数据总大小,我们可以猜想为:像素总数量 x 每个像素的字节大小,而像素总数量在矩形屏幕的表现下,应该是:横向像素数量 x 纵向像素数量,结合得到:

1
bitmap内存大小 = bitmap宽度(px) * bitmap长度(px) * 一个像素点占用的字节数

单个像素的字节大小由Bitmap 的一个可配置参数 Config 来决定。

Bitmap 中,存在一个 枚举类 Config,定义了Android 中支持的 Bitmap配置。

image

而Bitmap默认是使用24位真彩色加载的。

1
2
3
/** Image are loaded with the {@link Bitmap.Config#ARGB_8888} config by default.
**/
public Bitmap.Config inPreferredConfig = Bitmap.Config.ARGB_8888;

Bitmap的加载

加载Bitamp的方式
bitmap在Android中指的是一张图片。通过BitmapFactory类提供的4类方法:

decodeFile,decodeResouce,decodeStream和 decodeByteArray,分别从文件系统,资源,输入流和字节数组中加载出一个 Bitmap 对象,其中decodeFiled,decodeResource又间接调用了 decodeStream 方法,这4类 方法最终是在Android的底层实现的,对应着BitmapFactory类的几个native方法。

BitmapFactory.Options的参数
inSampleSize参数(采样率)
上述4类方法都支持BitmapFactory.Options参数,而Bitmap的按一定采样率进行缩放就是通过 BitmapFactory.Options参数实现的,主要用到了 inSampleSize参数,即采样率。通过对 inSampleSize 的设置,对图片的像素的高和款进行缩放。

当 inSampleSize=1 ,即采样后的图片大小为图片的原始大小,小于1,也按照1来计算。当 inSampleSize>1,即采样后的拖欠将会缩小,缩放比例为1/(inSampleSize的二次方)。

1
例如:一张 1024—1024像素的图片,采用ARG8888 格式存储,那么内存大小1024x1024x4=4m.如果 inSampleSize=2,即采样后图片内存大小为 512x512X4=1m

注意:官方文档中指出,inSampleSize的取值应该总是2的指数,如1,2,4,8等。如果外界传入的 inSampleSize的值不为2的指数,那么系统会向下取整并选择成立一个最接近2的指数来代替。比如3,系统会选择2来代替。不过并非在所有的Android版本都成立

关于 inSampleSize 取值的注意事项:通常是根据图片宽高实际的大小/需要的宽高大小,分别计算出宽和高的缩放比。单应该取其中最小的缩放比,避免缩放图片大小,到达指定控件中不能铺满,需要拉伸从而导致模糊。

例如:ImageView的大小是 100x100 像素,而图片的原始大小是 200x300,那么宽的缩放比是 2,高的缩放比是 3,如果最终 inSampleSize=2,那么缩放后的图片大小 100x150,仍然合适 ImageView。如果inSamleSize=3,那么缩放后的图片大小小于 ImageView所期望的大小。这样图片就会被拉伸而导致模糊。

inJustDecodeBounds 参数
我们需要获取加载的图片的宽高信息,然后交给inSampleSize 参数选择缩放比缩放,那么如何能不先加载图片却能获取得图片的宽高信息,通过 inJustDecodeBunds=true,然后加载图片就可以实现只解析图片的宽高信息,并不会真正的加载图片,所以这个操作是轻量级的。当获取了宽高信息,计算出缩放比后,然后在将 inJustDecodeBounds=false,再重新加载图片,就可以加载缩放后的图片。

注意:BitmapFactory 获取得图片宽高信息和图片的位置以及程序运行的设备有关,比如同一张图片放在不同的drawable目录下或者程序运行在不同屏幕密度的设备上,都可能导致BitmapFactory 获取到不同的结果,和 Android 的资源加载机制有关。

高效加载Bitmap的流程

  1. 将BitmapFactory.Options的 inJustDecodeBounds 参数设置为true并加载图片。
  2. 从BitmapFactory.Options中取出图片的原始宽高信息,他们对应于uouytWidth 和 outHeight参数。
  3. 根据采样率的规则并结合目标View 的所需大小计算出采样率 inSampleSize.
  4. 将BitmapFactory.Options 的inJustDecodeBounds 参数设为 false,然后重新加载图片。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
private Bitmap showBit(Resources resources, int id, int width, int height) {
BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
Bitmap bitmap;
//加载图片
BitmapFactory.decodeResource(resources, id, options);
Log.e("demo","w"+options.outWidth+"----h"+options.outHeight);
//计算缩放比
options.inSampleSize = calculateInsamplSize(options, width, height);
//重新加载图片
options.inJustDecodeBounds = false;
bitmap = BitmapFactory.decodeResource(resources, id, options);
Log.e("demo",bitmap.getWidth()
+"------height"+bitmap.getHeight()+"------size"
+options.inSampleSize+"-----byte"+bitmap.getRowBytes());
return bitmap;
}

private int calculateInsamplSize(BitmapFactory.Options options, int reqWidth, int reqHeight) {
int height = options.outHeight;
int width = options.outWidth;
int sizeSimple = 1;
if (height > reqHeight && width > reqWidth) {
int sizew=width/reqWidth;
int sizeh=height/reqHeight;
//选其中最小的边为标准
int sizemode=sizew>sizeh?sizeh:sizew;
if (sizemode>sizeSimple){
return sizeSimple*sizemode;
}
}
return sizeSimple;
}

观察打印数据:
Bitmap内存优化
经过我们压缩之后,其图片大小占用1260字节,分辨率也是随之下降,不过都在我们所设定的范围之内,下面我们看看,如果不压缩,结果是怎么样。
更改inSampleSize=1,也就是默认原图显示。效果如下:
Bitmap内存优化

改变Bitmap大小

根据上面的原理,我们可以从两个方面减少Bitmap的内存占用,一个是改变Bitmap的宽高,另一个是改变Bitmap.Config的值,将Bitmap.Config.ARGB_8888改为占用字节更少的Bitmap.Config.ARGB_4444或者Bitmap.Config.RGB_565。
除了上文中的更改采样率的方式进行压缩外,还有没有别的?显然是有的。

1、通过martix进行压缩(改变Bitmap大小)。Bitmap.createBitmap或者Bitmap.createScaledBitmap方法。
2、更改Bitmap.Config格式。
3、通过Bitmap#compress方法压缩。

原图信息如下:

原图

通过martix进行压缩(改变Bitmap大小)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
val matrix = Matrix()
matrix.setScale(0.1f, 0.1f)
val bmpMatrixJpg = Bitmap.createBitmap(
bmpOriginJpg, 0, 0, bmpOriginJpg.getWidth(),
bmpOriginJpg.getHeight(), matrix, true
)
// Bitmap.createScaledBitmap内部也会使用Matrix进行缩放
// val bmpMatrixJpg = Bitmap.createScaledBitmap(
// bmpOriginJpg,
// ScreenUtils.dip2px(this, 60f),
// ScreenUtils.dip2px(this, 45f),
// true
// )
val descBmpConfigJpg =
"height:${bmpMatrixJpg?.height},\nwidth:${bmpMatrixJpg?.width},\nallocationByteCount:${bmpMatrixJpg?.allocationByteCount}byte,\n" +
"byteCount:${bmpMatrixJpg?.byteCount}byte,\nrowBytes:${bmpMatrixJpg?.rowBytes}byte,\ndensity:${bmpMatrixJpg?.density}"
tvMatrixJpgInfo.text = "Jpg通过Matrix压缩后的信息:$descBmpConfigJpg"
ivMatrixJpg.setImageBitmap(bmpMatrixJpg)

Matrix缩放Bitmap

由于我们设置的缩放比是0.1f,也就是宽高均是之前的1/10,所以压缩后的bitmap占用的内存大小变为原来的1/100。

这种情况适用原图大小和目标bitmap大小均已知的情况。

更改Bitmap.Config格式

1
2
3
4
5
6
7
8
9
val option = BitmapFactory.Options()
option.inPreferredConfig = Bitmap.Config.ARGB_4444
// option.inPreferredConfig = Bitmap.Config.RGB_565 // 对透明度没要求的话可以试一下rgb_565
val bmpBmpConfigJpg = BitmapFactory.decodeStream(assets.open("maomi.jpg"), null, option)
val descBmpConfigJpg =
"height:${bmpBmpConfigJpg?.height},\nwidth:${bmpBmpConfigJpg?.width},\nallocationByteCount:${bmpBmpConfigJpg?.allocationByteCount}byte,\n" +
"byteCount:${bmpBmpConfigJpg?.byteCount}byte,\nrowBytes:${bmpBmpConfigJpg?.rowBytes}byte,\ndensity:${bmpBmpConfigJpg?.density}"
tvBmpConfigJpgInfo.text = "Jpg通过Bitmap.Config压缩后的信息:$descBmpConfigJpg"
ivBmpConfigJpg.setImageBitmap(bmpBmpConfigJpg)

Bitmap.Config减少内存占用

我们将Bitmap.Config的值改为了根据输出的byteCount的值改为了Bitmap.Config.ARGB_4444,根据byteCount输出的值可以明显的看到,bitmap的内存大小减少了一半。

如果对透明度没要求的话可以试一下Bitmap.Config.RGB_565。

这种情况适用于对图片分辨率要求不高的情况。

通过Bitmap#compress方法压缩,质量压缩

还有一种很重要的压缩方式,通过Bitmap#compress方法,修改quality的值,来改变Bitmap生成的字节流的大小。这种方法不会改变Bitmap占用的内存大小。

质量压缩不会减少图片的像素,它是在保持像素的前提下改变图片的位深及透明度等,来达到压缩图片的目的。图片的长,宽,像素都不变,那么bitmap所占内存大小是不会变的。这里改变的是bitmap对应的字节数组的大小,适合去传递二进制的图片数据,比如微信分享。

1
2
3
4
5
6
7
8
val bytearray = getBytesFromCompressBitmap(bmpOriginJpg, 32 * 1024)
val bmpQualityJpg = BitmapFactory.decodeByteArray(bytearray, 0, bytearray.size)
val descQualityJpg =
"height:${bmpQualityJpg.height},\nwidth:${bmpQualityJpg.width},\nallocationByteCount:${bmpQualityJpg.allocationByteCount}byte,\n" +
"byteCount:${bmpQualityJpg.byteCount}byte,\nrowBytes:${bmpQualityJpg.rowBytes}byte,\ndensity:${bmpQualityJpg.density},\n" +
"bytearray:${bytearray.size}"
tvQualityJpgInfo.text = "Jpg进行Quality压缩后的信息:$descQualityJpg"
ivQualityJpg.setImageBitmap(bmpQualityJpg)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/**
* 将Bitmap的字节流压缩为目标大小
* @targetSize 单位为Byte
*/
private fun getBytesFromCompressBitmap(
bitmap: Bitmap,
targetSize: Int
): ByteArray {
val baos = ByteArrayOutputStream()
var quality = 100
bitmap.compress(Bitmap.CompressFormat.PNG, quality, baos)
var bytes = baos.toByteArray()
while (bytes.size > targetSize && quality >= 5) {
quality -= 5
if (quality < 0) {
quality = 0
}
// 重置,不然会累加
baos.reset()
bitmap.compress(Bitmap.CompressFormat.JPEG, quality, baos)
bytes = baos.toByteArray()
}
try {
baos.close()
} catch (e: Exception) {
e.printStackTrace()
}
return bytes
}

质量压缩

可以看到,质量压缩不会改变原有bitmap的大小,它改变的是通过Bitmap#compress方法的字节流。

具体开发过程中,可以根据需要自行选择合适的方式。