高性能图片优化方案

11,812 阅读40分钟

目录介绍

  • 01.图片基础概念介绍
    • 1.1 图片占用内存介绍
    • 1.2 加载网络图片流程
    • 1.3 三方库加载图片逻辑
    • 1.4 从网络直接拉取图片
    • 1.5 加载图片的流程
    • 1.6 Bitmap能直接存储吗
    • 1.7 Bitmap创建流程
    • 1.8 图片框架如何设计
  • 02.图片内存计算方式
    • 2.1 如何计算占用内存
    • 2.2 上面计算内存对吗
    • 2.3 一个像素占用内存
    • 2.4 使用API获取内存
    • 2.5 影响Bitmap内存因素
    • 2.6 加载xhdpi和xxhdpi图片
    • 2.7 图片一些注意事项
  • 03.大图的内存优化
    • 3.1 常见图片压缩
    • 3.2 图片尺寸压缩
    • 3.3 图片质量压缩
    • 3.4 双线性采样压缩
    • 3.5 高清图分片加载
    • 3.6 图片综合压缩
  • 04.色彩格式及内存优化
    • 4.1 RGB颜色种类
    • 4.2 ARGB色彩模式
    • 4.3 改变色彩格式优化
  • 05.缓存的使用实践优化
    • 5.1 Lru内存缓存
    • 5.2 Lru内存注意事项
    • 5.3 使用Lru磁盘缓存
  • 06.不同版本对Bitmap管理
    • 6.1 演变进程
    • 6.2 管理Bitmap内存
    • 6.3 提高Bitmap复用
  • 07.图片其他方面优化
    • 7.1 减少PNG图片的使用
    • 7.2 控件切割圆角优化
    • 7.3 如何给图片置灰色
    • 7.4 如何处理图片旋转呢
    • 7.5 保存图片且刷相册
    • 7.6 统一图片域名优化
    • 7.7 优化H5图片加载
    • 7.8 优化图片阴影效果
    • 7.9 图片资源的压缩

01.图片基础概念介绍

1.1 图片占用内存介绍

  • 移动设备的系统资源有限,所以应用应该尽可能的降低内存的使用。

    • 在应用运行过程中,Bitmap (图片)往往是内存占用最大的一个部分,Bitmap 图片的加载和处理,通常会占用大量的内存空间,所以在操作 Bitmap 时,应该尽可能的小心。
  • Bitmap 会消耗很多的内存,特别是诸如照片等内容丰富的大图。

    • 例如,一个手机拍摄的 2700 * 1900 像素的照片,需要 5.1M 的存储空间,但是在图像解码配置 ARGB_8888 时,它加载到内存需要 19.6M 内存空间(2592 * 1936 * 4 bytes),从而迅速消耗掉该应用的剩余内存空间。
    • OOM 的问题也是我们常见的严重问题,OOM 的产生的一个主要场景就是在大图片分配内存的时候产生的,如果 APP 可用内存紧张,这时加载了一张大图,内存空间不足以分配该图片所需要的内存,就会产生 OOM,所以控制图片的高效使用是必备技能。

1.2 加载网络图片流程

  • 这一部分压缩和缓存图片,在glide源码分析的文章里已经做出了比较详细的说明。在这里简单说一下图片请求加载过程……

  • 在使用App的时候,会经常需要加载一些网络图片,一般的操作步骤大概是这样的:

    • 第一步从网络加载图片:一般都是通过网络拉取的方式去服务器端获取到图片的文件流后,再通过BitmapFactory.decodeStream(InputStream)来加载图片Bitmap。
    • 第二步这种压缩图片:网络加载图片方式加载一两张图片倒不会出现问题,但是如果短时间内加载十几张或者几十张图片的时候,就很有可能会造成OOM(内存溢出),因为现在的图片资源大小都是非常大的,所以我们在加载图片之前还需要进行相应的图片压缩处理。
    • 第三步变换图片:比如需要裁剪,切割圆角,旋转,添加高斯模糊等属性。
    • 第四步缓存图片:但又有个问题来了,在使用移动数据的情况下,如果用户每次进入App的时候都会去进行网络拉取图片,这样就会非常的浪费数据流量,这时又需要对图片资源进行一些相应的内存缓存以及磁盘缓存处理,这样不仅节省用户的数据流量,还能加快图片的加载速度。
    • 第五步异步加载:虽然利用缓存的方式可以加快图片的加载速度,但当需要加载很多张图片的时候(例如图片墙瀑布流效果),就还需用到多线程来加载图片,使用多线程就会涉及到线程同步加载与异步加载问题。

1.3 三方库加载图片逻辑

  • 先说出结论,目前市面较为常用的大概是Glide,Picasso,Fresco等。大概的处理图片涉及主要逻辑有:

    • 从网络或者本地等路径拉取图片;然后解码图片;然后进行压缩;接着会有图片常用圆角,模糊或者裁剪等处理;然后三级缓存加载的图片;当然加载图片过程涉及同步加载和异步加载;最后设置到具体view控件上。

1.4 从网络直接拉取图片

  • 直接通过网络请求将网络图片转化成bitmap

    • 在这将采用最原生的网络请求方式HttpURLConnection方式进行图片获取。
    • 经过测试,请求8张图片,耗时毫秒值174。一般是通过get请求拉取图片的。这种方法应该是最基础的网络请求,大家也可以回顾一下,一般开发中很少用这种方式加载图片。具体可以看:ImageToolLib
  • 如何加载一个图片呢?

    • 可以看看BitmapFactory类为我们提供了四类方法来加载Bitmap:decodeFile、decodeResource、decodeStream、decodeByteArray;也就是说Bitmap,Drawable,InputStream,Byte[] 之间是可以进行转换。

1.5 加载图片的流程

  • 搞清楚一个图片概念

    • 在电脑上看到的 png 格式或者 jpg 格式的图片,png(jpg) 只是这张图片的容器。是经过相对应的压缩算法将原图每个像素点信息转换用另一种数据格式表示。
  • 加载图片显示到手机

    • 通过代码,将这张图片加载进内存时,会先解析(也就是解码操作)图片文件本身的数据格式,然后还原为位图,也就是 Bitmap 对象。
  • 图片大小vs图片内存大小

    • 一张 png 或者 jpg 格式的图片大小,跟这张图片加载进内存所占用的大小完全是两回事。

