最詳細的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版本之前。