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的探索
文章图片
调用上述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

【Android包大小优化之无Alpha通道PNG转JPG的探索】首先看到的是,能检测出来不包含alpha像素的png图片的数量少了很多,猜测这个可能是用pngquant压缩后与Java API的检测有关,具体源码不深究了。
我们来看此时图像的成像质量,扫描出的不包含alpha像素的png图片:

Android包大小优化之无Alpha通道PNG转JPG的探索
文章图片
png目录
而发现jpg中有好多转换失败的黑图:

Android包大小优化之无Alpha通道PNG转JPG的探索
文章图片
转换后的jpg目录
不用挑样张来对比成像质量了,这是绝对不允许的,所以通过算法压缩后的图像,转换后,不仅体积变大,而且还有很多转换失败的。
所以,上述的转换,一定要是针对未通过其他算法进行压缩后的图像资源。
4.2 转换成jpg后,还可以通过tinypng压缩吗?
依然回到debug版本的资源上,进一步探索,看转换后的图像,是否可以通过tinypng压缩。
  • 把压缩后的jpg,通过tinypng进行压缩(一次20张,分批次进行压缩)
    压缩后,得到的图像大小如图,换算成KB是1099KB

    Android包大小优化之无Alpha通道PNG转JPG的探索
    文章图片
    转换为jpg后又通过tinypng进行压缩后的大小
    整个过程的大小变化:
step 1 扫出需要转换的png原图 -> 4226KB
step 2 上述png转成jpg -> 2036KB
setp 3 上述的jpg经过tinypng压缩 -> 1099KB
压缩后,发现有些图像也被损坏,很多图片出现了奇怪的背景颜色:

Android包大小优化之无Alpha通道PNG转JPG的探索
文章图片
经过tinypng压缩后,有的图像出现了损坏
虽然图片大小体积进一步变小,但是图像出现了损坏,这种情况也是不可取的。
4.3png直接用tinypng压缩
  • png转jpg再进行tinypng压缩后,大小虽然小了很多,但是图像在tinypng压缩的时候失败了。那么直接把png进行tinypng进行压缩,大小和成像质量会怎么样呢?
    通过tinypng将png压缩后,得到对大小如图,换算成KB,是1300KB。

    Android包大小优化之无Alpha通道PNG转JPG的探索
    文章图片
    直接把需要转换的图片放到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的探索
文章图片
欢迎来扫
参考链接 Comparison of different image compression formats

    推荐阅读