最詳細的Android圖片壓縮攻略,讓你一次過足癮(建議收藏)

語言: CN / TW / HK

 BATcoder技術 群,讓一部分人先進大廠

大家好,我是劉望舒,騰訊最具價值專家,著有三本業內知名暢銷書,連續五年蟬聯電子工業出版社年度優秀作者, 百度百科收錄的資深技術專家。

前華為面試官、獨角獸公司技術總監。

想要 加入  BATcoder技術群,公號回覆 BAT  即可。

作者:Mr.Louis

https://blog.csdn.net/weixin_44005563

前言

最近在研究圖片壓縮原理,看了大量資料,從上層尺寸壓縮、質量壓縮原理到下層的哈夫曼壓縮,走成華大道,然後去二仙橋,全看了個遍,今天就來總結總結,做個技術分享,下面的內容可能會顛覆你對圖片壓縮的認知。

圖片基礎知識

首先帶著幾個疑問來看這一小節:

1、位深和色深有什麼區別,他們是一個東西嗎?

2、為什麼Bitmap不能直接儲存,Bitmap和PNG、JPG到底是什麼關係?

3、圖片佔用的記憶體大小公式:圖片解析度 * 每個畫素點大小,這種說法正確嗎?

4、為什麼有時候同一個 app,app >內的同個介面上的同張圖片,但在不同裝置上所耗記憶體卻不一樣?

5、同一張圖片,在介面上顯示的控制元件大小不同時,它的記憶體大小也會跟隨著改變嗎?

ARGB介紹

ARGB顏色模型:最常見的顏色模型,裝置相關,四種通道,取值均為[0,255],即轉化成二進位制位0000 0000 ~ 1111 1111。

A:Alpha (透明度) R:Red (紅) G:Green (綠) B:Blue (藍)

Bitmap概念

Bitmap物件本質是一張圖片的內容在手機記憶體中的表達形式。它將圖片的內容看做是由儲存資料的有限個畫素點組成;每個畫素點儲存該畫素點位置的ARGB值。每個畫素點的ARGB值確定下來,這張圖片的內容就相應地確定下來了。

色彩模式

Bitmap.Config是Bitmap的一個列舉內部類,它表示的就是每個畫素點對ARGB通道值的儲存方案。取值有以下四種:

ALPHA_8:每個畫素佔8位(1個位元組),儲存透明度資訊,沒有顏色資訊。

RGB_565:沒有透明度,R=5,G=6,B=5,,那麼一個畫素點佔5+6+5=16位(2位元組),能表示2^16種顏色。

ARGB_4444:由4個4位組成,即A=4,R=4,G=4,B=4,那麼一個畫素點佔4+4+4+4=16位 (2位元組),能表示2^16種顏色。

ARGB_8888:由4個8位組成,即A=8,R=8,G=8,B=8,那麼一個畫素點佔8+8+8+8=32位(4位元組),能表示2^24種顏色。

位深與色深

在windows上檢視一張圖片的資訊會發現有位深度這個東西,但沒看到有色深:

這裡介紹一下位深與色深的概念:

色深:顧名思義,就是"色彩的深度",指是每一個畫素點用多少bit來儲存ARGB值,屬於圖片自身的一種屬性。色深可以用來衡量一張圖片的色彩處理能力(即色彩豐富程度)。典型的色深是8-bit、16-bit、24-bit和32-bit等。上述的Bitmap.Config引數的值指的就是色深。比如ARGB_8888方式的色深為32位,RGB_565方式的色深是16位。色深是數字影象引數。

位深度是指在記錄數字影象的顏色時,計算機實際上是用每個畫素需要的二進位制數值位數來表示的。當這些資料按照一定的編排方式被記錄在計算機中,就構成了一個數字影象的計算機檔案。每一個畫素在計算機中所使用的這種位數就是“位深度”,位深是物理硬體引數,主要用來儲存。

舉個例子:某張圖片100畫素*100畫素 色深32位(ARGB_8888),儲存時位深度為24位,那麼:

  • 該圖片在記憶體中所佔大小為:100 * 100 * (32 / 8) Byte

  • 在檔案中所佔大小為 100 * 100 * ( 24/ 8 ) * 壓縮率 Byte

拓展小知識

24位顏色可稱之為真彩色,色深度是24,它能組合成2的24次冪種顏色,即:16777216種顏色,超過了人眼能夠分辨的顏色數量。

記憶體中Bitmap的大小

網上很多文章都會介紹說,計算一張圖片佔用的記憶體大小公式:解析度 * 每個畫素點的大小,但事實真的如此嗎?

我們都知道我們的手機螢幕有著一定的解析度(如:1920×1080),影象也有自己的畫素(如拍攝影象的解析度為4032×3024)。

如果將一張1920×1080的圖片載入鋪滿1920×1080的螢幕上這就是最合適的了,此時顯示效果最好。

