Android包大小优化之无Alpha通道PNG转JPG的探索

apk的大小与推广成本、转化率有着密不可分的关系,所以对包大小的优化,应做到谓锱铢必较,特别像抖音这样上亿DAU的应用,追求到极极极极极致都不为过。除了常见的APK瘦身方式,还有哪些方式呢?本文是对其中一个想法的探索过程。

1.问题引入

  • PNG图片支持alpha通道,JPG不支持alpha通道,所以PNG图片的位深可能会比JEG的位深大;
  • PNG格式采用的是无损数据压缩算法,JPG采用的压缩比更好的有损数据压缩算法,在有鲜艳明亮的色彩和纹理的图像中,JPG通常比PNG具有更高的压缩比;
  • 综上猜测,如果把无alpha通道的PNG(甚至是有alpha通道但是无透明度的PNG)转变为JPG格式,是否可以使得图片的体积变小,于是有了本文的探索过程。

2.Java相关的 API

Java已经提供了很多API,如BufferedImage、ColorModel、IIOImage、ImageIO、ImageWriter、JPEGImageWriteParam,来帮助进行图像处理。

2.1 BufferedImage

  • BufferedImage是Image的一个子类,Image和BufferedImage的主要作用就是将一副图片加载到内存中。BufferedImage生成的图片在内存里有一个图像缓冲区,利用这个缓冲区我们可以很方便的操作这个图片,通常用来做图片修改操作如大小变换、图片变灰、设置图片透明或不透明等。
  • Java将一副图片加载到内存中的方法是:
BufferedImage bufferedImage = ImageIO.read(new FileInputStream(filePath));
  • 通过BufferedImage得到内存中一张图片到数据实体后,便可以通过它获得图片基本信息,如长、宽、每个像素的值等
BufferedImage image = ImageIO.read(new FileInputStream(file)); //获取位图
image.getHeight();//图像的高
image.getWidth();//图像的宽
//获取图像某一像素的值,返回的int型数据(32位)为ARGB格式,其中ARGB各占8bit
int pixel = image.getRGB(x,y);
//返回图像的类型,如TYPE_INT_RGB、TYPE_INT_ARGB,如果是未知的类型,会返回TYPE_CUSTOM
int type = image.getType(); 
  • API文档:BufferedImage

2.2 ColorModel

  • ColorModel抽象类封装了一系列把像素值转换为色彩分量(R、G、B)和透明度分量(alpha)的方法。
BufferedImage sourceImg = ImageIO.read(new FileInputStream(file));
//通过BufferedImage获得其ColorModel
ColorModel color = sourceImg.getColorModel();
//获得每像素的大小,也即图片的位深度
color.getPixelSize();
//返回一个32位像素值的透明通道分量的值,同理,可获得像素值其他分量的值
color.getAlpha(int pixel);
  • API文档:ColorModel

2.3 ImageIO

  • ImageIO是一个辅助类,提供了一系列的静态方法,可以来获取已经注册了的 ImageReader和 ImageWriter的对对象,以及执行简单编码和解码。
//getImageWritersByFormatName方法返回是所有能够对指定格式进行编码的ImageWriter的迭代器(Iterator),此行代码获取了一个能够对jpg格式编码的ImageWriter
ImageWriter jpgWriter = ImageIO.getImageWritersByFormatName("jpg").next();
//将一个BufferedImage对象以jpg形式写入jpgFile中,用它可以进行简单的图像格式转换
ImageIO.write(newBufferedImage,"jpg",jpgFile);
  • API文档:ImageIO

2.4 IIOImage

  • IIOImage是一个简单的容器类,它聚合了一张图像的图像数据(RenderedImage)、一系列的缩略图以及与图像关联的其他元数据(IIOMetadata,非图像信息)。
  • 构造方法:创建的时候,需要把相关的参数进行注入:
    • RenderedImage image:代表图像的图像信息,RenderedImage是个接口,需要传入其实现类。BufferedImage实现了RenderedImage接口,其对象可作为参数传入。
    • List thumbnails:图像的缩略图信息,可为null
    • IIOMetadata metadata:与图像相关联的其他非图像数据的元数据,可为null
IIOImage(RenderedImaeg image, List thumbnails, IIOMetadata metadata);
//如下,就得到了一个与Buffered所关联的IIOImage对象
IIOImage iioImage = new IIOImage(bufferedImage,null,null);
  • API文档:IIOImage

