作者 | Like_Codeing 地址 | juejin.im/post/5ec7302c518825434062f497正文 图片在移动开发中占据中举足轻重的地位,早期的android 应用页面Ui相对简单,但随着Android系统不断的升级发展, 界面元素越来越丰富,用户对体验要求越来越高,UI小姐姐们需要设计出精致的界面元素,其中不乏很多好看的图片,但是随着手机性能提升(分辨率,cpu主频,内存等),图片质量也越来越大,拍个照动不动就3M,4M,8M, 大家都知道,android 应用在创建进程时候,会分配一个指定的内存大小,准确的说话是 google原生OS的默认值是16M,但是各个厂家的系统会对这个值进行修改,如果我们应用“毫不吝啬”将这些大图直接加载到内存中,很快内存就会耗尽,最终出现OOM异常,所以图片的处理对于一个稳定、用户体验友好的应用来说非常重要,今天我们就来聊一聊Bitmap,在开发过程中把”图片“给优化一番,保证我们项目在线上稳定、流畅运行。 Bitmap初识 Bitmap图像处理的最重要类之一,用它可以获取图像文件信息,进行图像颜色变换、剪切、旋转、缩放等操作,并可以指定格式保存图像文件。 如图,bitmap在sdk中算是元老级的人物了,从api1中就已经有了,可见其重要性。 继承关系就不解释了,实现了Parcelable 具备在内存中传递的特性。 bitmap中有两个重要的内部类 CompressFormat 以及 Config; 下面分别介绍一下这两个类
- 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 of100
results in a file in the lossless WEBP format. Otherwise the file will be in the lossy WEBP format. 意为Android10之后如果quality值(压缩质量)为100的话,bitmap压缩采用无损压缩格式,其他都为有损压缩;
- format :?上面已经说明了,表示压缩格式;
- quality :压缩质量,取值0-100,0表示最低画质压缩,100表示最高画质压缩,对于PNG压缩格式来说,该参数可以忽略,对于WEBP格式来说,小于100为有损压缩格式,会对画质产生直接影响, 等于100时候采用的是无损压缩格式,画质是不会有改变,但是图片大小得到很好压缩;
- stream :将压缩后的图片写到指定的输出流中;
- Config
- 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位;
BitmapFactort.Options这个是什么鬼呢, 很重要!bitmap加载的配置类,想要做图片内存优化是少不了跟它打“打交道”,如下其内部属性
这里我们大概只说跟图片优化相关的几个重要属性- insampleSize :采样率,默认1表示无缩放,等于2表示宽高缩放2倍,总大小缩小4倍;
- inBitmap :被复用的bitmap;
- inJustDecodeBound : 如果设置为true,不获取图片,不分配内存,但会返回图片的高度宽度信息;
- inMutable :是否图片内容可变,如果Bitmap复用的话,需要设置为true;
- inDensity :加载bitmap时候对应的像素密度(后面会讲到);
- inTargetDensity :bitmap位图被真实渲染出的像素密度,对应终端设备的屏幕像素密度(后面会讲到);
- getAllocationByteCount
- getByteCount
- getRowBytes
2020-05-23 10:20:10.926 7669-7669/com.example.practicerecycerviewitemdecoration D/AAAA: density1:1.5 densityDpi:2402020-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:2402020-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 bitmapreturn GraphicsJNI::createBitmap(env, javaAllocator.getStorageObjAndReset(), bitmapCreateFlags, ninePatchChunk, ninePatchInsets, -1);
native层计算如上处理,先获取图片原始宽高,根据decodeMode计算出缩放系数, 最后对canvas进行缩放,最后将Bitmap画出来从而完成Bitmap的加载操作, 如果看到这里大家基本已经了解Bitpmap加载到内存的流程和底层缩放策略了,不要停!继续聊,关于bitmap的优化还没开始讲。。。
- assets 中的图片大小
2020-05-23 14:32:33.799 11603-11603/com.example.practicerecycerviewitemdecoration D/AAAA: density1:1.5 densityDpi:2402020-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:2402020-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:2402020-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 优化
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 的复用有一定的限制:
在 Android 4.4 版本之前,只能重用相同大小的 Bitmap 内存区域, 4.4 之后你可以重用任何 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实际问题的处理远不止这么多,像截屏长图的处理,如果不处理这张”超大图“,应用很容易就崩掉,这里需要用到分片加载, 这里不多说了,大家可自行查阅官方文档学习一下。
如果大家对这篇文章感兴趣,欢迎收藏点赞,下一篇再见!
---END---
转发至朋友圈,是绝对的真爱
让我知道你在看