1.6 Bitmap能直接存储吗

  • Bitmap基础概念

    • Bitmap对象本质是一张图片的内容在手机内存中的表达形式。它将图片的内容看做是由存储数据的有限个像素点组成;每个像素点存储该像素点位置的ARGB值。每个像素点的ARGB值确定下来,这张图片的内容就相应地确定下来了。
  • Bitmap本质上不能直接存储

    • 为什么?bitmap是一个对象,如果要存储成本地可以查看的图片文件,则必须对bitmap进行编码,然后通过io流写到本地file文件上。

1.7 Bitmap创建流程

  • 对于图片OOM,可以发现一个现象。

    • heapsize(虚拟机的内存配置)越大越不容易 OOM,Android8.0 及之后的版本更不容易 OOM,这个该如何理解呢?
  • Bitmap对象内存的变化:

    • 在 Android 8.0 之前,Bitmap 像素占用的内存是在 Java heap 中分配的;8.0 及之后,Bitmap 像素占用的内存分配到了 Native Heap。
    • 由于 Native heap 的内存分配上限很大,32 位应用的可用内存在 3~4G,64 位上更大,虚拟内存几乎很难耗尽,所以推测 OOM 时 Java heap 中占用内存较多的对象是 Bitmap” 成立的情况下,应用更不容易 OOM。
  • 搞清楚Bitmap对象内存分配

    • Bitmap 的构造方法是不公开的,在使用 Bitmap 的时候,一般都是通过 Bitmap、BitmapFactory 提供的静态方法来创建 Bitmap 实例。
    • 以 Bitmap.createBitmap 说明了 Bitmap 对象的主要创建过程分析,可以看到 Java Bitmap 对象是在 Native 层通过 NewObject 创建的。
    • allocateJavaPixelRef,是 8.0 之前版本为 Bitmap 像素从 Java heap 申请内存。其核心原理是Bitmap 的像素是保存在 Java 堆上。
    • allocateHeapBitmap,是 8.0 版本为 Bitmap 像素从 Native heap 申请内存。其核心原理主要是通过 calloc 为 Bitmap 的像素分配内存,这个分配就在 Native 堆上。
    • 更多详细内容可以看:Bitmap对象内存分配

1.8 图片框架如何设计

  • 大多数图片框架加载流程

    • 概括来说,图片加载包含封装,解析,下载,解码,变换,缓存,显示等操作。
  • 图片框架是如何设计的

    • 封装参数:从指定来源,到输出结果,中间可能经历很多流程,所以第一件事就是封装参数,这些参数会贯穿整个过程;
    • 解析路径:图片的来源有多种,格式也不尽相同,需要规范化;比如glide可以加载file,io,id,网络等各种图片资源
    • 读取缓存:为了减少计算,通常都会做缓存;同样的请求,从缓存中取图片(Bitmap)即可;
    • 查找文件/下载文件:如果是本地的文件,直接解码即可;如果是网络图片,需要先下载;比如glide这块是发起一个请求
    • 解码:这一步是整个过程中最复杂的步骤之一,有不少细节;比如glide中解析图片数据源,旋转方向,图片头等信息
    • 变换和压缩:解码出Bitmap之后,可能还需要做一些变换处理(圆角,滤镜等),还要做图片压缩;
    • 缓存:得到最终bitmap之后,可以缓存起来,以便下次请求时直接取结果;比如glide用到三级缓存
    • 显示:显示结果,可能需要做些动画(淡入动画,crossFade等);比如glide设置显示的时候可以添加动画效果

02.图片内存计算方式

2.1 如何计算占用内存

  • 如果图片要显示下Android设备上,ImageView最终是要加载Bitmap对象的,就要考虑单个Bitmap对象的内存占用了,如何计算一张图片的加载到内存的占用呢?其实就是所有像素的内存占用总和:
  • bitmap内存大小 = 图片长度 x 图片宽度 x 单位像素占用的字节数
  • 起决定因素就是最后那个参数了,Bitmap常见有2种编码方式:ARGB_8888和RGB_565,ARGB_8888每个像素点4个byte,RGB_565是2个byte,一般都采用ARGB_8888这种。那么常见的1080*1920的图片内存占用就是:1920 x 1080 x 4 = 7.9M

2.2 上面计算内存对吗

  • 我看到好多博客都是这样计算的,但是这样算对吗?有没有哥们试验过这种方法正确性?我觉得看博客要对博主表示怀疑,论证别人写的是否正确。

    • 说出我的结论:上面2.1这种说法也对,但是不全对,没有说明场景,同时也忽略了一个影响项:Density。接下来看看源代码。
    • inDensity默认为图片所在文件夹对应的密度;inTargetDensity为当前系统密度。
    • 加载一张本地资源图片,那么它占用的内存 = width * height * nTargetDensity/inDensity 一个像素所占的内存。
    @Nullable
    public static Bitmap decodeResourceStream(@Nullable Resources res, @Nullable TypedValue value,
            @Nullable InputStream is, @Nullable Rect pad, @Nullable Options opts) {
        validate(opts);
        if (opts == null) {
            opts = new Options();
        }
    
        if (opts.inDensity == 0 && value != null) {
            final int density = value.density;
            if (density == TypedValue.DENSITY_DEFAULT) {
                opts.inDensity = DisplayMetrics.DENSITY_DEFAULT;
            } else if (density != TypedValue.DENSITY_NONE) {
                opts.inDensity = density;
            }
        }
        
        if (opts.inTargetDensity == 0 && res != null) {
            opts.inTargetDensity = res.getDisplayMetrics().densityDpi;
        }
        
        return decodeStream(is, pad, opts);
    }
    
  • 正确说法,这个注意呢?计算公式如下所示

    • 对资源文件:width * height * nTargetDensity/inDensity * nTargetDensity/inDensity * 一个像素所占的内存;
    • 别的:width * height * 一个像素所占的内存;

