也谈图片压缩

4,566 阅读20分钟
原文链接: zhengxiaoyong.me

简介

随着目前设备像素的不断提高,基本随便一张照片即是M级别的大小,对于如此大的图片,不管是在内存空间、带宽资源和服务器数据空间上都是非常耗费的,特别是在移动端,由图片引起的OOM和图片上传质量过大等问题我想大家都遇到过,所以对于图片内存占用上和物理空间占用上进行压缩很有必要,在Android上,我们使用到的图片格式无非这五种:PNG、JPEG、Webp、SVG、GIF。其中GIF的位深为8位,所以文件通常比较小而且支持alpha通道以及动画,Webp在等质量的大小上和等大小的清晰度上都占极大优势,而SVG矢量图是由xml文件进行描述的,可以适配于任何分辨率的设备而保证图像不失真,Google的官方视频中也提到可用这两种格式进行某些场景下替换PNG或JPEG图像,这不但能节约带宽资源还能提高图片加载速度,所以图片压缩主要是对PNG和JPEG这两种格式,关于图片的压缩,有无损压缩和有损压缩两种方式,这两种压缩方式区别如下:

无损压缩:通过对冗余数据的存储方式进行优化,该方式不会丢失文件内容,压缩率受冗余度的影响,所以压缩率较低
有损压缩:通过丢失不会对文件造成太大影响的数据来达到压缩效果,所以压缩率较高

其中PNG是无损压缩格式图片,JPEG是有损压缩格式图片,所以对应的也有各自的压缩算法,在Android系统中,png的压缩是使用libpng进行压缩,场景有两个:编译阶段以及api层调用方式进行压缩。其中在编译阶段通过aapt打包工具会对drawable目录下png图片进行压缩,压缩率大约在40%以下,如果我们对编译后的apk进行解压,可以发现解压后drawable目录下的png图片比原先的变小了,但是,也有例外,对于NinePatch(.9)图片却变大了,这里先讲下原因,因为对于.9图片在编译过程中aapt会对它额外进行处理,使得.9图片会增加2~3个不同类型的Chunk块(:api层调用方式进行压缩不会对.9进行额外处理),而jpeg的压缩是用libjpeg(7.0后有变化,后面另外说)进行压缩,场景只有在api层进行调用方式进行压缩。下面将主要围绕图片的压缩原理、压缩策略以及在Android上的运用进行讲解。

色彩空间

在此之前,我们先了解一下ColorSpace(色彩空间),通常来说,我们看一张彩色图片,会有几千甚至上万种颜色构成,色彩空间就是用来表示图片所构成的色彩范围,对于图片最常用的是使用三原色组成的RGB色彩空间,它最大可表示2^24(16777216)种颜色,其它的色彩空间还有YUVCMYKYCCK等,其中YUV在视频的开发方面会经常涉及到,它主要用于表示彩色视频中彩色图像的颜色空间,为什么使用这种呢?因为它节约带宽,每个像素位深最大不超过12位,最小为6位,在此不过多描述,了解即可,顺便提一点平时在进行视频相关开发时,进行视频采集到的图像数据都是YUV编码的,如果需保存某一帧图像,需把图像数据编码方式YUV转成RGB

上面说到RGB色彩空间,它根据每个分量的所占位数不同又可以分为这两种:RGB_565RGB_888,其中带alpha通道的有这两种:ARGB_4444ARGB_8888,区别如下:

RGB_565:每个像素占两个字节,R分量占5位,G分量占6位,B分量占5位,最多能表示2^16(65536)中颜色
RGB_888:每个像素占三个字节,R、G、B分量各占8位,最多能表示2^24(16777216)中颜色
ARGB_4444:每个像素占两个字节,A、R、G、B分量各占4位,最多能表示2^12(4096)中颜色,成像效果比较差,所以Google给了它一个Deprecated,并且v4.4+后如果使用了它会自动转成用ARGB_8888
ARGB_8888:每个像素占四个字节,A、R、G、B分量各占8位,最多能表示2^24(16777216)中颜色,其中前面8位alpha(0~255)通道表示每个像素点的透明度

存储形式

