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<ImageWriter>),此行代碼獲取了一個能夠對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<? extends BufferedImage> thumbnails:圖像的縮略圖信息,可為null
- IIOMetadata metadata:與圖像相關聯的其他非圖像數據的元數據,可為null
IIOImage(RenderedImaeg image, List<? extends BufferedImage> 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后可用。
- 先把壓縮質量的參數設置為1.0f,把PNG圖像轉換為JPG,輸入文件大小:
png total size:4226.41KB
jpg total size:5012.55KB
可以看到大小不減少反而增大了,進一步驗證了,并不是所有的情況,png轉換為jpg,圖像大小都會變小,詳細說明可看參考鏈接。
看看成像質量:
左邊是png,右邊是轉換后的jpg,雖然畫面的通透性感覺有點改變,畫質還是可以的,可是大小卻變大了
- 再按照默認的0.75的質量進行轉換,輸出文件大小:
png total size:4226.41KB
jpg total size:1479.10KB
可以看到,文件大小從原來png的4226KB變成了1479KB,來看看成像質量:
左邊是png原圖,右邊是轉換成jpg后的圖像。同樣可以發現存在肉眼可見的畫質損失。
- 通過反復測試,發現當壓縮質量參數為0.9f的時候,畫質與大小得到了平衡。體積大小相比0.75f時增加不多。
大小輸出對比:
png total size:4226.41KB
jpg total size:2036.71KB
畫質對比:
可以看到圖像的質量還是可以的,沒有明顯的糊邊了。
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圖片:
而發現jpg中有好多轉換失敗的黑圖:
不用挑樣張來對比成像質量了,這是絕對不允許的,所以通過算法壓縮后的圖像,轉換后,不僅體積變大,而且還有很多轉換失敗的。
所以,上述的轉換,一定要是針對未通過其他算法進行壓縮后的圖像資源。
4.2 轉換成jpg后,還可以通過tinypng壓縮嗎?
依然回到debug版本的資源上,進一步探索,看轉換后的圖像,是否可以通過tinypng壓縮。
-
把壓縮后的jpg,通過tinypng進行壓縮(一次20張,分批次進行壓縮)
壓縮后,得到的圖像大小如圖,換算成KB是1099KB
轉換為jpg后又通過tinypng進行壓縮后的大小
整個過程的大小變化:
step 1 掃出需要轉換的png原圖 -> 4226KB
step 2 上述png轉成jpg -> 2036KB
setp 3 上述的jpg經過tinypng壓縮 -> 1099KB
壓縮后,發現有些圖像也被損壞,很多圖片出現了奇怪的背景顏色:
雖然圖片大小體積進一步變小,但是圖像出現了損壞,這種情況也是不可取的。
4.3png直接用tinypng壓縮
-
png轉jpg再進行tinypng壓縮后,大小雖然小了很多,但是圖像在tinypng壓縮的時候失敗了。那么直接把png進行tinypng進行壓縮,大小和成像質量會怎么樣呢?
通過tinypng將png壓縮后,得到對大小如圖,換算成KB,是1300KB。
直接把需要轉換的圖片放到tinypng上進行壓縮后的目錄大小
大小從原來的4226KB減小到了1300KB,減小了很多,現在來看下成像質量:
png原圖與tinypng壓縮后的成像對比
同樣,左邊是png原圖,右邊是tinypng壓縮后的,不得不承認,tinypng壓縮真的很優秀,肉眼看去,跟原圖無異啊。
5. 結論與思考
- 如果不考慮使用其他算法對圖片進行壓縮,把不包含透明度的png轉換為jpg,體積大小通常情況下會大大減少;
- 壓縮質量參數可根據成像質量自行設定,官方建議0.75f,屬于視覺無損;
- 如果要用壓縮算法對圖片進行壓縮,不建議進行格式轉換,無論是轉換前壓縮還是轉換后壓縮,圖像都可能會損壞;
- tinypng壓縮算法還是相當優秀的,體積大小縮小很多,畫質肉眼幾乎看不到損失,良心推薦啊;
- 通過上述介紹的幾個Java API,我們對圖片對控制是可以達到每個像素的粒度,拿到這些信息后是可以做很多事情的,比如:結合圖像識別算法,可以判斷圖片的相似度。
- jpg不包含透明通道,png包含透明通道。通常情況下,在有明亮的色彩與紋理的圖像中,位深相同的情況下,jpg比png圖像擁有更高的壓縮比。我個人的理解是,圖片位深越大,壓縮比越大,詳情可查看下方鏈接。
廣告時間
字節跳動各Android客戶端團隊招人火爆進行中,各個級別和應屆實習生都需要,業務增長快、日活高、挑戰大、待遇給力,各位大佬走過路過千萬不要錯過!
本科以上學歷、對技術有熱情,歡迎加我的微信詳聊:spq951992006