2.3 一个像素占用内存

  • 一个像素占用多大内存?Bitmap.Config用来描述图片的像素是怎么被存储的?

    • ARGB_8888: 每个像素4字节. 共32位,默认设置。
    • Alpha_8: 只保存透明度,共8位,1字节。
    • ARGB_4444: 共16位,2字节。
    • RGB_565:共16位,2字节,只存储RGB值。

2.4 使用API获取内存

  • Bitmap使用API获取内存

    • getByteCount()

      • getByteCount()方法是在API12加入的,代表存储Bitmap的色素需要的最少内存。API19开始getAllocationByteCount()方法代替了getByteCount()。
    • getAllocationByteCount()

      • API19之后,Bitmap加了一个Api:getAllocationByteCount();代表在内存中为Bitmap分配的内存大小。
  • 思考: getByteCount()与getAllocationByteCount()的区别?

    • 一般情况下两者是相等的;通过复用Bitmap来解码图片,如果被复用的Bitmap的内存比待分配内存的Bitmap大,那么getByteCount()表示新解码图片占用内存的大小(并非实际内存大小,实际大小是复用的那个Bitmap的大小),getAllocationByteCount()表示被复用Bitmap真实占用的内存大小(即mBuffer的长度)。
  • 在复用Bitmap的情况下,getAllocationByteCount()可能会比getByteCount()大。

2.5 影响Bitmap内存因素

  • 影响Bitmap占用内存的因素:

    • 图片最终加载的分辨率;
    • 图片的格式(PNG/JPEG/BMP/WebP);
    • 图片所存放的drawable目录;
    • 图片属性设置的色彩模式;
    • 设备的屏幕密度;

2.6 加载xhdpi和xxhdpi图片

  • 提个问题,加载xhdpi和xxhdpi中相同的图片,显示在控件上会一样吗?内存大小一样吗?为什么?

    • 肯定是不一样的。xhdpi:240dpi--320dpi,xxhdpi:320dpi--480dpi,
  • app中设置的图片是如何从hdpi中查找的?

    • 首先计算dpi,比如手机分辨率是1920x1080,5.1寸的手机。那么得到的dpi公式是(√ ̄1920² + 1080²)/5.1 =2202/5.1= 431dpi。这样优先查找xxhdpi
    • 如果xxhdpi里没有查找图片,如果没有会往上找,遵循“先高再低”原则。如果xhdpi里有这个图片会使用xhdpi里的图片,这时候发现会比在xhdpi里的图片放大了。
  • 为何要引入不同hdpi的文件管理?

    • 比如:xxhdpi放94x94,xhdpi放74x74,hdpi放45x45,这样不管是什么样的手机图片都能在指定的比例显示。引入多种hdpi是为了让这个图片在任何手机上都是手机的这个比例。

2.7 图片一些注意事项

  • 同样图片显示在大小不相同的ImageView上,内存是一样吗?

    • 图片占据内存空间大小与图片在界面上显示的大小没有关系。
  • 图片放在res不同目录,加载的内存是一样的吗?

    • 最终图片加载进内存所占据的大小会不一样,因为系统在加载 res 目录下的资源图片时,会根据图片存放的不同目录做一次分辨率的转换,而转换的规则是:新图的高度 = 原图高度 * (设备的 dpi / 目录对应的 dpi )

03.大图的内存优化

3.1 常见图片压缩

  • 常见压缩方法Api

    • Bitmap.compress(),质量压缩,不会对内存产生影响;
    • BitmapFactory.Options.inSampleSize,内存压缩;
  • Bitmap.compress()质量压缩

    • 质量压缩,不会对内存产生影响。它是在保持像素的前提下改变图片的位深及透明度等,来达到压缩图片的目的,不会减少图片的像素。进过它压缩的图片文件大小会变小,但是解码成bitmap后占得内存是不变的。
  • BitmapFactory.Options.inSampleSize内存压缩

    • 解码图片时,设置BitmapFactory.Options类的inJustDecodeBounds属性为true,可以在Bitmap不被加载到内存的前提下,获取Bitmap的原始宽高。而设置BitmapFactory.Options的inSampleSize属性可以真实的压缩Bitmap占用的内存,加载更小内存的Bitmap。
    • 设置inSampleSize之后,Bitmap的宽、高都会缩小inSampleSize倍。例如:一张宽高为2048x1536的图片,设置inSampleSize为4之后,实际加载到内存中的图片宽高是512x384。占有的内存就是0.75M而不是12M,足足节省了15倍。
    • 备注:inSampleSize值的大小不是随便设、或者越大越好,需要根据实际情况来设置。inSampleSize比1小的话会被当做1,任何inSampleSize的值会被取接近2的幂值。

3.2 图片尺寸压缩

3.2.1 如何理解尺寸压缩
  • 通常在大多数情况下,图片的实际大小都比需要呈现的尺寸大很多。

    • 例如,我们的原图是一张 2700 * 1900 像素的照片,加载到内存就需要 19.6M 内存空间,但是,我们需要把它展示在一个列表页中,组件可展示尺寸为 270 * 190,这时,我们实际上只需要一张原图的低分辨率的缩略图即可(与图片显示所对应的 UI 控件匹配),那么实际上 270 * 190 像素的图片,只需要 0.2M 的内存即可。
    • 可以看到,优化前后相差了 98 倍,原来显示 1 张,现在可以显示 98 张图片,效果非常显著。
  • 既然在对原图缩放可以显著减少内存大小,那么我们应该如何操作呢?

    • 先加载到内存,再进行操作吗,可以如果先加载到内存,好像也不太对,这样只接占用了 19.6M + 0.2M 2份内存了,而我们想要的是,在原图不加载到内存中,只接将缩放后的图片加载到内存中,可以实现吗?
  • BitmapFactory 提供了从不同资源创建 Bitmap 的解码方法:

    • decodeByteArray()、decodeFile()、decodeResource() 等。但是,这些方法在构造位图的时候会尝试分配内存,也就是它们会导致原图直接加载到内存了,不满足我们的需求。我们可以通过 BitmapFactory.Options 设置一些附加的标记,指定解码选项,以此来解决该问题。
    • 如何操作呢?答案来了:将 inJustDecodeBounds 属性设置为 true,可以在解码时避免内存的分配,它会返回一个 null 的 Bitmap ,但是可以获取 outWidth、outHeight 和 outMimeType 值。利用该属性,我们就可以在图片不占用内存的情况下,在图片压缩之前获取图片的尺寸。
  • 怎样才能对图片进行压缩呢?

    • 通过设置BitmapFactory.Options中inSampleSize的值就可以实现。其计算方式大概就是:计算出实际宽高和目标宽高的比率,然后选择宽和高中最小的比率作为inSampleSize的值,这样可以保证最终图片的宽和高。