图片的存储形式,主要以以下三种形式存在:File、Stream、Bitmap。其中在Android上File主要有PNG、JPEG、XML(VectorDrawable)、Webp和GIF这五种类型格式进行存储,下面分别对这三种存储形式以及压缩方式进行分析。

PNG

PNG是一种无损压缩的图像存储格式,正由于它使用的是无损压缩算法进行压缩,所以相同像素宽高的图像保存为PNG在文件大小上比JPEG往往要大的多,一般是JPEG大小的几倍左右。由于无损压缩不会丢失图像数据,并且支持alpha通道而且完整的保存了图像数据且无锯齿,所以一般应用在PS素材或图标上,这就为什么不管Android和iOS图标都是使用的png格式。

PNG图像根据每个像素位数的不同,可分为三种格式:PNG8PNG24PNG32。PNG8只支持256色,有索引色透明和Alpha透明两种方式,索引色透明只能简单的指明一个像素点为透明还是不透明,Alpha透明则支持像素点的透明度,PNG24支持全色1670万色,只支持不透明,PNG32支持全色1670万色,在PNG24基础上增加了8位的alpha分量,支持Alpha透明,目前大部分PNG图片使用的格式大都为PNG32。

PNG数据结构

一个标准PNG图像文件数据结构如下:

其中一个最简单的PNG图像应该至少包含PNG文件的签名Signature、文件头数据块IHDR、图像内容数据块IDAT以及图像结束数据块IEND,这三个的数据块叫做关键数据块,是每一个PNG文件必须包含的,否则PNG文件将无法正常显示,另外还有辅助数据块,如:PLTE(调色板数据块,仅用于索引PNG)、tEXt(文本信息数据块),这些辅助数据块是可选的,用于额外表示一个PNG文件的内容。
这些Chunk都由四部分内容组成:

含义如下:

Length:占4个字节,表示该Chunk中Data域的长度
Type:占4个字节,表示该Chunk的类型,如:IHDR、IDAT等
Data:占n个字节,存储着该Chunk的数据
CRC:占4个字节,循环冗余校验码

下面主要说下这三个关键数据块,它们表示的含义如下:

Signature:占8个字节,用于表示该文件是一个PNG文件,内容固定
IHDR:占25字节,其中Data域占13个字节,用于表示图像的基本信息,如图像的宽高与位深等,并且它永远都是第一个数据块
IDAT:占n个字节,用于表示图像的数据信息,它存储真实的图像数据,在一个PNG文件中,该数据块出现的数量为>=1
IEND:占12个字节,用于表示数据块内容已结束,永远都是最后一个数据块,内容固定

如下,我们查看一张最简单的PNG文件结构:

可以看到这张PNG图像只包含了SignatureIHDR、一个IDAT以及结束数据块IEND,可以说是最.简单的PNG图像。

前8个字节:


描述的Signature为ASCII字符.PNG表示.该文件为PNG文件。
后续的25个字节:


描述的是IHDR头数据块,其中前4个字节00 00 00 0D表示该数据块Data域的长度为13字节,然后是4个字节49 48 44 52描述的该数据块类型,对应的ASCII字符为IHDR,接下来是数据块真正存储的数据Data域,最后是4个字节的CRC校验码。关于IHDR数据块的Data,主要有四个我们比较关心的数据:图像宽高、色深以及颜色类型。其中宽和高各占4个字节,位深和颜色类型占1个字节,对应字节为:

.

从中可以得知该PNG图像宽和高都为00 00 00 30(48px),色深为8bit,颜色类型为6(6代表带alpha通道的彩色图像)。

更多细节可以参考PNG百科以及W3C Introduce

NinePatch(.9)

NinePatch(.9)图片是Android上一个可动态伸缩的PNG图片,为什么它具有这种特性呢?原理是在PNG图像基础上添加一个额外的1像素的边框来描述动态伸缩与内容填充的区域,然后在编译打包时通过aapt工具对.9图进行额外处理,具体是提取所添加的1像素边框的信息,这些信息会通过额外类型npTcnpOlChunk数据块保存在PNG文件中,当在图片加载时,会在判断该图片是否为.9图来选择性的构造一个NinePatchDrawable还是BitmapDrawable对象,NinePatchDrawable即是一个可对内容区域进行动态伸缩的Drawable,判断是否为.9图以及构造一个NinePatchD.rawable代码为:

