为何大厂的图片不会OOM?

678次阅读  |  发布于4年以前

作者: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 是用来设置压缩方式的,是个枚举类,内部提供了三种图片压缩方式类型,

  1. JPEG :表示Bitmap采用JPEG压缩算法进行压缩,压缩后的格式可以是.jpg或者.png,是一种有损压缩方式。
  2. PNG : 表示Bitmap采用PNG压缩算法进行压缩,压缩后的格式可以是.png,是一种无损压缩方式。
  3. WEBP :表示以WebP压缩算法进行图像压缩,压缩后的格式可以是".webp",是一种有损压缩,质量相同的情况下,WebP格式图像的体积要比JPEG格式图像小40%,美中不足的是,WebP格式图像的编码时间“比JPEG格式图像长8倍”, 而且还需要注意,在官方文档中有这样的描述:As 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()方法, 没错就是这么就这方法,一共有三个参数

  1. format :上面已经说明了,表示压缩格式;
  2. quality :压缩质量,取值0-100,0表示最低画质压缩,100表示最高画质压缩,对于PNG压缩格式来说,该参数可以忽略,对于WEBP格式来说,小于100为有损压缩格式,会对画质产生直接影响, 等于100时候采用的是无损压缩格式,画质是不会有改变,但是图片大小得到很好压缩;
  3. stream :将压缩后的图片写到指定的输出流中;

返回值:boolean, 返回true表示成功将bitmap压缩到输出流中,然后可以通过Bitmap.Factory从相应的输入流中解析出来bitmap信息;

从官网介绍可知, 该方法在图片压缩过程中可能消耗较长时间,建议放在子线程中操作,至于为什么大家可以看看源码, 源码中会调用一个nativeCompress 的Native 方法,也就是压缩处理是放在底层处理的;

表示位图像素的存储格式,什么意思呢? 就是bitmap在屏幕上显示的每一像素在内存中存储的格式,会影响Bitmap真实图片的透明度以及图片质量;

  1. Bitmap.Config.ALPHA_8:颜色信息只由透明度组成,占8位;
  2. Bitmap.Config.ARGB_4444:颜色信息由透明度与R(Red),G(Green),B(Blue)四部分组成,每个部分都占4位,总共占16位;
  3. Bitmap.Config.ARGB_8888:颜色信息由透明度与R(Red),G(Green),B(Blue)四部分组成,每个部分都占8位,总共占32位,是Bitmap 默认的颜色存储格式,也是最占空间的一种配置;
  4. 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配置信息 , 它也是我们图片优化的重要手段;

BitmapFactort.Options这个是什么鬼呢, 很重要!bitmap加载的配置类,想要做图片内存优化是少不了跟它打“打交道”,如下其内部属性

这里我们大概只说跟图片优化相关的几个重要属性

好了,Bitmap的api我们就讲到这里,因为我们今天不是主要讲解他的用法,为了给接下来的知识做一个铺垫,简单介绍bitmap的知识点,我们接下来回归”正题“

Bitmap 占用内存分析

Bitmap 用来描述一张图片的长、宽、颜色等信息。通常情况下,我们可以使用 BitmapFactory 来将某一路径下的图片解析为 Bitmap 对象。

当一张图片加载到内存后,具体需要占用多大内存呢?

这三个方法是什么意思呢?跟内存占用又有什么关系呢,下面我们分别解释一下这三个方法

先一下这张图

上图中 是保存在 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的优化还没开始讲。。。

我们知道,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 中查看内存情况,多次切换图片后,显示的效果如下:

实际上经过第一次显示之后,内存中已经存在了一个 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 的复用有一定的限制:

在 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实际问题的处理远不止这么多,像截屏长图的处理,如果不处理这张”超大图“,应用很容易就崩掉,这里需要用到分片加载, 这里不多说了,大家可自行查阅官方文档学习一下。

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8