3.2.2 设置BitmapFactory.Options属性
  • 大概步骤如下所示

    • 要将BitmapFactory.Options的inJustDecodeBounds属性设置为true,解析一次图片。注意这个地方是核心,这个解析图片并没有生成bitmap对象(也就是说没有为它分配内存控件),而仅仅是拿到它的宽高等属性。
    • 然后将BitmapFactory.Options连同期望的宽度和高度一起传递到到calculateInSampleSize方法中,就可以得到合适的inSampleSize值了。这一步会压缩图片。
    • 之后再解析一次图片,使用新获取到的inSampleSize值,并把inJustDecodeBounds设置为false,就可以得到压缩后的图片了。此时才正式创建了bitmap对象,由于前面已经对它压缩了,所以你会发现此时所占内存大小已经很少了。
  • 具体的实现代码

    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); 
        // 使用获取到的inSampleSize值再次解析图片 
        options.inJustDecodeBounds = false; 
        return BitmapFactory.decodeResource(res, resId, options); 
    }
    
  • 思考:inJustDecodeBounds这个参数是干什么的?

    • 如果设置为true则表示decode函数不会生成bitmap对象,仅是将图像相关的参数填充到option对象里,这样我们就可以在不生成bitmap而获取到图像的相关参数了。
  • 为何设置两次inJustDecodeBounds属性?

    • 第一次:设置为true则表示decode函数不会生成bitmap对象,仅是将图像相关的参数填充到option对象里,这样我们就可以在不生成bitmap而获取到图像的相关参数。
    • 第二次:将inJustDecodeBounds设置为false再次调用decode函数时就能生成bitmap了。而此时的bitmap已经压缩减小很多了,所以加载到内存中并不会导致OOM。

3.3 图片质量压缩

  • 在Android中,对图片进行质量压缩,通常我们的实现方式如下所示:

    //quality 为0~100,0表示最小体积,100表示最高质量,对应体积也是最大
    bitmap.compress(Bitmap.CompressFormat.JPEG, quality , outputStream);
    
  • 在上述代码中,我们选择的压缩格式是CompressFormat.JPEG,除此之外还有两个选择:

    • 其一,CompressFormat.PNG,PNG格式是无损的,它无法再进行质量压缩,quality这个参数就没有作用了,会被忽略,所以最后图片保存成的文件大小不会有变化;
    • 其二,CompressFormat.WEBP,这个格式是google推出的图片格式,它会比JPEG更加省空间,经过实测大概可以优化30%左右。
  • Android质量压缩逻辑,函数compress经过一连串的java层调用之后,最后来到了一个native函数:

    • 具体看:Bitmap.cpp,最后调用了函数encoder->encodeStream(…)编码保存本地。该函数是调用skia引擎来对图片进行编码压缩。

3.4 双线性采样压缩

  • 双线性采样(Bilinear Resampling)在 Android 中的使用方式一般有两种:

    bm = Bitmap.createScaledBitmap(bitmap, bitmap.getWidth()/2, bitmap.getHeight()/2, true);
    
    //或者直接使用 matrix 进行缩放
    Matrix matrix = new Matrix();
    matrix.setScale(0.5f, 0.5f);
    bm = Bitmap.createBitmap(bitmap, 0, 0, bit.getWidth(), bit.getHeight(), matrix, true);
    
  • 看源码可以知道createScaledBitmap函数最终也是使用第二种方式的matrix进行缩放

    • 双线性采样使用的是双线性內插值算法,这个算法不像邻近点插值算法一样,直接粗暴的选择一个像素,而是参考了源像素相应位置周围2x2个点的值,根据相对位置取对应的权重,经过计算之后得到目标图像。

3.5 高清图分片加载

  • 适用场景 : 当一张图片非常大 , 在手机中只需要显示其中一部分内容 , BitmapRegionDecoder 非常有用 。
  • 主要作用 : BitmapRegionDecoder 可以从图像中 解码一个矩形区域 。相当于手在滑动的过程中,计算当前显示区域的图片绘制出来。
  • 基本使用流程 : 先创建,后解码 。调用 newInstance 方法 , 创建 BitmapRegionDecoder 对象 ;然后调用 decodeRegion 方法 , 获取指定 Rect 矩形区域的解码后的 Bitmap 对象

3.6 图片综合压缩

  • 一般情况下图片综合压缩的整体思路如下:

    • 第一步进行采样率压缩;
    • 第二步进行宽高的等比例压缩(微信对原图和缩略图限制了最大长宽或者最小长宽);
    • 第三步就是对图片的质量进行压缩(一般75或者70);
    • 第四步就是采用webP的格式。
  • 关于图片压缩的综合案例如下

04.色彩格式及内存优化

4.1 RGB颜色种类

  • RGB 色彩模式是工业界的一种颜色标准

    • 通过对红(R)、绿(G)、蓝(B)三个颜色通道的变化以及它们相互之间的叠加来得到各式各样的颜色的,RGB即是代表红、绿、蓝三个通道的颜色,这个标准几乎包括了人类视力所能感知的所有颜色,是运用最广的颜色系统之一。Android 中,像素的存储方式使用的色彩模式正是 RGB 色彩模式。

4.2 ARGB色彩模式

  • 在 Android 中,我们常见的一些颜色设置,都是 RGB 色彩模式来描述像素颜色的,并且他们都带有透明度通道,也就是所谓的 ARGB。例如,我们常见的颜色定义如下:

    //在代码中定义颜色值:蓝色
    public final int blue=0xff0000ff;
    
    //或者在xml中定义:
    <drawable name="blue">#ff0000ff</drawable>  
    
  • 以上设置中,颜色值都是使用 16 进制的数字来表示的。以上颜色值都是带有透明度(透明通道)的颜色值,格式是 AARRGGBB,透明度、红色、绿色、蓝色四个颜色通道,各占有 2 位,也就是一个颜色通道,使用了 1 个字节来存储。