2.5 JPEGImageWriteParam

  • JPEGImageWriteParam是图像写入文件时的一个参数类,可以通过它设置图像的压缩质量等参数。
//初始化,参数Local代表图像的地理、政治、文化等信息,可为空
JPEGImageWriteParam jpegParams = new JPEGImageWriteParam(null);
//如果支持压缩,必须设置压缩模式,MODE_EXPLICIT模式表示会使用此mageWriteParam中指定的压缩类型和质量设置进行压缩。所有之前设置的compression参数都将被丢弃。
jpegParams.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
//设置压缩质量,取值范围0.0f~1.0f,1.0f代表质量最好;默认0.75f,表示视觉无损
jpegParams.setCompressionQuality(1.0f);
  • API文档:JPEGImageWriteParam

2.6 ImageWriter

  • ImageWriter是用来编码和写入图像的抽象超类。我们进行图片格式转换的时候,主要就是通过它的子类进行写入的。上边介绍类那么多API,其实就是要给它转换图片来用的。
  • 重点关注它的setOutput方法和一系列的write方法:
//设置输出路径,这里虽然传入的是Object对象,但是一般应该传入以下两种对象:
// 1. FileImageOutputStream,用于写入文件
// 2. MemoryCacheImageOutputStream,用于写入内存中
void setOutput(Object output);
//把IIOImage对象关联的对象直接作为输入,写入到输出对象
void write(IIOImage image);
//同上,写入的时候加上元数据、写入参数,我们就应该调用它来完成格式转换
void write(IIOMetadata metadata, IIOImage image, ImageWriteParam param);
//同上,只是输入的对象是RenderedImage的实现类对象
void write(RenderedImage image);
  • API文档:ImageWriter

3.探索过程

我们可以hook Android编译过程,拿到所有的资源文件。由于本文是探索的过程,还未集成到项目里,所以探索的demo是拿的apk包反编译出来的。而且打包过程中已经禁止了AAPT采用内置的压缩算法对图片资源的优化,所以反编译出来的图片资源跟打包前应该是一致的:

aaptOptions {
    cruncherEnabled = false
}

3.1 获取需要处理的PNG图片

3.1.1 根据位深

  • 由于常见的图片色彩模式中,只有ARGB是包含透明通道的,所以可以获取图片的位深,也即是每像素的大小,根据其大小来获取是否包含透明通道。

它只适用于不经过压缩处理的图片,如经过像tinypng、pngguant压缩过的,位深会被压缩,这点千万要注意!如果经过tinypng或者pngquant算法压缩后,是可能出现虽然包含透明通道,但是位深(每像素大小)是4、8、16、24甚至是1的,具体原理涉及到压缩算法,这里不进行深究。

if(file.getName().endsWith(".png") 
    && !file.getName().contains(".9.png")
    && getPngBitDepth(file) != 32) {
        //do convert
    }

private static int getPngBitDepth(File file) throws IOException {
    BufferedImage sourceImg = ImageIO.read(new FileInputStream(file));
    ColorModel color = sourceImg.getColorModel();
    return color.getPixelSize();
}

3.1.2 根据是否包含alpha通道

  • 从上述API的介绍里也了解到了,可以通过ColorModel直接获取图片各个分量上的值的,当然也就可以通过它判断图片否包含alpha通道。
