標籤:
1. 引子
前幾天跟服務端的一個妹子聯調介面,伺服器配置一張圖片,幾十KB就行,她問我圖片從哪裡找,我告訴她先隨便在網上找個圖片連結就行了。結果一運行程式,就崩潰了,出現了下面的異常。
java.lang.OutofMemoryError
記憶體溢出OOM,我當時一臉懵逼。
圖-1 一臉懵逼
於是拿著後台返回的連結去查看了一片,是一張6M的壁紙。
圖-2 我內心幾乎是崩潰的
這隻是一個簡單的聯調,而在聯調過程中操作不當導致出現OOM問題,大家就當是個玩笑。其實在Android中很容易出現OOM的異常,特別是對圖片操作的時候,所以當面對大圖片,需要我們對圖片進行適當的壓縮,在不影響圖片顯示的情況下,盡量保證不出現OOM的異常。
2. 概述
在開發中,對於圖片的操作,稍有不慎,可能就會消耗大量的記憶體,導致程式崩潰,所以瞭解一種通用的技術去處理和載入圖片,同時保證UI流暢避免OOM現象,是非常有必要的。那麼為什麼在Android中對於圖片的處理會如此棘手呢?主要有以下一些原因:
- 通常情況下,行動裝置的記憶體資源是有限的,Android系統會根據手機的螢幕大小和密度,為每個程式設定一個最大記憶體限制,應用程式消耗的記憶體不能超過這個最大記憶體限制,否則就會出現OOM現象。當然,這個記憶體限制是跟手機配置相關聯的。
- 圖片的操作會消耗大量的記憶體,特別是細節豐富的圖片,例如照片。以Galaxy Nexus相機為例子,它拍攝一張2592x1936像素的照片,如果使用的位元影像配置是ARGB_8888(預設從Android 2.3開始),那麼這張照片載入到記憶體,大約會消耗19MB的記憶體(2592 x 1936 x 4位元組),僅僅是圖片消耗記憶體的數值可能已經超過了某些裝置的記憶體限制
- Android的UI經常會一次載入多張圖片,例如,ListView、GridView、ViewPager等等
圖片有各種形狀和大小。通常情況下,它們普遍比裝置所需要的圖片要大一些,例如手機相簿顯示手機拍攝的照片,而手機的相機解析度大多時候是要高於手機螢幕的解析度。鑒於手機的記憶體有限,我們只需要在記憶體中載入一個低解析度的照片版本就可以了,而這個低解析度的照片應該與顯示它的控制項相匹配,這就需要對圖片進行壓縮處理了。
Android中有兩種壓縮圖片的方法。
- 第一種是針對圖片的長寬進行壓縮,在將圖片載入到記憶體過程中將圖片的長寬進行壓縮,擷取長寬壓縮版的的圖片
- 第二種是針對圖片的像素進行壓縮,圖片載入到記憶體後,針對圖片品質進行壓縮,會導致圖片品質下降。
3. 圖片長寬壓縮3.1 擷取載入圖片的屬性
Android中的BitmapFactory類提供了一些解碼方法,decodeByteArray()、decodeFile()、decodeResource()等等,根據不通的圖片源選擇不同的解碼方法載入圖片建立出Bitmap。這些方法中都會傳入一個BitmapFactory.Options
執行個體化對象,通過這個對象,可以更改一些載入圖片的設定。由於這些解碼方法用於解碼載入圖片,會佔用記憶體構建Bitmap,因此很容易導致OOM的異常。
如果將options.inJustDecodeBounds
設定為true,在解碼過程中就不會申請記憶體去建立Bitmap,返回的是一個空的Bitmap,但是可以擷取圖片的一些屬性,例片寬高,圖片類型等等。
BitmapFactory.Options options = new BitmapFactory.Options();options.inJustDecodeBounds = true; // 設定為true,不將圖片解碼到記憶體中BitmapFactory.decodeResource(getResources(), R.id.myimage, options);int imageHeight = options.outHeight; // 圖片高度int imageWidth = options.outWidth; // 圖片寬度String imageType = options.outMimeType; // 圖片類型
一般來說,為了避免OOM的異常,在載入圖片到記憶體之前,會先檢查圖片的尺寸,除非你能確保圖片源不會導致OOM。
3.2 縮小圖片的長寬來壓縮圖片
我們知道圖片的大小之後,就可以決定是否將完整的圖片載入到記憶體或者載入壓縮版的圖片到記憶體。可以基於以下幾點做出決定:
- 估計完整圖片載入到記憶體中所使用記憶體
- 可分配給載入圖片的記憶體
- 用於顯示圖片的控制項的大小
- 當前裝置的螢幕大小和密度
例如,如果顯示圖片的控制項大小為128x96像素,就沒有必要將一個1024x768像素的圖片載入到記憶體中。
設定options.inSampleSize
的數值,來控制壓縮圖片程度。例如,將options.inSampleSize
設定為4,將一個2048x1536像素的圖片解碼載入到記憶體後產生的Bitmap大約為512x384像素,如果使用的位元影像配置是ARGB_8888,那麼僅僅需要0.75M就載入了縮小版的圖片到記憶體,而載入完整的圖片需要12M。
也就是說,如果我們設定inSampleSize == 2
,解碼出來的位元影像的寬高是原圖的1/2,圖片所佔用記憶體縮小了1/4(1/2 x 1/2)。如果inSampleSize
設定的值小於等1,都會當做inSampleSize == 1
來解碼載入圖片。
於是我們可以在載入圖片的時候,根據控制項的大小(顯示到螢幕上的大小)來計算出加壓縮版圖片的inSampleSize
值。
/** * 計算inSampleSize值 * * @param options * 用於擷取原圖的長寬 * @param reqWidth * 要求壓縮後的圖片寬度 * @param reqHeight * 要求壓縮後的圖片長度 * @return * 返回計算後的inSampleSize值 */ public static int calculateInSampleSize(BitmapFactory.Options options, int reqWidth, int reqHeight) { // 原圖片的寬高 final int height = options.outHeight; final int width = options.outWidth; int inSampleSize = 1; if (height > reqHeight || width > reqWidth) { final int halfHeight = height / 2; final int halfWidth = width / 2; // 計算inSampleSize值 while ((halfHeight / inSampleSize) >= reqHeight && (halfWidth / inSampleSize) >= reqWidth) { inSampleSize *= 2; } } return inSampleSize; }
有人可能會疑問為什麼每次inSampleSize
都是乘以2,指數增長。這是因為在載入圖片過程中,解析器使用的inSampleSize
都是2的指數倍,如果inSampleSize
是其他值,則找一個離這個值最近的2的指數值。
上面已經擷取了inSampleSize
,然後就可以根據這個值來載入壓縮版的圖片了。
public static Bitmap decodeSampledBitmapFromResource(Resources res, int resId, int reqWidth, int reqHeight) { // 先將inJustDecodeBounds設定為true來擷取圖片的長寬屬性 final BitmapFactory.Options options = new BitmapFactory.Options(); options.inJustDecodeBounds = true; BitmapFactory.decodeResource(res, resId, options); // 計算inSampleSize options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight); // 載入壓縮版圖片 options.inJustDecodeBounds = false; // 根據具體情況選擇具體的解碼方法 return BitmapFactory.decodeResource(res, resId, options);}
擷取到了壓縮版的Bitmap之後就可以直接設定到螢幕的控制項上了。
mImageView.setImageBitmap( decodeSampledBitmapFromResource(getResources(), R.id.myimage, 100, 100));
4. 圖片品質壓縮4.1 方法介紹
上面一種方法是通過縮放圖片的大小來達到壓縮效果,基本不會對圖片的顯示效果有影響。但是現在介紹的這一種方法,可能會導致圖片品質下降。
使用的是下面這個方法來進行壓縮。
Bitmap.compress(CompressFormat format, int quality, OutputStream stream)
這個方法有三個參數,是布爾類型的傳回值
- CompressFormat 指定的Bitmap被壓縮成的圖片格式,只支援JPEG,PNG,WEBP三種
- quality 圖片壓縮品質的控制,範圍為0~100,0表示壓縮後體積最小,但是品質也是最差,100表示壓縮後體積最大,但是品質也是最好的(個人認為相當於未壓縮),有些格式,例如png,它是無損的,所以會忽略這個值。
- OutputStream 壓縮後的資料會寫入這個位元組流中
- 傳回值表示返回的位元組流是否可以使用
BitmapFactory.decodeStream()
解碼成Bitmap,至於傳回值是怎麼得到的,因為是Native的代碼,沒法找到邏輯。
4.2 色位元深度介紹
接下來說說為什麼用這個方法可能會導致圖片品質下降。在Bitmap中有一個Config
的屬性,這個屬性是用來描述每個像素被儲存的大小。目前Config
有四個值:ALPHA_8
、RGB_565
、ARGB_4444
、ARGB_8888
。這個說明一下(我個人的理解,真心不好解釋),每一個像素會可能由四個屬性群組成,R(Red紅色通道)、G(Green綠色通道)、B(Blue藍色通道)、A(Alpha透明度通道)。
Config |
每個像素佔用的位元組 |
說明 |
ALPHA_8 |
1 bytes |
每個像素僅僅儲存透明度通道 |
RGB_565 |
2 bytes |
每個像素的RGB通道會儲存,透明度不會儲存,紅色通道5位,有2^5=32種表現形式;綠色通道6位,有2^6=64種表現形式;藍色通道5位,有2^5=32種表現形式 |
ARGB_4444 |
2 bytes |
每個像素的ARGB通道都會儲存,透明度/紅色/綠色/藍色通道4位,有2^4=16種表現形式 |
ARGB_8888 |
4 bytes |
每個像素的ARGB通道都會儲存,透明度/紅色/綠色/藍色通道8位,有2^8=256種表現形式 |
有什麼區別呢?最簡單的,當一個顏色表現形式越多,那麼畫面整體的色彩就會更豐富,圖片品質就會越高,當然,圖片佔用的儲存空間也越大。
4.3 圖片品質下降原因介紹
前面提到過調用Bitmap.compress()
方法時候,會傳入一個壓縮後的圖片格式,但是由於並不是所有的圖片格式都支援上面說的Config
的所有通道,比如說,JPEG格式的圖片,是不支援Alpha(透明度)屬性的,這樣將壓縮後返回的位元組流通過BitmapFactory.decodeStream()
轉換成Bitmap的過程中,會將透明度屬性給丟棄,導致圖片品質下降。
4.4 壓縮過程介紹
壓縮過程如下,通過依次減少圖片品質,將圖片大小控制在限制值範圍內。
/** * 壓縮圖片 * * @param bitmap * 被壓縮的圖片 * @param sizeLimit * 大小限制 * @return * 壓縮後的圖片 */private Bitmap compressBitmap(Bitmap bitmap, long sizeLimit) { ByteArrayOutputStream baos = new ByteArrayOutputStream(); int quality = 100; bitmap.compress(Bitmap.CompressFormat.JPEG, quality, baos); // 迴圈判斷壓縮後圖片是否超過限制大小 while(baos.toByteArray().length / 1024 > sizeLimit) { // 清空baos baos.reset(); bitmap.compress(Bitmap.CompressFormat.JPEG, quality, baos); quality -= 10; } Bitmap newBitmap = BitmapFactory.decodeStream(new ByteArrayInputStream(baos.toByteArray()), null, null); return newBitmap;}
5. 更近一步的最佳化
上面提到的很多壓縮方法,如果是在UI線程執行的話,很有可能阻塞到主線程,這是在開發過程中非常不願意見到的事情,所以我們需要在後台線程去執行這些壓縮圖片比較耗時的操作,然後擷取到壓縮後的圖片,設定到螢幕中。使用AsyncTask
可以協助我們很好的實現。
class BitmapWorkerTask extends AsyncTask<Integer, Void, Bitmap> { private final WeakReference<ImageView> imageViewReference; private int data = 0; public BitmapWorkerTask(ImageView imageView) { // 使用弱引用 imageViewReference = new WeakReference<ImageView>(imageView); } // 在後台線程壓縮圖片 @Override protected Bitmap doInBackground(Integer... params) { data = params[0]; return decodeSampledBitmapFromResource(getResources(), data, 100, 100)); } // 壓縮完成後,將圖片設定到控制項中 @Override protected void onPostExecute(Bitmap bitmap) { if (imageViewReference != null && bitmap != null) { final ImageView imageView = imageViewReference.get(); if (imageView != null) { imageView.setImageBitmap(bitmap); } } }}
最終的執行代碼。
BitmapWorkerTask task = new BitmapWorkerTask(imageView); task.execute(resId);
6. 總結
圖片的處理,時刻都需要注意,因為機型配置的不同,以及現場裝置記憶體使用量的情況,都有可能導致OOM的現象,上述提到了壓縮方法,基本適用與大部分圖片壓縮情況。當然如果對圖片畫質顯示有要求,可能就需要特殊的處理了,這個就不在大部分情境的考慮內。
7. 高斯模糊的建議
我在項目中遇見的關於圖片操作的OOM異常,有80%源自於高斯模糊。是的,有些產品經理為了和iOS保持一致,需要將某些頁面背景設定成高斯模糊效果。
一般的做法是將上一個頁面,然後做高斯模糊處理,設定成背景。正好我接觸過這種需求,說一下自己對於高斯模糊的建議。
- 確定產品經理的需求,高斯模糊的效果是不是一定要上。之前遇見一個需求,需要高斯模糊,結果Android做出來效果很不理想,後來,我把背景直接設定成60%的透明度白色。產品經理看了之後,覺得Android的高斯模糊效果(其實是透明度)比iOS的要好一些,就讓iOS改。所以,一定要首先確認產品經理的需求,產品經理想要的效果可能並不是他口中說出的效果,就像我遇見的這位,可能誤將透明度和高斯模糊混合了。
- 如果高斯模糊效果一定要上。先將圖片長寬縮小,然後壓縮圖片品質,再進行高斯模糊的渲染,最後將高斯模糊之後的放大至控制項大小,顯示到螢幕。
- 縮放圖片長寬
- 壓縮圖片品質
- 高斯模糊渲染
- 放大高斯模糊
最後,希望Android工程師不要遇見高斯模糊的需求,因為,真的,很坑。但是如果遇見了,也不要怕,因為你已經知道該如何處理了。
Android之圖片壓縮