4.3 改变色彩格式优化

  • Android 中有多种 RGB 模式,我们可以设置不同的格式,来控制图片像素颜色的显示质量和存储空间。

  • Android.graphics.Bitmap 类里有一个内部类 Bitmap.Config 类,它定义了可以在 Android 中使用的几种色彩格式:

    public enum Config {
        ALPHA_8     (1),
        RGB_565     (3),
        @Deprecated
        ARGB_4444   (4),
        ARGB_8888   (5),
        RGBA_F16    (6),
        HARDWARE    (7);
    }
    
  • 解释一下这几个值分别代表了什么含义?我们已经知道了:A 代表透明度、R 代表红色、G 代表绿色、B 代表蓝色。

    • ALPHA_8:表示,只存在 Alpha 通道,没有存储色彩值,只含有透明度,每个像素占用 1 个字节的空间。
    • RGB_565:表示,R 占用 5 位二进制的位置,G 占用了6位,B 占用了 5 位。每个像素占用 2 个字节空间,并且不包含透明度。
    • ARGB_4444:表示,A(透明度)、R(红色)、G(绿色)、B(蓝色)4个通道各占用 4 个 bit 位。每个像素占用 2 个字节空间。
    • ARGB_8888:表示,A(透明度)、R(红色)、G(绿色)、B(蓝色)4个通道各占用 8 个 bit 位。每个像素占用 4 个字节空间。
    • RGBA_F16:表示,每个像素存储在8个字节上。此配置特别适合广色域和HDR内容。
    • HARDWARE:特殊配置,当位图仅存储在图形内存中时。 此配置中的位图始终是不可变的。
  • 那么开发中一般选择哪一种比较合适呢

    • Android 中的图片在加载时,默认的色彩格式是 ARGB_8888,也就是每个像素占用 4 个字节空间,一张 2700 * 1900 像素的照片,加载到内存就需要 19.6M 内存空间(2592 * 1936 * 4 bytes)。
    • 如果图片在 UI 组件中显示时,不需要太高的图片质量,例如显示一张缩略图(不透明图片)等场景,这时,我们就没必要使用 ARGB_8888 的色彩格式了,只需要使用 RGB_565 模式即可满足显示的需要。
    • 那么,我们的优化操作就可以是:
    • 将 2700 * 1900 像素的原图,压缩到原图的低分辨率的缩略图 270 * 190 像素的图片,这时需要 0.2M 的内存。也就是从 19.6M内存,压缩为 0.2 M内存。
    • 我们还可以进一步优化色彩格式,由 ARGB_8888 改为 RGB_565 模式,这时,目标图片需要的内存就变为 270 * 190 * 2 = 0.1M 了。图片内存空间又减小了一倍。

05.缓存的使用实践优化

5.1 Lru内存缓存

  • LruCache 类特别适合用来缓存 Bitmap,它使用一个强引用的 LinkedHashMap 保存最近引用的对象,并且在缓存超出设定大小时,删除最近最少使用的对象。

  • 给 LruCache 确定一个合适的缓存大小非常重要,我们需要考虑几个因素:

    • 应用剩余多少可用内存?
    • 需要有多少张图片同时显示到屏幕上?有多少图片需要准备好以便马上显示到屏幕?
    • 设备的屏幕大小和密度是多少?高密度的设备需要更大的缓存空间来缓存同样数量的图片。
    • Bitmap 的尺寸配置是多少,花费多少内存?
    • 图片被访问的频率如何?如果其中一些比另外一些访问更频繁,那么我们可能希望在内存中保存那些最常访问的图片,或者根据访问频率给 Bitmap 分组,为不同的 Bitmap 组设置多个 LruCache 对象。
    • 是否可以在缓存图片的质量和数量之间寻找平衡点?有时,保存大量低质量的 Bitmap 会非常有用,加载更高质量的图片的任务可以交给另外一个后台线程处理。
    • 缓存太小会导致额外的花销却没有明显的好处,缓存太大同样会导致 java.lang.OutOfMemory 的异常,并且使得你的程序只留下小部分的内存用来工作(缓存占用太多内存,导致其他操作会因为内存不够而抛出异常)。所以,我们需要分析实际情况之后,提出一个合适的解决方案。
  • LruCache是Android提供的一个缓存类,通常运用于内存缓存

    • LruCache是一个泛型类,它的底层是用一个LinkedHashMap以强引用的方式存储外界的缓存对象来实现的。
    • 为什么使用LinkedHashMap来作为LruCache的存储,是因为LinkedHashMap有两种排序方式,一种是插入排序方式,一种是访问排序方式,默认情况下是以访问方式来存储缓存对象的;LruCache提供了get和put方法来完成缓存的获取和添加,当缓存满时,会将最近最少使用的对象移除掉,然后再添加新的缓存对象。如下源码所示,底层是LinkedHashMap。
    public LruCache(int maxSize) {
        if (maxSize <= 0) {
            throw new IllegalArgumentException("maxSize <= 0");
        }
        this.maxSize = maxSize;
        this.map = new LinkedHashMap<K, V>(0, 0.75f, true);
    }
    
  • 在使用LruCache的时候,首先需要获取当前设备的内存容量,通常情况下会将总容量的八分之一作为LruCache的容量,然后重写LruCache的sizeof方法,sizeof方法用于计算缓存对象的大小,单位需要与分配的容量的单位一致;

    // 获取系统最大缓存
    int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);
    // set LruCache size;
    // 使用最大可用内存值的1/8作为缓存的大小
    int cacheSize = maxMemory / 8;
    LruCache<String, Bitmap> memoryCache = new LruCache<String, Bitmap>(cacheSize) {
        @Override
        protected int sizeOf(@NonNull String uri, @NonNull Bitmap bitmap) {
            // 重写此方法来衡量每张图片的大小,默认返回图片数量
            return bitmap.getRowBytes() * bitmap.getHeight() / 1024;
        }
    };
    //插入对象
    memoryCache.put(key, bitmap);
    //取出对象
    memoryCache.get(key);
    
  • 如何淘汰缓存

    • 这个就要看LinkedHashMap集合的特点呢!LinkedHashMap 构造函数的第三个参数:accessOrder,传入true时, 元素会按访问顺序排列,最后访问的在遍历器最后端。在进行淘汰时,移除遍历器前端的元素,直至缓存总大小降低到指定大小以下。