if(file.getName().endsWith(".png") 
    && !file.getName().contains(".9.png")
    && !constainsAlphaChannel(file) {
        //do convert
    }

private static boolean constainsAlphaChannel(File file)  throws IOException{
    BufferedImage sourceImg = ImageIO.read(new FileInputStream(file));
    ColorModel color = sourceImg.getColorModel();
    return color.hasAlpha();
}

3.1.3 根据是否包含透明度像素

  • 有的png图片虽然包含了透明通道,但并未使用,可遍历每张图片上的像素点,把不包含透明度的图片全部找出来,进行转换,扩大转换范围。
if(file.getName().endsWith(".png") 
    && !file.getName().contains(".9.png")
    && !constainsAlphaChannel(file) {
        //do convert
    }
private static boolean containsTransparency(File file) throws FileNotFoundException, IOException{
    BufferedImage image = ImageIO.read(new FileInputStream(file));
    for (int i = 0; i < image.getHeight(); i++) {
        for (int j = 0; j < image.getWidth(); j++) {
            if (isTransparent(image, j, i)){
                return true;
                }
            }
        }
        return false;
    }

public static boolean isTransparent(BufferedImage image, int x, int y ) {
    int pixel = image.getRGB(x,y);
    return (pixel>>24) == 0x00; //透明通道在高8位,根据其是否为0判断是否包含透明通道
}
  • 本文进行的验证都是通过第三种来的,需要转换的png图片范围会比不包含alpha通道的图片集稍微大些

3.2 图像转换

本节进行转换的是debug版本的APK反编译出来的目录,release版本的会在下一节阐述。

3.2.1 ImageIO进行转换

  • 最开始找到的png转jpg的方法是使用ImageIO,这种方式也是网上能找到的比较多的方法,使用它转换的时候要注意,由于jpg是不包含alpha通道的,所以转换过程中需要先画一个背景,具体颜色自己可以设置:
private static void convertPNG2JPG(File pngFile, File jpgFile) throws IOException {
    BufferedImage bufferedImage = ImageIO.read(pngFile);
    BufferedImage newBufferedImage = new BufferedImage(bufferedImage.getWidth(),bufferedImage.getHeight(),BufferedImage.TYPE_INT_RGB);
    //创建BufferedImage,并绘制白色的背景
    newBufferedImage.createGraphics().drawImage(bufferedImage, 0, 0, Color.WHITE, null);
    ImageIO.write(newBufferedImage,"jpg",jpgFile);
}

通过上述介绍过API以后,这段代码不难理解了,就是通过ImageIO把创建的BufferedImage以jpg形式写回文件。
通过这次转换以后,输出文件大小对比:

png total size:4226.41KB
jpg total size:1103.19KB

可以看到,大小减少了很多,但是看看成像质量,发现画质损失的有点严重啊:


画质对比

左边是png原图,右边是jpg,放大后可以看到边缘损失很大。

  • 跟踪源码发现,ImageIO.write()方法的内部其实也是通过IIOImage调用了ImageWrite.write方法,只不是压缩质量设置的是默认的0.75f,那有没有可以设置压缩质量的转换方法呢?

3.2.2 ImageWriter.Write进行转换

  • 经过调研,找到了以下方式进行图片格式转换。通过上述API的讲解,代码很好理解,这种方式对图像的操作也更加灵活:
private static void convertPNG2JPG_2(File pngFile, File jpgFile) throws FileNotFoundException, IOException {
    BufferedImage bufferedImage = ImageIO.read(pngFile);
    JPEGImageWriteParam jpegParams = new JPEGImageWriteParam(null);
    jpegParams.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
    //这里设置压缩质量
    jpegParams.setCompressionQuality(1.0f);
    ImageWriter jpgWriter = ImageIO.getImageWritersByFormatName("jpg").next();
    jpgWriter.setOutput(new FileImageOutputStream(jpgFile));
    IIOImage iioImage = new IIOImage(bufferedImage,null,null);
    jpgWriter.write(null, iioImage,jpegParams);
    jpgWriter.dispose();
}

注意,使用上述API,不要采用JDK11,否则会报出以下错误,不好排查,改用JDK1.8后可用。

Android包大小优化之无Alpha通道PNG转JPG的探索_第1张图片
调用上述API后发生了Native层的奔溃
  • 先把压缩质量的参数设置为1.0f,把PNG图像转换为JPG,输入文件大小:
png total size:4226.41KB
jpg total size:5012.55KB

可以看到大小不减少反而增大了,进一步验证了,并不是所有的情况,png转换为jpg,图像大小都会变小,详细说明可看参考链接。
看看成像质量:


压缩参数是1.0f时的成像对比

左边是png,右边是转换后的jpg,虽然画面的通透性感觉有点改变,画质还是可以的,可是大小却变大了

  • 再按照默认的0.75的质量进行转换,输出文件大小:
png total size:4226.41KB
jpg total size:1479.10KB

可以看到,文件大小从原来png的4226KB变成了1479KB,来看看成像质量:


压缩参数是0.75f时的成像对比

左边是png原图,右边是转换成jpg后的图像。同样可以发现存在肉眼可见的画质损失。

  • 通过反复测试,发现当压缩质量参数为0.9f的时候,画质与大小得到了平衡。体积大小相比0.75f时增加不多。
    大小输出对比:
png total size:4226.41KB
jpg total size:2036.71KB

画质对比:


压缩参数是0.9f时的成像对比

可以看到图像的质量还是可以的,没有明显的糊边了。

4.进一步探索

上一节的探索都是在debug版本的APK反编译进行的,由于抖音的图片资源在打release包的时候会经过McImage的优化,期间会用pngguant算法进行压缩,思考,如果此时我将压缩后的png的图片进行上述转换,会发生什么情况呢?

4.1 release版本探索

压缩质量设置为0.9,通过上述程序转换,输出文件大小:

png total size:415.09KB
jpg total size:595.10KB

首先看到的是,能检测出来不包含alpha像素的png图片的数量少了很多,猜测这个可能是用pngquant压缩后与Java API的检测有关,具体源码不深究了。
我们来看此时图像的成像质量,扫描出的不包含alpha像素的png图片:


Android包大小优化之无Alpha通道PNG转JPG的探索_第2张图片
png目录

而发现jpg中有好多转换失败的黑图:


Android包大小优化之无Alpha通道PNG转JPG的探索_第3张图片
转换后的jpg目录

不用挑样张来对比成像质量了,这是绝对不允许的,所以通过算法压缩后的图像,转换后,不仅体积变大,而且还有很多转换失败的。

所以,上述的转换,一定要是针对未通过其他算法进行压缩后的图像资源。

4.2 转换成jpg后,还可以通过tinypng压缩吗?

依然回到debug版本的资源上,进一步探索,看转换后的图像,是否可以通过tinypng压缩。

  • 把压缩后的jpg,通过tinypng进行压缩(一次20张,分批次进行压缩)
    压缩后,得到的图像大小如图,换算成KB是1099KB


    Android包大小优化之无Alpha通道PNG转JPG的探索_第4张图片
    转换为jpg后又通过tinypng进行压缩后的大小

    整个过程的大小变化:

step 1 扫出需要转换的png原图 -> 4226KB
step 2 上述png转成jpg -> 2036KB
setp 3 上述的jpg经过tinypng压缩 -> 1099KB

压缩后,发现有些图像也被损坏,很多图片出现了奇怪的背景颜色:


Android包大小优化之无Alpha通道PNG转JPG的探索_第5张图片
经过tinypng压缩后,有的图像出现了损坏

虽然图片大小体积进一步变小,但是图像出现了损坏,这种情况也是不可取的。

4.3png直接用tinypng压缩

  • png转jpg再进行tinypng压缩后,大小虽然小了很多,但是图像在tinypng压缩的时候失败了。那么直接把png进行tinypng进行压缩,大小和成像质量会怎么样呢?
    通过tinypng将png压缩后,得到对大小如图,换算成KB,是1300KB。


    Android包大小优化之无Alpha通道PNG转JPG的探索_第6张图片
    直接把需要转换的图片放到tinypng上进行压缩后的目录大小

    大小从原来的4226KB减小到了1300KB,减小了很多,现在来看下成像质量:


    png原图与tinypng压缩后的成像对比

    同样,左边是png原图,右边是tinypng压缩后的,不得不承认,tinypng压缩真的很优秀,肉眼看去,跟原图无异啊。

5. 结论与思考

  • 如果不考虑使用其他算法对图片进行压缩,把不包含透明度的png转换为jpg,体积大小通常情况下会大大减少;
  • 压缩质量参数可根据成像质量自行设定,官方建议0.75f,属于视觉无损;
  • 如果要用压缩算法对图片进行压缩,不建议进行格式转换,无论是转换前压缩还是转换后压缩,图像都可能会损坏;
  • tinypng压缩算法还是相当优秀的,体积大小缩小很多,画质肉眼几乎看不到损失,良心推荐啊;
  • 通过上述介绍的几个Java API,我们对图片对控制是可以达到每个像素的粒度,拿到这些信息后是可以做很多事情的,比如:结合图像识别算法,可以判断图片的相似度。
  • jpg不包含透明通道,png包含透明通道。通常情况下,在有明亮的色彩与纹理的图像中,位深相同的情况下,jpg比png图像拥有更高的压缩比。我个人的理解是,图片位深越大,压缩比越大,详情可查看下方链接。

广告时间

字节跳动各Android客户端团队招人火爆进行中,各个级别和应届实习生都需要,业务增长快、日活高、挑战大、待遇给力,各位大佬走过路过千万不要错过!

本科以上学历、对技术有热情,欢迎加我的微信详聊:spq951992006

Android包大小优化之无Alpha通道PNG转JPG的探索_第7张图片
欢迎来扫

参考链接

Comparison of different image compression formats

你可能感兴趣的:(Android包大小优化之无Alpha通道PNG转JPG的探索)