if (npChunk != null && NinePatch.isNinePatchChunk(npChunk)) {
NinePatchDrawable npDrawable = new NinePatchDrawable(getResources(), bitmap, npChunk, new Rect(), null);
//...
}

一个.9图片可以由自带的draw9patch工具进行制作,制作后文件的后缀为.9.png

PNG压缩

关于对PNG图片的压缩,Android默认使用的是libpng库进行PNG图片的压缩,场景有两个地方:aapt打包时和bitmap.compress()时。
所以对于Android中PNG的压缩或想获取更好的压缩率,我们有两种做法:

1、屏蔽在aapt打包时默认的libpng的压缩,我们自己使用第三方压缩工具进行png图像的压缩
2、对于api层面,使用自己编译的lib库替换系统的api进行png的压缩

对于一些第三方png压缩工具,有:PngquantPngoutTinyPngOptipn以及ImageOptim等。

通常来说如果我们不满足于在aapt打包时进行的png图片压缩,我们可以通过上面的工具进行png的压缩,此时,必须屏蔽aapt打包时的压缩,为什么呢?避免压缩覆盖,因为每个压缩工具的压缩算法也不同,所以对于压缩只能并且最好只有一次,否则极可能导致经过第三方压缩后再经过libpng压缩,最后的png图片大小并没有发生明显的变化。

我们可以通过gradle的aaptOptions配置来屏蔽aapt打包时对png进行压缩,进而使.用我们自己压缩的png图片,通过以下配置:

    aaptOptions {
cruncherEnabled false
}
}

其中这不会屏蔽对.9图片的处理,所以不影响.9图的使用。

JPEG

JPEG是一种有损压缩的图像存储格式,不支持alpha通道,由于它具有高压缩比,在压缩过程中把重复的数据和无关紧要的数据会选择性的丢失,所以如果不需要用到alpha通道,那么大都图片格式都用该格式。

JPEG数据结构

一张JPEG图片的数据结构大致如下:

JPEG文件主要是由多个segment段组成,每个segment又由标识码压缩数据组成,标识码由两个字节组成,第一个为固定值0xFF,而区分每个标识码的类型主要由第二个进行区分,下面介绍一下常用的标识码:

FFD8:表示图像的开始,段名为SOI
FFE0:表示JFIF数据块,段名为APP0
FFC0:表示图像帧开始,段名为SOFO
FFC4:表示Huffman表,段名为DHT
FFDA:表示从上往下开始扫描图像,段名为SOS
FFD9:表示图像结束,段名为EOI

更多JPEG格式细节可以看JPEG Wiki

JPEG压缩

对于JPEG图片的压缩,文章开头说到了Android默认使用的是libjpeg库进行压缩的,不过在Android7.0+发生了一点点变化,主要是做了两点优化

1、内部使用的JPEG压缩库改为libjpeg-turbo,这是一个基于libjpeg的涡轮增压库,主要的一特点就是速度比libjpeg
2、使用Huffman编码替代Arithmetic编码

上面第二点为主要优化点,有兴趣的同学可以用一台Android7.0+手机以及7.0以下版本的手机,压缩相同一张图片,会发现在相同质量下Android7.0+机子上的压缩后的图片大小比7.0以下的要小。

VectorDrawable

矢量图是通过一系列的xml标签进行描述图像的行为信息,通过xml文件进行存储,使得它文件比一般PNG、JPEG更小,同时具备高度的伸缩性且不失真,但在Android上的矢量图(VectorDrawable)描述标签和广义上的SVG矢量图有些差别,在Android具体的对矢量图进行描述的标签主要是vectorpath,其中path的格式和定义是一样的,矢量图的行为内容描述也主要在该标签中,在支持上:

1、在Android5.0+提供原生支持
2、使用support-library-v23.2+版本提供全版本支持,具体可查看官方博客

WebP

Webp图片格式是Google推出的一个支持alpha通道的有损压缩格式,据Google官方表明,同质量情况下Webp图像要比JPEG、PNG图像小25%~45%左右,在支持上Android4.0+版本提供原生支持,使用libwebp库进行编解码。