5.2 Lru内存注意事项

  • 看一个真实的场景

    • 假设我们的LruCache可以缓存80张,每次刷新从网络获取20张图片且不重复,那么在刷新第五次的时候,根据LruCache缓存的规则,第一次刷新的20张图片就会从LruCache中移出,处于等待被系统GC的状态。如果我们继续刷新n次,等待被回收的张数就会累积到 20 * n 张。
  • 会出现什么问题

    • 会出现大量的Bitmap内存碎片,我们不知道系统什么时候会触发GC回收掉这些无用的Bitmap,对于内存是否会溢出,是否会频繁GC导致卡顿等未知问题。
  • 解决方案该怎么做?

    • 第一种:在3.0以后引入了 BitmapFactory.Options.inBitmap,如果设置此项,需要解码的图片就会尝试使用该Bitmap的内存,这样取消了内存的动态分配,提高了性能,节省了内存。
    • 第二种:把处于无用的状态的Bitmap放入SoftReference。SoftReference引用的对象会在内存溢出之前被回收。
    • 关于Lru缓存案例和代码可以参考:AppLruCache

5.3 使用Lru磁盘缓存

  • 内存缓存能够提高访问最近用过的 Bitmap 的速度,但是我们无法保证最近访问过的 Bitmap 都能够保存在缓存中。像类似 GridView 等需要大量数据填充的控件很容易就会用尽整个内存缓存。另外,我们的应用可能会被类似打电话等行为而暂停并退到后台,因为后台应用可能会被杀死,那么内存缓存就会被销毁,里面的 Bitmap 也就不存在了。一旦用户恢复应用的状态,那么应用就需要重新处理那些图片。
  • 磁盘缓存可以用来保存那些已经处理过的 Bitmap,它还可以减少那些不再内存缓存中的 Bitmap 的加载次数。当然从磁盘读取图片会比从内存要慢,而且由于磁盘读取操作时间是不可预期的,读取操作需要在后台线程中处理。
  • 注意:如果图片会被更频繁的访问,使用 ContentProvider 或许会更加合适,比如在图库应用中。
  • 注意:因为初始化磁盘缓存涉及到 I/O 操作,所以它不应该在主线程中进行。但是这也意味着在初始化完成之前缓存可以被访问。为了解决这个问题,在上面的实现中,有一个锁对象(lock object)来确保在磁盘缓存完成初始化之前,应用无法对它进行读取。
  • 内存缓存的检查是可以在 UI 线程中进行的,磁盘缓存的检查需要在后台线程中处理。磁盘操作永远都不应该在 UI 线程中发生。当图片处理完成后,Bitmap 需要添加到内存缓存与磁盘缓存中,方便之后的使用。

06.不同版本对Bitmap管理

6.1 演变进程

  • Android 2.3.3 (API level 10)以及之前,

    • 一个 Bitmap 的像素数据是存放在 Native 内存空间中的。这些数据与 Bitmap 对象本身是隔离的,Bitmap 本身被存放在 Dalvik 堆中。并且无法预测在 Native 内存中的像素级数据何时会被释放,这意味着程序容易超过它的内存限制并且崩溃。
  • Android 3.0 (API Level 11)开始

    • 像素数据则是与 Bitmap 本身一起存放在 Dalvik 堆中。
  • Android 8.0(Android O)及之后的版本中

    • Bitmap 的像素数据的内存分配又回到了 Native 层,它是在 Native 堆空间进行分配的。

6.2 管理Bitmap内存

  • 管理 Android 2.3.3 及以下版本的内存使用

    • 在 Android 2.3.3 (API level 10) 以及更低版本上,推荐使用 recycle() 方法。 如果在应用中显示了大量的 Bitmap 数据,我们很可能会遇到 OutOfMemoryError 的错误。 recycle() 方法可以使得程序更快的释放内存。
  • 管理 Android 3.0 及其以上版本的内存

    • 从 Android 3.0 (API Level 11)开始,引进了 BitmapFactory.Options.inBitmap 字段。 如果使用了这个设置字段,decode 方法会在加载 Bitmap 数据的时候去重用已经存在的 Bitmap。这意味着 Bitmap 的内存是被重新利用的,这样可以提升性能,并且减少了内存的分配与回收。然而,使用 inBitmap 有一些限制,特别是在Android 4.4 (API level 19)之前,只有同等大小的位图才可以被重用。
  • 管理 Android 8.0 及其以上版本的内存

    • 在 Android 8.0 及其以上版本,处理内存,也遵循 Android 3.0 以上版本同样的方式。同时,图片像素数据存储在 native 层,并且不占用 Java 堆的空间,这也代表着我们拥有更大的图片存储空间,可以加载质量更高、数据更多的图片到内存中。但是,内存依然不是无限的,应用还是要受到手机内存的限制,所以一定要注意这一点。