如果將一張4032×3024的影象放到1920×1080的螢幕並不會得到更好的顯示效果(和1920×1080的影象顯示效果是一致的),反而會浪費更多的記憶體,如果按ARGB_8888來顯示的話,需要48MB的記憶體空間(404830364 bytes),這麼大的記憶體消耗極易引發OOM,後面我們會講到針對大圖載入的記憶體優化,在這裡不過多介紹。

在 Android 原生的 Bitmap操作中,圖片來源是res內的不同資源目錄時,圖片被載入進記憶體時的解析度會經過一層轉換,所以,雖然最終圖片大小的計算公式仍舊是解析度*畫素點大小,但此時的解析度已不是圖片本身的解析度了。詳細請看位元組跳動面試官:一張圖片佔據的記憶體大小是如何計算,規則如下:

新解析度 = 原圖橫向解析度 * (裝置的 dpi / 目錄對應的 dpi ) * 原圖縱向解析度 * (裝置的 dpi / 目錄對應的 dpi )。

當使用 Glide時,如果有設定圖片顯示的控制元件,那麼會自動按照控制元件的大小,降低圖片的解析度載入。圖片來源是res 的解析度轉換規則對它也無效。

當使用 fresco 時,不管圖片來源是哪裡,即使是res,圖片佔用的記憶體大小仍舊以原圖的解析度計算。

其他圖片的來源,如磁碟,檔案,流等,均按照原圖的解析度來進行計算圖片的記憶體大小。

那麼如何計算Bitmap佔用的記憶體?

來看BitmapFactory.decodeResource()的原始碼:

BitmapFactory.

java

public static Bitmap  decodeResourceStream

(Resources res, TypedValue value,InputStream is, Rect pad, Options opts)

