码个蛋(codeegg) 第 980 次推文
作者:Like_Codeing
链接:https://juejin.im/post/5ec7302c518825434062f497
图片在移动开发中占据中举足轻重的地位,早期的android 应用页面Ui相对简单,但随着Android系统不断的升级发展, 界面元素越来越丰富,用户对体验要求越来越高,UI小姐姐们需要设计出精致的界面元素,其中不乏很多好看的图片,但是随着手机性能提升(分辨率,cpu主频,内存等),图片质量也越来越大,拍个照动不动就3M,4M,8M, 大家都知道,android 应用在创建进程时候,会分配一个指定的内存大小,准确的说话是 google原生OS的默认值是16M,但是各个厂家的系统会对这个值进行修改,如果我们应用“毫不吝啬”将这些大图直接加载到内存中,很快内存就会耗尽,最终出现OOM异常,所以图片的处理对于一个稳定、用户体验友好的应用来说非常重要,今天我们就来聊一聊Bitmap,在开发过程中把”图片“给优化一番,保证我们项目在线上稳定、流畅运行。
初识
Bitmap图像处理的最重要类之一,用它可以获取图像文件信息,进行图像颜色变换、剪切、旋转、缩放等操作,并可以指定格式保存图像文件。
如图,bitmap在sdk中算是元老级的人物了,从api1中就已经有了,可见其重要性。
继承关系就不解释了,实现了Parcelable 具备在内存中传递的特性。
bitmap中有两个重要的内部类 CompressFormat 以及 Config;
下面分别介绍一下这两个类
CompressFormat
CompressFormat 是用来设置压缩方式的,是个枚举类,内部提供了三种图片压缩方式类型,
JPEG :表示Bitmap采用JPEG压缩算法进行压缩,压缩后的格式可以是.jpg或者.png,是一种有损压缩方式。
PNG : 表示Bitmap采用PNG压缩算法进行压缩,压缩后的格式可以是.png,是一种无损压缩方式。
WEBP :表示以WebP压缩算法进行图像压缩,压缩后的格式可以是".webp",是一种有损压缩,质量相同的情况下,WebP格式图像的体积要比JPEG格式图像小40%,美中不足的是,WebP格式图像的编码时间“比JPEG格式图像长8倍”, 而且还需要注意,在官方文档中有这样的描述:As of Build.VERSION_CODES.Q
, a value of 100
results in a file in the lossless WEBP format. Otherwise the file will be in the lossy WEBP format. 意为Android10之后如果quality值(压缩质量)为100的话,bitmap压缩采用无损压缩格式,其他都为有损压缩;
这里有的同志会问,这都是压缩格式啊,具体怎么操作压缩呢,Bitmap为我们提供了一个可靠的方法供开发者使用,我们来顺便看看Bitmap都有什么方法,如下:
第一个方法就是compress()方法, 没错就是这么就这方法,一共有三个参数
format :????上面已经说明了,表示压缩格式;
quality :压缩质量,取值0-100,0表示最低画质压缩,100表示最高画质压缩,对于PNG压缩格式来说,该参数可以忽略,对于WEBP格式来说,小于100为有损压缩格式,会对画质产生直接影响, 等于100时候采用的是无损压缩格式,画质是不会有改变,但是图片大小得到很好压缩;
stream :将压缩后的图片写到指定的输出流中;
返回值:boolean, 返回true表示成功将bitmap压缩到输出流中,然后可以通过Bitmap.Factory从相应的输入流中解析出来bitmap信息;
从官网介绍可知, 该方法在图片压缩过程中可能消耗较长时间,建议放在子线程中操作,至于为什么大家可以看看源码, 源码中会调用一个nativeCompress 的Native 方法,也就是压缩处理是放在底层处理的;
Config
表示位图像素的存储格式,什么意思呢? 就是bitmap在屏幕上显示的每一像素在内存中存储的格式,会影响Bitmap真实图片的透明度以及图片质量;
Bitmap.Config.ALPHA_8:颜色信息只由透明度组成,占8位;
Bitmap.Config.ARGB_4444:颜色信息由透明度与R(Red),G(Green),B(Blue)四部分组成,每个部分都占4位,总共占16位;
Bitmap.Config.ARGB_8888:颜色信息由透明度与R(Red),G(Green),B(Blue)四部分组成,每个部分都占8位,总共占32位,是Bitmap 默认的颜色存储格式,也是最占空间的一种配置;
Bitmap.Config.RGB_565:颜色信息由R(Red),G(Green),B(Blue)三部分组成,R占5位,G占6位,B占5位,总共占16位;
上面说了 android 系统默认存储位图方式是 ARGB_8888, 4个通道组成,每个通道8位,分表代表透明度和RGB颜色值, 也就是说一个位图像素占用了4个字节(1个byte8个bit位),
同理:采用 Bitmap.Config.RGB_565 存储,单像素占用内存大小仅有2byte,换句话说一张图片采用ARGB_565格式相对于默认的ARGB_8888内存将减少一半,所以通过改变bitmap像素存储方式也是图片内存优化的重要渠道,这个后面会讲到;
BitmapFactory
创建位图bitmap对象途径有很多种, 包括指定文件、流, 和字节数组等;
官方文档中提供了从字节数组、指定路径,系统Resource、二进制流等方式创建Bitmap, 当然有的方法需要一些特殊参数,例如通过字节数组方式需要指定解析的起始偏移位置,长度等,有的需要指定路径 path , 或者指定 BitmapFactory.Option配置信息 , 它也是我们图片优化的重要手段;
这里我们大概只说跟图片优化相关的几个重要属性
insampleSize :采样率,默认1表示无缩放,等于2表示宽高缩放2倍,总大小缩小4倍;
inBitmap :被复用的bitmap;
inJustDecodeBound : 如果设置为true,不获取图片,不分配内存,但会返回图片的高度宽度信息;
inMutable :是否图片内容可变,如果Bitmap复用的话,需要设置为true;
inDensity :加载bitmap时候对应的像素密度(后面会讲到);
inTargetDensity :bitmap位图被真实渲染出的像素密度,对应终端设备的屏幕像素密度(后面会讲到);
好了,Bitmap的api我们就讲到这里,因为我们今天不是主要讲解他的用法,为了给接下来的知识做一个铺垫,简单介绍bitmap的知识点,我们接下来回归”正题“
Bitmap 占用内存分析
Bitmap 用来描述一张图片的长、宽、颜色等信息。通常情况下,我们可以使用 BitmapFactory 来将某一路径下的图片解析为 Bitmap 对象。
当一张图片加载到内存后,具体需要占用多大内存呢?
getAllocationByteCount
getByteCount
getRowBytes
这三个方法是什么意思呢?跟内存占用又有什么关系呢,下面我们分别解释一下这三个方法
先一下这张图
上图中 是保存在 res/drawable-mdpi 目录下的一张 1920*1200,大小为 270Kb 的图片
为什么让你看这张图呢? 因为眼睛????看累了,顺便。。。 不是的,注意上面红色框中原始图片大小和尺寸,为后面压缩设定主题;
我们分别通过 Bitmap.getAllocationByteCount() 以及 Bitmap.getByteCount()和Bitmap.getRowBytes() 方法获取 该Bitmap 的相关字节大小,比如以下代码:
打印结果如下
2020-05-23 10:20:10.926 7669-7669/com.example.practicerecycerviewitemdecoration D/AAAA: density1:1.5 densityDpi:240
2020-05-23 10:21:52.422 7669-7669/com.example.practicerecycerviewitemdecoration D/AAAAA: height:1800
width:2880
allocationByteCount:20736000
byteCount:20736000
rowBytes:11520
density:240
mutable:false
大家看到 allocationByteCount = byteCount = 20736000 为什么呢? 两者又有什么差距呢?
这里我们看看官方文档怎么说的:
该方法在api19 之后加入的,用来返回一个存储Bitmpa像素信息的内存大小, 什么意思呢?就是为Bitmpa分配的内存大小而已, 它跟getByteCount有什么关系呢? 文档上有说明,一般情况下这两个值相等,当bitmap用来复用存储另外一个比原bitmap大小更小一点图片时候getAllocationByteCount是大于getByteCount的值,换句话说通过复用Bitmap来解码图片,如果被复用的Bitmap的内存比待分配内存的Bitmap大,那么getByteCount()表示新解码图片占用内存的大小(并非实际内存大小,实际大小是复用的那个Bitmap的大小),getAllocationByteCount()表示被复用Bitmap真实占用的内存大小。
而且在api19之后系统推荐使用getAllocationByteCount,看源码
所以上面日志信息两者相等是成立,那这两者又跟getRowBytes()有什么关系呢?我们接着往下看,当我打开getByteCount的源码你就瞬间明白了
getByteCunt内存大小其实就是一行像素所占据字节大小 * Bitmap高度
我们可以验证一下:
11520 * 1800 = 20736000
结算结果非常准确,没有任何偏差,大小类似理解一个矩形面积等于长*宽一样 , getRowBytes代表就是该bitmap一行像素所占据的内存大小,然后再乘以高度就是整张bitmap所占用内存;
或许有的朋友又会问,那getRowBytes大小怎么来的呢?总得给个解释吧, 刚才上面解释了,它代表了bitmap一行的像素内存,这又什么意思呢?一行像素所占用内存=bitmap宽度 * 1像素所占字节大小 ,计算如下
2880 * 4 = 11520
计算结果同样没有任何偏差,此时大家是不是似乎明白了一些什么, 我这里是根据 bitmap 内存相关api 从内到外跟大家分析内存占用, 最终得出结论
Bitmap占用内存= 宽 * 高 * 一像素所占用字节内存 ,如下
2880 * 1800 * 4 = 20736000
可能有的同志发现了,内存中bitmap图片高度、宽度跟原始图片宽高不一样,这是为什么呢?
是的,确实不一样,这里有个细节知识点,我们上面在讲Bitmap相关api时候也提到过inDensity和InTargetDensity,我这里先说出结论,然后在带大家从源码角度上找答案;
实际上 BitmapFactory 在解析图片的过程中,会根据当前设备屏幕密度和图片所在的 drawable 目录来做一个对比,根据这个对比值进行缩放操作。具体公式为如下所示:
缩放比例 scale = 当前设备屏幕密度 / 图片所在 drawable 目录对应屏幕密度
Bitmap 实际占用内存 = 宽 * scale * 高 * scale * 一像素所占用字节内存,在 Android 中,各个 drawable 目录对应的屏幕密度分别为下:
在回头看我们上面那个问题,为什么图片原始宽高跟bitmap宽高不等,从我们打印的日志可知我们设备density=1.5 densityDpi=240,而图片放在drawable-mdpi , 该bitmap的desityDpi为160 ,
bitmap 真实高= 1.5 (设备densityDpi 240/图片所在drawable的densityDpi 160 ) * 1200 = 1800
bitmap 真实宽= 1.5 (设备densityDpi 240/图片所在drawable的densityDpi 160 ) * 1920 = 2800
同样结果非常准确,也就是说明我们Bitmap内存大小除了跟我们图片宽高有关系、Bitmap.Config 以及 缩放比,而缩放比大小取决于 设备屏幕密度和图片所在drawable对应密度。
如果我们把图片放到drawable-hdpi下面,bitmap内存大小会有变化么? 是变大了还是变小了?
我是打印一下日志试一下, 然后再根据上面那个规则验证一下结果,打印如下
2020-05-23 12:01:45.358 9182-9182/com.example.practicerecycerviewitemdecoration D/AAAA: density1:1.5 densityDpi:240
2020-05-23 12:01:47.018 9182-9182/com.example.practicerecycerviewitemdecoration D/AAAAA: height:1200
width:1920
allocationByteCount:9216000
byteCount:9216000
rowBytes:7680
density:240
mutable:false
Bitmap的宽高等于原始宽高,内存大小 9216000 ,原因就是图片的drawable的densityDpi变化了,根据公式大小计算
1920 * 240/240 * 1200 * 240/240 * 4 = 9216000
9216000/20736000 = 0.44..... 把图片放到mdpi下比在hdpi内存多消耗了60% 左右,
由此可见,我们在进行图片适配时候要准备多张图片放到不同drawable目录下,一方面保证了我们图片在各设备下的显示效果一致,另一方面系统加载适合的bitmap可以节省非常多内存空间,试想一下如果我们设备是640 Dpi的呢?而我们只准备了一张图片放在mdpi或者hdpi中,那么我们这张图片会消耗多大内存呀!!!
讲了这么多,Bitmap 占用内存大小我们已经总结出来了,那我们再看看源码验证一把,前面我们讲过BitmapFactory 解析Bitmap 相关api, 如:
public static Bitmap decodeResource(Resources res, int id, Options opts) {
validate(opts);
Bitmap bm = null;
InputStream is = null;
try {
final TypedValue value = new TypedValue();
is = res.openRawResource(id, value);
bm = decodeResourceStream(res, value, is, null, opts);
} catch (Exception e) {
/* do nothing. If the exception happened on open, bm will be null. If it happened on close, bm is still valid. */
} finally { try {
if (is != null) is.close();
} catch (IOException e) { // Ignore
} }
if (bm == null && opts != null && opts.inBitmap != null) { throw new IllegalArgumentException("Problem decoding into existing bitmap"); }
return bm;
}
继续跟进decodeResourceStream里面(这里就截图吧)
如果option为空就重新new一个出来,如果TypeValue不为空取出TypeValue的density信息,TypeValue是Resource解析对应资源时候的结果封装,这里就不详细解释了,大家可以自己学习一下, 从resource里面读取到图片信息后,包括该图片所在的drawable对一个的dpi,也就是TypeValue里面的density值,如果这个值为0的话此时就会用到系统的 认 DENSITY_DEFAULT,也就是这个值
public static final int DENSITY_DEFAULT = DENSITY_MEDIUM;
public static final int DENSITY_MEDIUM = 160;
而inTargetDesity 大小为设备的屏幕密度 densityDpi
有了这两个值我们就可以计算bitmap的大小了, 我们接着看 decodeStream , 最终会跟到nativeDecodeStream 中, 很明显这是个native方法,因此我们知道Bitmap的内存计算其实是放在 native层做的, 那么我直接贴出native 层处理的代码吧,
static jobject doDecode(JNIEnv* env, SkStreamRewindable* stream, jobject padding, jobject options) { //初始缩放系数float scale = 1.0f;
if (env->GetBooleanField(options, gOptions_scaledFieldID)) {
const int density = env->GetIntField(options, gOptions_densityFieldID);
const int targetDensity = env->GetIntField(options, gOptions_targetDensityFieldID);
const int screenDensity = env->GetIntField(options, gOptions_screenDensityFieldID);
if (density != 0 && targetDensity != 0 && density != screenDensity) { //缩放系数是当前系数密度/图片所在文件夹对应的密度;
scale = (float) targetDensity / density;
}
}
//原始解码出来的Bitmap;SkBitmap decodingBitmap;
if(decoder->decode(stream, &decodingBitmap, prefColorType, decodeMode) != SkImageDecoder::kSuccess) {
return nullObjectReturn("decoder->decode returned false"); }
//原始解码出来的Bitmap的宽高;
int scaledWidth = decodingBitmap.width();
int scaledHeight = decodingBitmap.height();
//要使用缩放系数进行缩放,缩放后的宽高;
if(willScale && decodeMode != SkImageDecoder::kDecodeBounds_Mode) {
scaledWidth = int(scaledWidth * scale + 0.5f);
scaledHeight = int(scaledHeight * scale + 0.5f);
}
//源码解释为因为历史原因;sx、sy基本等于scale。
const float sx = scaledWidth / float(decodingBitmap.width());
const float sy = scaledHeight / float(decodingBitmap.height());
canvas.scale(sx, sy); canvas.drawARGB(0x00, 0x00, 0x00, 0x00);
canvas.drawBitmap(decodingBitmap, 0.0f, 0.0f, &paint);
// now create the java bitmap
return GraphicsJNI::createBitmap(env, javaAllocator.getStorageObjAndReset(), bitmapCreateFlags, ninePatchChunk, ninePatchInsets, -1);
native层计算如上处理,先获取图片原始宽高,根据decodeMode计算出缩放系数, 最后对canvas进行缩放,最后将Bitmap画出来从而完成Bitmap的加载操作, 如果看到这里大家基本已经了解Bitpmap加载到内存的流程和底层缩放策略了,不要停!继续聊,关于bitmap的优化还没开始讲。。。
assets 中的图片大小
我们知道,Android 中的图片不仅可以保存在 drawable 目录中,还可以保存在 assets 目录下,然后通过 AssetManager 获取图片的输入流。那这种方式加载生成的 Bitmap 是多大呢?同样是上面的 girl.png,这次将它放到 assets 目录中,使用如下代码加载:
最终打印结果如下:
2020-05-23 14:32:33.799 11603-11603/com.example.practicerecycerviewitemdecoration D/AAAA: density1:1.5 densityDpi:240
2020-05-23 14:32:35.335 11603-11603/com.example.practicerecycerviewitemdecoration D/AAAAA: height:1200
width:1920
allocationByteCount:9216000
byteCount:9216000
rowBytes:7680
density:240
mutable:false
可以看出,加载 assets 目录中的图片,系统并不会对其进行缩放操作。
Bitmap 加载优化 上面的例子也能看出,一张 270Kb 大小的图片被加载到内存后,竟然占用了 9216000 个字节,也就是 9M 左右。因此适当时候,我们需要对需要加载的图片进行缩略优化。
修改图片加载的 Config
修改占用空间少的存储方式可以快速有效降低图片占用内存。比如通过 BitmapFactory.Options 的 inPreferredConfig 选项,将存储方式设置为 Bitmap.Config.RGB_565。这种存储方式一个像素占用 2 个字节,所以最终占用内存直接减半。如下:
打印日志如下:
2020-05-23 14:37:06.213 11949-11949/com.example.practicerecycerviewitemdecoration D/AAAA: density1:1.5 densityDpi:240
2020-05-23 14:37:07.047 11949-11949/com.example.practicerecycerviewitemdecoration D/AAAAA: height:1200
width:1920
allocationByteCount:4608000 // 相比9216000减少一半的内存
byteCount:4608000
rowBytes:3840
density:240
mutable:false
这个结论我们在介绍Bitmap 的 Config时候已经介绍过了,这里不多说了;
另外 Options 中还有一个 inSampleSize 参数,可以实现 Bitmap 采样压缩,这个参数的含义是宽高维度上每隔 inSampleSize 个像素进行一次采集。比如以下代码:
因为宽高都会进行采样,所以最终图片会被缩略 4 倍,最终打印效果如下:
2020-05-23 14:42:59.440 12182-12182/com.example.practicerecycerviewitemdecoration D/AAAA: density1:1.5 densityDpi:240
2020-05-23 14:43:00.332 12182-12182/com.example.practicerecycerviewitemdecoration D/AAAAA: height:600
width:960
allocationByteCount:2304000 // 为9216000的1/4,极大降低了内存
byteCount:2304000
rowBytes:3840
density:240
mutable:false
Bitmap 复用
场景描述
如果在 Android 某个页面创建很多个 Bitmap,比如有两张图片 A 和 B,通过点击某一按钮需要在 ImageView 上切换显示这两张图片,
可以使用以下代码实现上述效果:
但是在每次调用 switchImage 切换图片时,都需要通过 BitmapFactory 创建一个新的 Bitmap 对象。当方法执行完毕后,这个 Bitmap 又会被 GC 回收,这就造成不断地创建和销毁比较大的内存对象,从而导致频繁 GC(或者叫内存抖动)。像 Android App 这种面相最终用户交互的产品,如果因为频繁的 GC 造成 UI 界面卡顿,还是会影响到用户体验的。可以在 Android Studio Profiler 中查看内存情况,多次切换图片后,显示的效果如下:
使用 Options.inBitmap 优化
实际上经过第一次显示之后,内存中已经存在了一个 Bitmap 对象。每次切换图片只是显示的内容不一样,我们可以重复利用已经占用内存的 Bitmap 空间,这个概念上面也讲过,这里具体做法就是使用 Options.inBitmap 参数。修改如下:
解释说明:
第一个红框处创建一个可以用来复用的 Bitmap 对象。第二处红框,将 options.inBitmap 赋值为之前创建的bitmap 对象,从而避免重新分配内存。重新运行代码,并查看 Profiler 中的内存情况,可以发现不管我们切换图片多少次,内存占用基本处于一个水平线状态。
我们再来看日志:
2020-05-23 15:33:46.515 21739-21739/com.example.practicerecycerviewitemdecoration D/AAAAA: height:1200
width:1920
allocationByteCount:9216000
byteCount:9216000
rowBytes:7680
density:240
mutable:true
2020-05-23 15:34:09.031 21739-21739/com.example.practicerecycerviewitemdecoration D/AAAAA: height:322
width:640
allocationByteCount:9216000
byteCount:824320
rowBytes:2560
density:240
mutable:true
第二张图片内存明显复用了第一张Bitmap内存大小 9216000,而第二张图片byteCount大小为824320,而不等于allocationByteCoung大小,就在文章开头我们也讲解过getAllocationByteCoun和getByteCount的区别,很好的解释了这个结果;
但是这里需要注意,我们第一张Bitmap比第二张Bitmap大,如果第一张Bitmap比第二章小的话,这里就不能复用了,前面我们也是提到过的,否则会直接崩溃掉, 如下:
我们默认在onResume里面 imageView?.setImageBitmap(bitmap) 此时这个bitmap是上图image【1】对应的Bitmap,他的内存分配上面也打印过为 824320 ,然后点击切换时候我们就复用这个Bitmap内存,将image【0】内容再填充到这个bitmap中,我们试着运行一下结果发现
直接给我们抛出异常了, 我们在decordResource源码中找到答案了,如下
如果bm为空,而且开启了bitmap复用,这里就会崩掉。。。
这是因为 Bitmap 的复用有一定的限制:
那么我们需要做一下处理了,如下
第一步:先初始化一个bitmap,这个bitmap我用的是bitmap[1]中的 加载到内存后的大小为824320 ;
第二步:取第一张图片也就是image[0],实际内存大小为9216000,由于我们把inJustDecodeBound=true 此时并没有正真加载到内存中,为了获取该bitmap配置信息;
第三步:判断bitmap 能否复用, 方法如下
获取option中的预加载bitmap的大小,然后根据位图存储格式计算预加载的bitmap大小,最后返回比较结果, 这里默认采用ARGB_8888所以✖️4;
如果预加载的bitmap所占内存大小<=被复用bitmap大小,
option.InMutable=true;
option.InBitmap=bitmap
最后一步:再次加载bitmap并实现复用;
细心的你可能也发现了在每次加载之前,除了 inBitmap 参数之外,我还将 Options.inMutable 置为 true,这里如果不置为 true 的话,BitmapFactory 将不会重复利用 Bitmap 内存,并输出相应 warning 日志:
W/BitmapFactory: Unable to reuse an immutable bitmap as an image decoder target
Bitmap 缓存
当需要在界面上同时展示一大堆图片的时候,比如 ListView、RecyclerView 等,由于用户不断地上下滑动,某个 Bitmap 可能会被短时间内加载并销毁多次。这种情况下通过使用适当的缓存,可以有效地减缓 GC 频率保证图片加载效率,提高界面的响应速度和流畅性。
最常用的缓存方式就是 LruCache,基本使用方式如下:
解释说明:
图中 指定 LruCache 的最大空间为 20M,当超过 20M 时,LruCache 会根据内部缓存策略将多余 Bitmap 移除。
图中 sizeOf () 方法指定了插入 Bitmap 时的大小,当我们向 LruCache 中插入数据时,LruCache 并不知道每一个对象会占用大多内存,因此需要我们手动指定,并且根据缓存数据的类型不同也会有不同的计算方式。
最后就是我们存取操作了。
上面就是今天的内容,讲解类Bitmap的相关基础知识点和优化,Bitmap实际问题的处理远不止这么多,像截屏长图的处理,如果不处理这张”超大图“,应用很容易就崩掉,这里需要用到分片加载, 这里不多说了,大家可自行查阅官方文档学习一下。
相关文章:
程序员如何在大环境恶劣下脱颖而出?(3000字干货)
国内第一创作平台大佬,教你如何写好一篇技术博客?
git命令的进阶和复习(带动图效果)
今日问题:
图片的实际问题还遇到过哪些么?