6.3 提高Bitmap复用

  • Android3.0之后,并没有强调Bitmap.recycle();而是强调Bitmap的复用。

    • 使用LruCache对Bitmap进行缓存,当再次使用到这个Bitmap的时候直接获取,而不用重走编码流程。

    • Android3.0(API 11之后)引入了BitmapFactory.Options.inBitmap字段,设置此字段之后解码方法会尝试复用一张存在的Bitmap。这意味着Bitmap的内存被复用,避免了内存的回收及申请过程,显然性能表现更佳。

    • 使用这个字段有几点限制:

      • 声明可被复用的Bitmap必须设置inMutable为true;
      • Android4.4(API 19)之前只有格式为jpg、png,同等宽高(要求苛刻),inSampleSize为1的Bitmap才可以复用;
      • Android4.4(API 19)之前被复用的Bitmap的inPreferredConfig会覆盖待分配内存的Bitmap设置的inPreferredConfig;
      • Android4.4(API 19)之后被复用的Bitmap的内存必须大于需要申请内存的Bitmap的内存;
      • Android4.4(API 19)之前待加载Bitmap的Options.inSampleSize必须明确指定为1。
  • Bitmap复用的实验,代码如下所示,然后看打印的日志信息

    • 从内存地址的打印可以看出,两个对象其实是一个对象,Bitmap复用成功;
    • bitmapReuse占用的内存(4346880)正好是bitmap占用内存(1228800)的四分之一;
    • getByteCount()获取到的是当前图片应当所占内存大小,getAllocationByteCount()获取到的是被复用Bitmap真实占用内存大小。虽然bitmapReuse的内存只有4346880,但是因为是复用的bitmap的内存,因而其真实占用的内存大小是被复用的bitmap的内存大小(1228800)。这也是getAllocationByteCount()可能比getByteCount()大的原因。
    @RequiresApi(api = Build.VERSION_CODES.KITKAT)
    private void initBitmap() {
        BitmapFactory.Options options = new BitmapFactory.Options();
        // 图片复用,这个属性必须设置;
        options.inMutable = true;
        // 手动设置缩放比例,使其取整数,方便计算、观察数据;
        options.inDensity = 320;
        options.inTargetDensity = 320;
        Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.bg_autumn_tree_min, options);
        // 对象内存地址;
        Log.i("ycBitmap", "bitmap = " + bitmap);
        Log.i("ycBitmap", "ByteCount = " + bitmap.getByteCount() + ":::bitmap:AllocationByteCount = " + bitmap.getAllocationByteCount());
        // 使用inBitmap属性,这个属性必须设置;
        options.inBitmap = bitmap; options.inDensity = 320;
        // 设置缩放宽高为原始宽高一半;
        options.inTargetDensity = 160;
        options.inMutable = true;
        Bitmap bitmapReuse = BitmapFactory.decodeResource(getResources(), R.drawable.bg_kites_min, options);
        // 复用对象的内存地址;
        Log.i("ycBitmap", "bitmapReuse = " + bitmapReuse);
        Log.i("ycBitmap", "bitmap:ByteCount = " + bitmap.getByteCount() + ":::bitmap:AllocationByteCount = " + bitmap.getAllocationByteCount());
        Log.i("ycBitmap", "bitmapReuse:ByteCount = " + bitmapReuse.getByteCount() + ":::bitmapReuse:AllocationByteCount = " + bitmapReuse.getAllocationByteCount());
    
        //11-26 18:24:07.971 15470-15470/com.yc.ycbanner I/ycBitmap: bitmap = android.graphics.Bitmap@9739bff
        //11-26 18:24:07.972 15470-15470/com.yc.ycbanner I/ycBitmap: bitmap:ByteCount = 4346880:::bitmap:AllocationByteCount = 4346880
        //11-26 18:24:07.994 15470-15470/com.yc.ycbanner I/ycBitmap: bitmapReuse = android.graphics.Bitmap@9739bff
        //11-26 18:24:07.994 15470-15470/com.yc.ycbanner I/ycBitmap: bitmap:ByteCount = 1228800:::bitmap:AllocationByteCount = 4346880
        //11-26 18:24:07.994 15470-15470/com.yc.ycbanner I/ycBitmap: bitmapReuse:ByteCount = 1228800:::bitmapReuse:AllocationByteCount = 4346880
    }
    

07.图片其他方面优化

7.1 减少PNG图片的使用

  • 这里要介绍一种新的图片格式:Webp,它是由 Google 推出的一种既保留 png 格式的优点,又能够减少图片大小的一种新型图片格式。
  • 在 Android 4.0(API level 14) 中支持有损的 WebP 图像,在 Android 4.3(API level 18) 和更高版本中支持无损和透明的 WebP 图像。
  • 注意一下,Webp格式图片仅仅只是减少图片的质量大小,并不会减少加载图片后的内存占用。

7.2 切割圆角优化

  • 方案1:直接采用Canvas.clipPath 相关api,裁剪出一个圆角区域。

    • 该方案简单暴力,通用性强。如果只是一个静态的单图视图,该方法问题不大,但如果是复杂页面,滚动的时候,测试就会跟你说,页面卡顿了,要优化。原因就是 Canvas.clip的相关api损耗相对较大。
  • 方案2:系统提供的CardView设置圆角

    • 把原来全工程各个视频控件和图片控件的外层,都加上一层CardView。改造成本大,布局层级更深一层,layout时间加长。
  • 方案3:使用setXfermode法

    • 此种方式就是再new一个相同尺寸的bitmap,然后使用paint.setXfermode(new PorterDuffXfermode(Mode.SRC_IN));先画圆角矩形,再画原始bitmap,然后就得到了一个圆角的bitmap。早期用得较多,占用bitmap双倍内存。
  • 方案4:图片加载库比如Glide,Fresco等

    • 在底层,无非也是使用上面的这两种种方式。早期的使用setXfermode来实现,后来使用BitmapShader实现。使用简单,稳定。
  • 方案5:遮罩

    • 还是使用setXfermode,不过与方法一不同的是:不对图片作任何更改,只在圆角之外再画一层与背景颜色相同的四个角来遮挡,在视觉上造成圆角图片的效果。
  • 那个切割圆角该怎么优化呢?

    • 使用方案3,可以采用自定义view,支持LinearLayout、RelativeLayout、FrameLayout、ConstraintLayout、ImageView、TextView、View、Button等设置圆角。
    • 具体案例可见:RoundCorners

7.3 如何给图片置灰色

  • 大概的操作步骤。具体可以参考:PicCalculateUtils

    • 第一步:获取原始图片的宽高,然后创建一个bitmap可变位图对象。
    • 第二步:创建画板canvas对象,然后创建画笔paint。然后调用canvas.drawBitmap方法绘制图片
    • 第三步:对画笔进行修饰,设置画笔颜色属性,这里使用到了ColorMatrix,核心就是设置饱和度为0,即可绘制灰色内容