{

if (opts ==  null ) {

opts =  new Options();

}

if (opts.inDensity ==  0 && value !=  null ) {

final int density = value.density;

if (density == TypedValue.DENSITY_DEFAULT) {

//inDensity預設為圖片所在資料夾對應的密度

opts.inDensity = DisplayMetrics.DENSITY_DEFAULT;

else if (density != TypedValue.DENSITY_NONE) {

opts.inDensity = density;

}

}

if (opts.inTargetDensity ==  0 && res !=  null ) {

//inTargetDensity為當前系統密度。

opts.inTargetDensity = res.getDisplayMetrics().densityDpi;

}

return decodeStream(is, pad, opts);

}

BitmapFactory.cpp 此處只列出主要程式碼。

static jobject  doDecode (JNIEnv* env, SkStreamRewindable* stream, jobject padding, jobject options) {

//初始縮放係數

float scale =  1.0f ;

if (env->GetBooleanField(options, gOptions_scaledFieldID)) {

const int density = env->GetIntField(options, gOptions_densityFieldID);

const int targetDensity = env->GetIntField(options, gOptions_targetDensityFieldID);

const int screenDensity = env->GetIntField(options, gOptions_screenDensityFieldID);

if (density !=  0 && targetDensity !=  0 && density != screenDensity) {

//縮放係數是當前係數密度/圖片所在資料夾對應的密度;

scale = ( float ) targetDensity / density;

}

}

//原始解碼出來的Bitmap;

SkBitmap decodingBitmap;

if (decoder->decode(stream, &decodingBitmap, prefColorType, decodeMode)

!= SkImageDecoder::kSuccess) {

return nullObjectReturn( "decoder->decode returned false" );

}

//原始解碼出來的Bitmap的寬高;

int scaledWidth = decodingBitmap.width();

int scaledHeight = decodingBitmap.height();

//要使用縮放係數進行縮放,縮放後的寬高;

if (willScale && decodeMode != SkImageDecoder::kDecodeBounds_Mode) {

scaledWidth =  int (scaledWidth * scale +  0.5f );

scaledHeight =  int (scaledHeight * scale +  0.5f );

}    

//原始碼解釋為因為歷史原因;sx、sy基本等於scale。

const float sx = scaledWidth /  float (decodingBitmap.width());

const float sy = scaledHeight /  float (decodingBitmap.height());

canvas.scale(sx, sy);

canvas.drawARGB( 0x000x000x000x00 );

canvas.drawBitmap(decodingBitmap,  0.0f0.0f , &paint);

// now create the java bitmap

return GraphicsJNI::createBitmap(env, javaAllocator.getStorageObjAndReset(),

bitmapCreateFlags, ninePatchChunk, ninePatchInsets, - 1 );

}

Android中圖片壓縮的方法介紹

在 Android 中進行圖片壓縮是非常常見的開發場景,主要的壓縮方法有兩種:其一是質量壓縮,其二是下采樣壓縮。

前者是在不改變圖片尺寸的情況下,改變圖片的儲存體積,而後者則是降低影象尺寸,達到相同目的。

質量壓縮

在Android中,對圖片進行質量壓縮,通常我們的實現方式如下所示:

ByteArrayOutputStream outputStream =  new ByteArrayOutputStream();

//quality 為0~100,0表示最小體積,100表示最高質量,對應體積也是最大

bitmap.compress(Bitmap.CompressFormat.JPEG, quality , outputStream);

在上述程式碼中,我們選擇的壓縮格式是CompressFormat.JPEG,除此之外還有兩個選擇:

其一,CompressFormat.PNG,PNG格式是無損的,它無法再進行質量壓縮,quality這個引數就沒有作用了,會被忽略,所以最後圖片儲存成的檔案大小不會有變化;

其二,CompressFormat.WEBP,這個格式是google推出的圖片格式,它會比JPEG更加省空間,經過實測大概可以優化30%左右。

在某些應用場景需要bitmap轉換成ByteArrayOutputStream,需要根據你要壓縮的圖片格式來判斷使用CompressFormat.PNG還是Bitmap.CompressFormat.JPEG,這時候quality為100。

Android質量壓縮邏輯,函式compress經過一連串的java層呼叫之後,最後來到了一個native函式,如下:

//Bitmap.cpp

static jboolean  Bitmap_compress

(JNIEnv* env, jobject clazz, jlong bitmapHandle,

jint format, jint quality,

{

LocalScopedBitmap  bitmap (bitmapHandle) ;

SkImageEncoder::Type fm;

switch (format) {

case kJPEG_JavaEncodeFormat:

fm = SkImageEncoder::kJPEG_Type;

break ;

case kPNG_JavaEncodeFormat:

fm = SkImageEncoder::kPNG_Type;

break ;

case kWEBP_JavaEncodeFormat:

fm = SkImageEncoder::kWEBP_Type;

break ;

default :

return JNI_FALSE;

}

if (!bitmap.valid()) {

return JNI_FALSE;

}

bool success =  false ;

std:: unique_ptr<SkWStream>  strm (CreateJavaOutputStreamAdaptor(env, jstream, jstorage) ) ;

if (!strm.get()) {

return JNI_FALSE;

}

std:: unique_ptr<SkImageEncoder>  encoder (SkImageEncoder::Create(fm) ) ;

if (encoder.get()) {

SkBitmap skbitmap;

bitmap->getSkBitmap(&skbitmap);

success = encoder->encodeStream(strm.get(), skbitmap, quality);

}

return success ? JNI_TRUE : JNI_FALSE;

}

可以看到最後呼叫了函式encoder->encodeStream(…)編碼儲存本地。該函式是呼叫skia引擎來對圖片進行編碼壓縮,對skia的介紹將在後文講解。

尺寸壓縮

鄰近取樣(Nearest Neighbour Resampling)

BitmapFactory.Options options =  new BitmapFactory.Options();

//或者 inDensity 搭配 inTargetDensity 使用,演算法和 inSampleSize 一樣

options.inSampleSize =  2//設定圖片的縮放比例(寬和高) , google推薦用2的倍數:

Bitmap bitmap = BitmapFactory.decodeFile( "xxx.png" );

Bitmap compress = BitmapFactory.decodeFile( "xxx.png" , options);

在這裡著重講一下這個inSampleSize。從字面上理解,它的含義是: “設定取樣大小”。它的作用是:設定inSampleSize的值(int型別)後,假如設為4,則寬和高都為原來的1/4,寬高都減少了,自然記憶體也降低了。

參考Google官方文件的解釋,我們從中可以看到 x(x 為 2 的倍數)個畫素最後對應一個畫素,由於取樣率設定為 1/2,所以是兩個畫素生成一個畫素。

鄰近取樣的方式比較粗暴,直接選擇其中的一個畫素作為生成畫素,另一個畫素直接拋棄,這樣就造成了圖片變成了純綠色,也就是紅色畫素被拋棄。

鄰近取樣採用的演算法叫做鄰近點插值演算法。

雙線性取樣(Bilinear Resampling)

雙線性取樣(Bilinear Resampling)在 Android 中的使用方式一般有兩種:

Bitmap bitmap = BitmapFactory.decodeFile( "xxx.png" );

Bitmap compress = Bitmap.createScaledBitmap(bitmap, bitmap.getWidth()/ 2 , bitmap.getHeight()/ 2true );

或者直接使用 matrix 進行縮放

Bitmap bitmap = BitmapFactory.decodeFile( "xxx.png" );

Matrix matrix =  new Matrix();

matrix.setScale( 0.5f0.5f );

bm = Bitmap.createBitmap(bitmap,  00 , bit.getWidth(), bit.getHeight(), matrix,  true );

看原始碼可以知道createScaledBitmap函式最終也是使用第二種方式的matrix進行縮放,雙線性取樣使用的是雙線性內插值演算法,這個演算法不像鄰近點插值演算法一樣,直接粗暴的選擇一個畫素,而是參考了源畫素相應位置周圍2x2個點的值,根據相對位置取對應的權重,經過計算之後得到目標影象。

雙線性內插值演算法在影象的縮放處理中具有抗鋸齒功能, 是最簡單和常見的影象縮放演算法,當對相鄰2x2個畫素點採用雙線性內插值演算法時,所得表面在鄰域處是吻合的,但斜率不吻合,並且雙線性內插值演算法的平滑作用可能使得影象的細節產生退化,這種現象在上取樣時尤其明顯。

雙線性取樣對比鄰近取樣的優勢在於:

它的係數可以是小數,而不一定是整數,在某些壓縮限制下,效果尤為明顯

處理文字比較多的圖片在展示效果上的差別,雙線性取樣效果要更好

還有雙三次取樣和**Lanczos **取樣等,具體分析可以參考 Android 中圖片壓縮分析(下)這篇QQ音樂大佬的分享。

小節總結

在 Android 中,前兩種取樣方法根據實際情況去選擇即可,如果對時間要求不高,傾向於使用雙線性取樣去縮放圖片。如果對圖片質量要求很高,雙線性取樣也已經無法滿足要求,則可以考慮引入另外幾種演算法去處理圖片,但是同時需要注意的是後面兩種演算法使用的都是卷積核去計算生成畫素,計算量會相對比較大,Lanczos的計算量則是最大,在實際開發過程中根據需求進行演算法的選擇即可,往往我們是尺寸壓縮和質量壓縮搭配來使用。

下面我們要進入到實戰中,參考一個仿微信朋友圈壓縮策略的Android圖片壓縮工具——Luban,進入我們的下一章節魯班壓縮演算法解析。

魯班壓縮的背景

魯班壓縮 —— Android圖片壓縮工具,仿微信朋友圈壓縮策略。

目前做App開發總繞不開圖片這個元素。但是隨著手機拍照解析度的提升,圖片的壓縮成為一個很重要的問題,隨便一張圖片都是好幾M,甚至幾十M,這樣的照片載入到app,可想而知,隨便載入幾張圖片,手機記憶體就不夠用了,自然而然就造成了OOM ,所以,Android的圖片壓縮異常重要。

單純對圖片進行裁切,壓縮已經有很多文章介紹。但是裁切成多少,壓縮成多少卻很難控制好,裁切過頭圖片太小,質量壓縮過頭則顯示效果太差。於是自然想到App巨頭——微信會是怎麼處理,Luban(魯班)就是通過在微信朋友圈傳送近100張不同解析度圖片,對比原圖與微信壓縮後的圖片逆向推算出來的壓縮演算法。

效果與對比

因為是逆向推算,效果還沒法跟微信一模一樣,但是已經很接近微信朋友圈壓縮後的效果,具體看以下對比!

Luban演算法解析

微信的演算法解析

第一步進行取樣率壓縮;

第二步進行寬高的等比例壓縮(微信對原圖和縮圖限制了最大長寬或者最小長寬);

第三步就是對圖片的質量進行壓縮(一般75或者70);

第四步就是採用webP的格式。

經過這四部的處理,基本上和微信朋友圈的效果一致,包括檔案大小和顯示效果

Luban的演算法解析

Luban壓縮目前的步驟只佔了微信演算法中的第二與第三步,演算法邏輯如下:

判斷圖片比例值,是否處於以下區間內。

  • [1, 0.5625) 即圖片處於 [1:1 ~ 9:16) 比例範圍內

  • [0.5625, 0.5) 即圖片處於 [9:16 ~ 1:2) 比例範圍內

  • [0.5, 0) 即圖片處於 [1:2 ~ 1:∞) 比例範圍內

簡單解釋一下:獲取圖片的比例係數,如果在區間 [1, 0.5625) 中即圖片處於 [1:1 ~ 9:16)比例範圍內,比例以此類推,如果這個係數小於0.5,那麼就給它放到 [1:2 ~ 1:∞)比例範圍內。

判斷圖片最長邊是否過邊界值。

  • [1, 0.5625) 邊界值為:1664 * n(n=1), 4990 * n(n=2), 1280 * pow(2, n-1)(n≥3)

  • [0.5625, 0.5) 邊界值為:1280 * pow(2, n-1)(n≥1)

  • [0.5, 0) 邊界值為:1280 * pow(2, n-1)(n≥1)

步驟二:上去一看一臉懵,1664是什麼,n是什麼,pow又是什麼。。。這寫的估計只有作者自己能看懂了。其實就是判斷圖片最長邊是否過邊界值,此邊界值是模仿微信的一個經驗值,就是說1664、4990都是經驗值,模仿微信的策略。

至於n,是返回的是options.inSampleSize的值,就是取樣壓縮的係數,是int型,Google建議是2的倍數,所以為了配合這個建議,程式碼中出現了小於10240返回的是4這種操作。最後說一下pow,其實是(長邊/1280), 這個1280也是個經驗值,逆向推出來的,解釋到這裡邏輯也清晰了。真是坑啊啊,哈哈哈

計算壓縮圖片實際邊長值,以第2步計算結果為準,超過某個邊界值則:

  • width / pow(2, n-1)

  • height/ pow(2, n-1)

步驟三:這個感覺沒什麼用,還是計算壓縮圖片實際邊長值,人家也說了,以第2步計算結果為準,其實就是晃你的,乍一看 ,這麼多步驟,哈哈哈哈,唬你呢!

計算壓縮圖片的實際檔案大小,以第2、3步結果為準,圖片比例越大則檔案越大。

size = (newW * newH) / (width * height) * m;

  • [1, 0.5625) 則 width & height 對應 1664,4990,1280 * n(n≥3),m 對應 150,300,300;

  • [0.5625, 0.5) 則 width = 1440,height = 2560, m = 200;

  • [0.5, 0) 則 width = 1280,height = 1280 / scale,m = 500;注:scale為比例值

步驟四:這個感覺也沒什麼用,這個m應該是壓縮比。但整個過程就是驗證一下壓縮完之後,size的大小,是否超過了你的預期,如果超過了你的預期,將進行重複壓縮。

判斷第4步的size是否過小。

  • [1, 0.5625) 則最小 size 對應 60,60,100

  • [0.5625, 0.5) 則最小 size 都為 100

  • [0.5, 0) 則最小 size 都為 100

步驟五:這一步也沒啥用,也是為了後面迴圈壓縮使用。這個size就是上面計算出來的,最小 size 對應的值公式為:size = (newW * newH) / (width * height) * m,對應的三個值,就是上面根據圖片的比例分成的三組,然後計算出來的。

將前面求到的值壓縮圖片 width, height, size 傳入壓縮流程,壓縮圖片直到滿足以上數值。

最後一步也沒啥用,看字就知道是為了迴圈壓縮,或許是微信也這樣做?既然你已經有了預期,為什麼不根據預期直接一步到位呢?但是裁剪的係數和壓縮的係數怎麼調整會達到最優一個效果,我的專案中已經對此功能進行了增加,目前還在內測,沒有開源,後期穩定後會開源給大家使用。

將演算法帶入到開原始碼中

咱們直接看演算法所在類 Engine.java:

// 計算取樣壓縮的值,也就是模仿微信的經驗值,核心內容

private int computeSize () {

// 補齊寬度和長度

srcWidth = srcWidth %  2 ==  1 ? srcWidth +  1 : srcWidth;

srcHeight = srcHeight %  2 ==  1 ? srcHeight +  1 : srcHeight;

// 獲取長邊和短邊

int longSide = Math.max(srcWidth, srcHeight);

int shortSide = Math.min(srcWidth, srcHeight);

// 獲取圖片的比例係數,如果在區間[1, 0.5625) 中即圖片處於 [1:1 ~ 9:16) 比例

float scale = (( float ) shortSide / longSide);

// 開始判斷圖片處於那種比例中,就是上面所說的第一個步驟

if (scale <=  1 && scale >  0.5625 ) {

// 判斷圖片最長邊是否過邊界值,此邊界值是模仿微信的一個經驗值,就是上面所說的第二個步驟

if (longSide <  1664 ) {

// 返回的是 options.inSampleSize的值,就是取樣壓縮的係數,是int型,Google建議是2的倍數

return 1 ;

else if (longSide <  4990 ) {

return 2 ;

// 這個10240上面的邏輯沒有提到,也是經驗值,不用去管它,你可以隨意調整

else if (longSide >  4990 && longSide <  10240 ) {

return 4 ;

else {

return longSide /  1280 ==  01 : longSide /  1280 ;

}

// 這些判斷都是逆向推導的經驗值,也可以說是一種策略

else if (scale <=  0.5625 && scale >  0.5 ) {

return longSide /  1280 ==  01 : longSide /  1280 ;

else {

// 此時圖片的比例是一個長圖,採用策略向上取整

return ( int ) Math.ceil(longSide / ( 1280.0 / scale));

}

}

// 圖片旋轉方法

private Bitmap  rotatingImage (Bitmap bitmap,  int  angle) {

Matrix matrix =  new Matrix();

// 將傳入的bitmap 進行角度旋轉

matrix.postRotate(angle);

// 返回一個新的bitmap

return Bitmap.createBitmap(bitmap,  00 , bitmap.getWidth(), bitmap.getHeight(), matrix,  true );

}

// 壓縮方法,返回一個File

File  compress () throws IOException  {

// 建立一個option物件

BitmapFactory.Options options =  new BitmapFactory.Options();

// 獲取取樣壓縮的值

options.inSampleSize = computeSize();

// 把圖片進行取樣壓縮後放入一個bitmap, 引數1是bitmap圖片的格式,前面獲取的

Bitmap tagBitmap = BitmapFactory.decodeStream(srcImg.open(),  null , options);

// 建立一個輸出流的物件

ByteArrayOutputStream stream =  new ByteArrayOutputStream();

// 判斷是否是JPG圖片

if (Checker.SINGLE.isJPG(srcImg.open())) {

// Checker.SINGLE.getOrientation這個方法是檢測圖片是否被旋轉過,對圖片進行矯正

tagBitmap = rotatingImage(tagBitmap, Checker.SINGLE.getOrientation(srcImg.open()));

}

// 對圖片進行質量壓縮,引數1:通過是否有透明通道來判斷是PNG格式還是JPG格式,

// 引數2:壓縮質量固定為60,引數3:壓縮完後將bitmap寫入到位元組流中

tagBitmap.compress(focusAlpha ? Bitmap.CompressFormat.PNG : Bitmap.CompressFormat.JPEG,  60 , stream);

// bitmap用完回收掉

tagBitmap.recycle();

// 將圖片流寫入到File中,然後重新整理緩衝區,關閉檔案流和Byte流

FileOutputStream fos =  new FileOutputStream(tagImg);

fos.write(stream.toByteArray());

fos.flush();

fos.close();

stream.close();

return tagImg;

}

Luban原框架問題分析

原框架問題分析

  • 解碼前沒有對記憶體做出預判

  • 質量壓縮寫死 60

  • 沒有提供圖片輸出格式選擇

  • 不支援多檔案合理並行壓縮,輸出順序和壓縮順序不能保證一致

  • 檢測檔案格式和影象的角度多次重複建立InputStream,增加不必要開銷,增加OOM風險

  • 可能出現記憶體洩漏,需要自己合理處理生命週期

  • 圖片要是有大小限制,只能進行重複壓縮

  • 原框架用的還是RxJava1.0

技術改造方案

  • 解碼前利用獲取的圖片寬高對記憶體佔用做出計算,超出記憶體的使用RGB-565嘗試解碼

  • 針對質量壓縮的時候,提供傳入質量係數的介面

  • 對圖片輸出支援多種格式,不侷限於File

  • 利用協程來實現非同步壓縮和並行壓縮任務,可以在合適時機取消協程來終止任務

  • 參考Glide對位元組陣列的複用,以及InputStream的mark()、reset()來優化重複開啟開銷

  • 利用LiveData來實現監聽,自動登出監聽。

  • 壓縮前計算好大小,逆向推匯出尺寸壓縮係數和質量壓縮係數

  • 現在已經出了RxJava3和協程,但大多數專案中已經有了執行緒池,要利用專案中的執行緒池,而不是匯入一個三方庫就建一個執行緒池而造成資源浪費

小結

Luban壓縮當初出來的時候號稱 "可能是最接近微信朋友圈的圖片壓縮演算法" ,但這個庫已經三四年沒有維護了,隨著產品的迭代微信已經也不是當初的那個微信了,Luban壓縮的庫也要進行更新了。所以為了適應現在的專案,我之後會根據上面的技術改造方案對圖片壓縮出一個船新版本的庫,更為強大。

Luban還有一個turbo分支,這個分支主要是為了相容Android 7.0以前的系統版本,匯入libjpeg-turbo的jni版本。

libjpeg-turbo是一個C語音編寫的高效JPEG影象處理庫,Android系統在7.0版本之前內部使用的是libjpeg非turbo版,並且為了效能關閉了Huffman編碼。在7.0之後的系統內部使用了libjpeg-turbo庫並且啟用Huffman編碼。

那麼什麼是Huffman編碼呢?前面提到的skio引擎又是什麼東西呢?

/   底層哈夫曼壓縮講解   /

在前面的Android圖片壓縮必備基礎知識中,提到的Skia是Android的重要組成部分。在魯班壓縮演算法解析中提到哈夫曼壓縮,那麼他們之間到底是什麼關係呢?

Android Skia 影象引擎

Skia 是一個2D向量圖形處理函式庫,2005年被Google收購後並自己維護的 c++ 實現的影象引擎,實現了各種影象處理功能,並且廣泛地應用於谷歌自己和其它公司的產品中(如:Chrome、Firefox、 Android等),基於它可以很方便為作業系統、瀏覽器等開發影象處理功能。

Skia 在 Android 中提供了基本的畫圖和簡單的編解碼功能,可以掛接其他的第三方編碼解碼庫或者硬體編解碼庫,例如libpng 和 libjpeg ,libgif等等。因此,這個函式呼叫bitmap.compress(Bitmap.CompressFormat.JPEG...),實際會呼叫 libjpeg.so動態庫進行編碼壓縮。

最終Android編碼儲存圖片的邏輯是Java層函式→Native函式→Skia函式→對應第三庫函式(例如libjpeg)。所以skia就像一個 膠水層,用來連結各種第三方編解碼庫,不過Android也會對這些庫做一些修改,比如修改記憶體管理的方式等等。

Android 在之前從某種程度來說使用的算是 libjpeg 的功能閹割版,壓縮圖片預設使用的是 standard huffman,而不是 optimized huffman,也就是說使用的是預設的哈夫曼表,並沒有根據實際圖片去計算相對應的哈夫曼表,Google 在初期考慮到手機的效能瓶頸,計算圖片權重這個階段非常佔用 CPU 資源的同時也非常耗時,因為此時需要計算圖片所有畫素 argb 的權重,這也是 Android 的圖片壓縮率對比 iOS 來說差了一些的原因之一。

影象壓縮與Huffman演算法

這裡簡單介紹一下哈夫曼演算法,哈夫曼演算法是在多媒體處理裡常用的演算法之一。比如一個檔案中可能會出現五個值 a,b,c,d,e,它們用二進位制表達是:

a.  1010 b.  1011 c.  1100 d.  1101 e.  1110

我們可以看到,最前面的一位數字是 1,其實是浪費掉了,在定長演算法下最優的表示式為:

a.  010 b.  011 c.  100 d.  101 e.  110

這樣我們就能做到節省一位的損耗,那哈夫曼演算法比起定長演算法改進的地方在哪裡呢?在哈夫曼演算法中我們可以給資訊賦予權重,即為資訊加權重,假設 a 佔據了 60%,b 佔據了 20%, c 佔據了 20%,d,e 都是 0%:

a: 010 ( 60 %) b: 011 ( 20 %) c: 100 ( 20 %) d: 101 ( 0 %) e: 110 ( 0 %)

在這種情況下,我們可以使用哈夫曼樹演算法再次優化為:

a: 1 b: 01 c: 00

所以思路當然就是出現頻率高的字母使用短碼,對出現頻率低的使用長碼,不出現的直接就去掉,最後 abcde 的哈夫曼編碼就對應:1 01 00

定長編碼下的abcde:010 011 100 101 110, 使用 哈夫曼樹 加權重後的 編碼則為 1 01 00,這就是哈夫曼演算法的整體思路(關於演算法的詳細介紹可以參考哈夫曼樹及編碼講解及例題)。

所以這個演算法一個很重要的思路是必須知道每一個元素出現的權重,如果我們能夠知道每一個元素的權重,那麼就能夠根據權重動態生成一個最優的哈夫曼表。

但是怎麼去獲取每一個元素,對於圖片就是每一個畫素中 argb 的權重呢,只能去迴圈整個圖片的畫素資訊,這無疑是非常消耗效能的,所以早期 android 就使用了預設的哈夫曼表進行圖片壓縮。

libjpeg與optimize_coding

libjpeg在壓縮影象時,有一個引數叫optimize_coding,關於這個引數,libjpeg.doc有如下解釋:

TRUE causes the compressor to compute optimal Huffman coding tables

for the image. This  requires an extra pass over the data and

therefore costs a good deal of space and time. The  default is

FALSE, which tells the compressor to use the supplied or  default

Huffman tables. In most cases optimal tables save only a few percent

of file size compared to the  default tables. Note that when  this is

TRUE, you need not supply Huffman tables at all, and any you  do

supply will be overwritten.

由上可知,如果設定optimize_coding 為TRUE,將會使得壓縮影象過程中,會先基於影象資料計算哈弗曼表。由於這個計算會顯著消耗空間和時間,預設值被設定為FALSE。

那麼optimize_coding引數的影響究竟會有多大呢?Skia的官方人員經過實際測試,分別設定optimize_coding=TRUE 和 FALSE 進行壓縮,發現FALSE時的圖片大小大約是 TRUE時的2倍+。換言之就是相同檔案體積的圖片,不使用哈夫曼編碼圖片質量會比使用哈夫曼低2倍+。

從Android 7.0版本開始,optimize_code標示已經設定為了TRUE,也就是預設使用影象生成哈夫曼表,而不是使用預設哈夫曼表。

以上內容借鑑了Android中圖片壓縮分析(上)中的內容,自認為不能比他寫的更好,感謝QQ音樂技術團隊,如有冒犯,請立即聯絡刪除。

Android 中圖片壓縮分析(上):

https://cloud.tencent.com/developer/article/1006307

手寫JPEG影象處理引擎

我們都知道bitmap是在native層被建立的,在Bitmap.cpp檔案中,建立的bitmap其實是建立了一個SKBitmap的物件,交給了skia引擎去處理。匯入jpeglib.h的標頭檔案會需要其他的.h標頭檔案,具體如下:

然後開始擼程式碼,照著安卓原始碼中libjpeg-turbo庫裡的example.c檔案(系統提供的例子),開始編寫native-lib.cpp檔案:

#include <jni.h>

#include <string>

#include <android/bitmap.h>

#include <android/log.h>

#include <malloc.h>

// 因為標頭檔案都是c檔案,咱們寫的是.cpp 是C++檔案,這時候就需要混編,所以加入下面關鍵字

extern  "C"

{

#include  "jpeglib.h"

}

#

define  LOGE (...) __android_log_print (ANDROID_LOG_ERROR,LOG_TAG,__VA_ARGS__)

#define LOG_TAG "louis"

#define  true 1

;

// 寫入圖片函式

void writeImg (BYTE *data,  const   char  *path,  int  w,  int  h) {

//  信使: java與C溝通的橋樑,jpeg的結構體,儲存的比如寬、高、位深、圖片格式等資訊

struct jpeg_compress_struct jpeg_struct;

//  設定錯誤處理資訊 當讀完整個檔案的時候就會回撥my_error_exit,例如內建卡出錯、沒許可權等

jpeg_error_mgr err;

jpeg_struct.err = jpeg_std_error(&err);

//  給結構體分配記憶體

jpeg_create_compress(&jpeg_struct);

//  開啟輸出檔案

FILE *file = fopen(path,  "wb" );

//  設定輸出路徑

jpeg_stdio_dest(&jpeg_struct, file);

jpeg_struct.image_width = w;

jpeg_struct.image_height = h;

//  初始化  初始化

//  改成FALSE   ---》 開啟hufuman演算法

jpeg_struct.arith_code = FALSE;

//  是否採用哈弗曼表資料計算 品質相差2倍多,官方實測, 吹5-10倍的都是扯淡

jpeg_struct.optimize_coding = TRUE;

//  設定結構體的顏色空間為RGB

jpeg_struct.in_color_space = JCS_RGB;

//  顏色通道數量

jpeg_struct.input_components =  3 ;

//  其他的設定預設

jpeg_set_defaults(&jpeg_struct);

//  設定質量

jpeg_set_quality(&jpeg_struct,  60true );

//  開始壓縮,(是否寫入全部畫素)

jpeg_start_compress(&jpeg_struct, TRUE);

JSAMPROW row_pointer[ 1 ];

//    一行的rgb

int row_stride = w *  3 ;

//  一行一行遍歷 如果當前的行數小於圖片的高度,就進入迴圈

while (jpeg_struct.next_scanline < h) {

//      得到一行的首地址

row_pointer[ 0 ] = &data[jpeg_struct.next_scanline * w *  3 ];

//        此方法會將jcs.next_scanline加1

jpeg_write_scanlines(&jpeg_struct, row_pointer,  1 ); //row_pointer就是一行的首地址,1:寫入的行數

}

jpeg_finish_compress(&jpeg_struct);

jpeg_destroy_compress(&jpeg_struct);

fclose(file);

}

extern  "C"

JNIEXPORT  void JNICALL

Java_com_maniu_wechatimagesend_MainActivity_compress

(JNIEnv *env, 

jobject instance,

jobject bitmap, 

{

const char *path = env->GetStringUTFChars(path_,  0 );

//  獲取Bitmap資訊

AndroidBitmapInfo bitmapInfo;

AndroidBitmap_getInfo(env, bitmap, &bitmapInfo);

//  儲存ARGB所有畫素點

BYTE *pixels;

//  1、讀取Bitmap所有畫素資訊

AndroidBitmap_lockPixels(env, bitmap, ( void **) &pixels);

//  獲取bitmap的 寬,高,format

int h = bitmapInfo.height;

int w = bitmapInfo.width;

//  儲存RGB所有畫素點

BYTE *data,*tmpData;

//  2、解析每個畫素,去除A通量,取出RGB通量,

//  假如圖片的畫素是1920*1080,只有RGB三個顏色通道的話,計算公式為 w*h*3

data= (BYTE *) malloc(w * h *  3 );

//  儲存RGB首地址

tmpData = data;

BYTE r, g, b;

int color;

for ( int i =  0 ; i < h; ++i) {

for ( int j =  0 ; j < w; ++j) {

color = *(( int *) pixels);

// 取出R G B 

r = ((color &  0x00FF0000 ) >>  16 );

g = ((color &  0x0000FF00 ) >>  8 );

b = ((color &  0x000000FF ));

// 賦值

*data = b;

*(data +  1 ) = g;

*(data +  2 ) = r;

// 指標後移

data +=  3 ;

pixels +=  4 ;

}

}

//  3、讀取畫素點完畢 解鎖,

AndroidBitmap_unlockPixels(env, bitmap);

//  直接用data寫資料

writeImg(tmpData, path, w, h);

env->ReleaseStringUTFChars(path_, path);

}

整個講解已經在程式碼裡已經做了註釋。

小結

查閱原始碼發現:

在Android系統在7.0版本之前內部使用的是libjpeg非turbo版,並且為了效能關閉了Huffman編碼計算,使用預設的哈夫曼表,而不是算數編碼。

從Android 7.0版本開始,系統內部使用了libjpeg-turbo庫並且啟用Huffman編碼,標示就是optimize_code已經設定為了TRUE,也就是預設使用Huffman壓縮計算生成新的哈夫曼表。libjpeg-turbo是一個C語音編寫的高效JPEG影象處理庫,相當於是一個libjpeg的增強版。

這也就是Luban壓縮為什麼會給出一個turbo分支,其實是為了相容Android 7.0版本之前。