GIF

GIF图像最广泛的应用是用于显示动画图像,它具备文件小且支持alpha通道的优点,不过它是由8位进行表示每个像素的色彩,仅支持256色,所以在对色彩要求比较高的场合不太适合。

Stream

图片的存储形式从File转到内存中时,图片内容以字节方式存储在Stream中,此时所占的内存大小为File文件大小。

Bitmap

在Android中,任何图片资源的显示对象都是通过bitmap来显示的,除了xml资源则是通过Canvas来绘制的,所以,对于某些纯色或者规则类的图像,可以通过xml进行描述或Canvas来绘制,这样所占用的内存比通过bitmap来显示将少几个等级。

Bitmap所占用内存的计算:

pixelWidth pixelHeight bytesPerPixel

即Bitmap的宽x高x每像素所占字节数,所以相同一个Bitmap对象,对于每像素占2字节的RGB_565色彩空间所占内存是每像素4字节的ARGB_8888占用内存的一半。

Bitmap与Drawable的联系

关于Bitmap和Drawable的关系,可以看官方的解释,Drawable是一个抽象的概念,来描述某些具备可绘制的的对象,它是一个抽象类,而Bitmap是一个最简单的Drawable实体对象,Bitmap并不继承于Drawable,它们之间建立关联最终是通过BitmapDrawable对象,该对象会把具体的Bitmap实例对象渲染到Canvas上。Drawable更注重描述的是某绘制的行为,而Bitmap则是注重存储着图像的像素信息。

Bitmap存储空间

随着版本的变化以及存储空间的变化,Bitmap的存储空间主要有三个地方
Native Memory
Android2.3以下版本,bitmap像素数据存储在native内存中,释放内存需主动调用recycle()方法
Dalvik Heap
Android3.0+版本,在Android2.3版本引入了并发的垃圾回收器后,在3.0以后的版本bitmap的像素数据则存储在虚拟机堆中,不需要主动调用recycle()来回收内存,gc会主动回收
Ashmem
匿名共享内存空间,说到这个,就会联想起大名鼎鼎的Fresco图片库,它巧妙的利用了这一空间来进行Bitmap对象的存储,对于Ashmem空间,首先想到的是与App进程空间是隔离且互不影响的,这点在Android4.4以下版本是这样的,在Android4.4+后版本,Ashmem空间将会包含在App所占用的内存空间中。看Fresco源码也可以看出,对于4.4+版本,对于Bitmap的解码使用了另外的解码器。在Android4.4以下版本如何使用Ashmem进行bitmap.的存储呢?通过DecodeOptions:

options.inInputShareable = true;

以及通过MemoryFile可将图片的字节数据存储在Ashmem中。

压缩方式

对于图片的压缩方式,针对内存空间来说主要有两个类型:

1、减少虚拟机堆中所占用的内存大小
2、减少在硬盘中所占用的物理内存大小

下面主要说说这两种类型的压缩方式

降低色彩位数

所谓的降低色彩位数,就是降低RGB各个分量的位数,如ARGB_8888RGB_565,每个像素所占字节从4个减小到2个字节,对应的内存大小也节约了一半。

对于每个像素的R、G、B分量的转换过程,即一个分量占用8bit需要转换为5bit(6bit),或者是一个分量占用5bit(6bit)需要转换为8bit,这两个过程叫:压缩补偿

压缩

压缩是各个分量位数的降低

RGB_888RGB_565为例:

RGB_888每个.分量占8位,每个像素的位深为24bit,如下:


RGB_565每个分量分别占5位、6位、5位,每个像素位深为16bit,对于各分量的压缩,避免不了丢失一些精度,为使得精度丢失最小,我们只需取其高位即可,RGB_888RGB_565.的转换后最终每个分量值为:


补偿

补偿是各个分量位数的增加

RGB_565RGB_888为例:
RGB_565每个分量分别占5位.、6位、5位,每个像素位深为16bit,如下:


RGB_888每个分量占8位,每个像素的位深为24bit,对于各个分量的补偿,常用的做法是

用原分量的值进行填充,剩下的用原分量的低位进行循环补偿