7.4 如何处理图片旋转呢

  • 在Android中使用ImageView显示图片的时候发现图片显示不正,方向偏了或者倒过来了。

    • 解决这个问题很自然想到的两步走,首先是要自动识别图像方向,计算旋转角度,然后对图像进行旋转并显示。
  • 识别图像方向

    • 首先在这里提一个概念EXIF(Exchangeable Image File Format,可交换图像文件)。简而言之,Exif是一个标准,用于电子照相机(也包括手机、扫描器等)上,用来规范图片、声音、视屏以及它们的一些辅助标记格式。
    • Exif支持的格式如下:图像;压缩图像文件:JPEG、DCT;非压缩图像文件:TIFF;音频;RIFF、WAV
    • Android提供了对JPEG格式图像Exif接口支持,可以读取JPEG文件metadata信息,参见ExifInterface。这些Metadata信息总的来说大致分为三类:日期时间、空间信息(经纬度、高度)、Camera信息(孔径、焦距、旋转角、曝光量等等)。
  • 关于图像旋转

    • 获取了图片的旋转方向后,然后再设置图像旋转。最后Bitmap提供的静态createBitmap方法,可以对图片设置旋转角度。具体看:PicCalculateUtils

7.5 保存图片且刷相册

  • 大概的操作步骤如下所示。具体可看:ImageSaveUtils

    • 第一步:创建图片文件,然后将bitmap对象写到图片文件中
    • 第二步:通过MediaStore将图片插入到共享目录相册中
    • 第三步:发送通知,通知相册中刷新插入图片的数据。注意,获取图片资源uri刷新即可,避免刷新所有数据造成等待时间过长。

7.6 统一图片域名优化

  • 域名统一

    • 减少了10%+的重复图片下载和内存消耗。同时减少之前多域名图片加载时重复创建HTTPS请求的过程,减少图片加载时间。

7.7 优化H5图片加载

  • 通过拦截WebView图片加载的方式,让原生图片库来下载图片之后传递图片二进制数据给WebView显示。

  • 采用OkHttp拦截资源缓存,下面是大概的思路。缓存的入口从shouldInterceptRequest出发

    • 第一步,拿到WebResourceRequest对象中请求资源的url还有header,如果开发者设置不缓存则返回null
    • 第二步,如果缓存,通过url判断拦截资源的条件,过滤非http,音视频等资源,这个是可自由配置缓存内容比如css,png,jpg,xml,txt等
    • 第三步,判断本地是否有OkHttp缓存数据,如果有则直接读取本地资源,通过url找到对应的path路径,然后读取文件流,组装数据返回。
    • 第四步,如果没有缓存数据,创建OkHttp的Request请求,将资源网络请求交给okHttp来处理,并且用它自带的缓存功能,当然如果是请求失败或者异常则返回null,否则返回正常数据
  • 关于webView图片缓存的方案,可以直接参考:YCWebView

7.8 优化图片阴影效果

  • 阴影效果有哪些实现方式

    • 第一种:使用CardView,但是不能设置阴影颜色
    • 第二种:采用shape叠加,存在后期UI效果不便优化
    • 第三种:UI切图
    • 第四种:自定义View
    • 第五种:自定义Drawable
  • 否定上面前两种方案原因分析?

    • 第一个方案的CardView渐变色和阴影效果很难控制,只能支持线性或者环装形式渐变,这种不满足需要,因为阴影本身是一个四周一层很淡的颜色包围,在一个矩形框的层面上颜色大概一致,而且这个CardView有很多局限性,比如不能修改阴影的颜色,不能修改阴影的深浅。所以这个思路无法实现这个需求。
    • 第二个采用shape叠加,可以实现阴影效果,但是影响UI,且阴影部分是占像素的,而且不灵活。
    • 第三个方案询问了一下ui。他们给出的结果是如果使用切图的话那标注的话很难标,身为一个优秀的设计师大多对像素点都和敏感,界面上的像素点有一点不协调那都是无法容忍的。
  • 网上一些介绍阴影效果方案

    • 所有在深奥的技术,也都是为需求做准备的。也就是需要实践并且可以用到实际开发中,这篇文章不再抽象介绍阴影效果原理,理解三维空间中如何处理偏移光线达到阴影视差等,网上看了一些文章也没看明白或者理解。这篇博客直接通过调用api实现预期的效果。
    • 多个drawable叠加,使用layer-list可以将多个drawable按照顺序层叠在一起显示,默认情况下,所有的item中的drawable都会自动根据它附上view的大小而进行缩放,layer-list中的item是按照顺序从下往上叠加的,即先定义的item在下面,后面的依次往上面叠放
  • 阴影是否占位

    • 使用CardView阴影不占位,不能设置阴影颜色和效果
    • 使用shape阴影是可以设置阴影颜色,但是是占位的
  • 几种方案优缺点对比分析

    • CardView 优点:自带功能实现简单 缺点:自带圆角不一定可适配所有需求
    • layer(shape叠加) 优点:实现形式简单 缺点:效果一般
    • 自定义实现 优点:实现效果好可配置能力高 缺点:需要开发者自行开发
  • 关于解决阴影效果

    • 具体各种方案的对比可以参考这个demo:AppShadowLib

7.9 图片资源的压缩

  • 我们应用中使用的图片,设计师出的原图通常都非常大,他们通常会使用工具,经过一定的压缩,缩减到比较小一些的大小。

  • 但是,这些图片通常都有一定的可压缩空间,我在之前的项目中,对图片进行了二次压缩,整体压缩率达到了 40%~50% ,效果还是非常不错的。

  • 这里介绍下常用的,图片压缩的方法:

    • 使用压缩工具对图片进行二次压缩。
    • 根据最终图片是否需要透明度展示,优先选择不透明的图片格式,例如,我们应该避免使用 png 格式的图片。
    • 对于色彩简单,例如,一些背景之类的图片,可以选择使用布局文件来定义(矢量图),这样就会非常节省内存了。
    • 如果包含透明度,优先使用 WebP 等格式图像。
  • 图片在上线前进行压缩处理,不但可以减少内存的使用,如果图片是网络获取的,也可以减少网络加载的流量和时间。