RGB_565RGB_888的转换后最终每个分量的值为:

尺寸压缩

尺寸压缩主要是缩减bitmap的大小,当加载一张大图片时,进行合适的尺寸的压缩是减小内存占用的很有效的方法

SamplingSize

采样率压缩,这基本是人人皆知的办法,在图片decode阶段,先获取其宽高然后进行判断是否符合我们.预期的,否则进行一定比例的缩放,代码为:

    BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeFile(filePath, options);
int outWidth = options.outWidth;
int outHeight = options.outHeight;
options.inSampleSize = computeSampleSize(outWidth, outHeight);
return BitmapFactory.decodeFile(filePath, options);
}

这种方式采样率只能支持2的幂次方的值进行缩放,所以一般decode出来的bitmap大小往往不是我们预期的大小,有可能大很多也有可能小很多

Matrix

Matrix矩阵变换,可以对bitmap进行非常多的操作,其中一项是对bitmap进行等比缩放,这种方式可以精确的缩.放到符合我们预期的bitmap大小,代码如下:

int bitmapHeight = bitmap.getHeight();
Matrix matrix = new Matrix();
float rate = computeScaleRate(bitmapWidth, bitmapHeight);
matrix.postScale(rate, rate);
Bitmap result = Bitmap.createBitmap(bitmap, 0, 0, bitmapWidth, bitmapHeight, matrix, true);

设备屏幕密度与drawable关系

我们知道在不同屏幕密度的设备下,会选择最适合该设备的资源目录进行资源的加载,如对于屏幕dpi为480的则会最优选择xxhdpi目录,关于分辨率与dpi以及屏幕密度对应关系为:

Dpi 分辨率 Res Density
160dpi 320x533 mdpi 1
240dpi 480x800 hdpi 1.5
320dpi 720x1280 xhdpi 2
480dpi 1080x1920 xxhdpi 3
560dpi 1440x2560 xxxhdpi 3.5

如果没有对应的目录,则会使用默认目录。假如有一设备dpi为480,并且没有xxdpi目录下没有该图片资源,那么在进行图片资源decode时候,会把图片适配到对应的屏幕分辨率,进行放大或缩小,缩放比例是:

宽、高 * (当前设备dpi/目标资源目录对应的dpi)

对于获取设备的dpi以.及density可由Resources静态方法方便获取:

int densityDpi = displayMetrics.densityDpi;
int density = displayMetrics.density;
int width = displayMetrics.widthPixels;
int height = displayMetrics.heightPixels;

一些建议
对于某些高清无码比较大的图片,如一些背景或者引导图等,可由第三方压缩工具进行压缩后放入assets目录中,避免可能在drawable目录下加载引起的放大导致消耗内存过大或缩小导致图片过小等问题。

质量压缩

对于质量压缩,也就是图片的所占物理内存的大小,主要是通过一些lib库进行压缩,如Android默认的bitmap.compress(),可选择PNG与JPEG等进行压缩,如果不满足于内置的lib压缩库效果,可自己选择替换系统api进行压缩,在上述JPEG这节中说的Android底层所用libjpeg库在7.0+版本变化,主要是进行了JPEG图片压缩的优化,所以为了弥补在Android7.0以下对JPEG压缩的质量问题以及对bitmap压缩进行合理适配,特此写了一个开源库 Tiny,对于JPEG的压缩选择和Android7.0后一样的库libjpeg-turbo库进行压缩,同时也开启了Huffman编码。

Tiny

下面是使用Tiny图片压缩库进行压缩与微信朋友圈的效果对比示例:

图片信息 Tiny Wechat
6.66MB (3500x2156) 151KB (1280x788) 135KB (1280x789)
4.28MB (4160x3120) 219KB (1280x960) 195KB (1280x960)
2.60MB (4032x3024) 193KB (1280x960)) 173KB (1280x960)
372KB (500x500) 38.67KB (500x500) 34.05KB (500x500)
236KB (960x1280) 127KB (960x1280) 118KB (960x1280)

Tiny项目地址为:戳我

Reference

Image compression for Android developers - Google I/O 2016
Android Support Library 23.2
WebP Compression Techniques in Detail
Managing Bitmap Memory