一、概述
在Android開發中,我們經常與Bitmap打交道,而對Bitmap的不恰當的操作經常會導致OOM(Out of Memory)。這篇文章我們會介紹如何高效地在Android開發中使用Bitmap,在保證圖片顯示質量的前提下盡可能占用更小的內存。
1. 什么是Bitmap
Android中的Bitmap對象是對位圖的抽象,它可以從文件系統、資源文件夾、網絡等各種不同的來源獲取。位圖可以看做是像素點的集合,本質上就是通過一系列二進制位來描述一張圖片,具有不同色彩格式的位圖使用不同數量的二進制位來描述一個像素點,因而圖片質量和圖片大小也就不同。
2. Bitmap占用的內存計算
(1)屏幕密度
首先,我們來介紹下兩個名詞:density和densityDpi,它們的含義分別如下:
-
density:可以理解為相對屏幕密度,我們知道,1個DIP在160dpi的屏幕上大約為1像素大小。我們以160dpi為基准線,density的值即為相對於160dpi屏幕的相對屏幕密度。比如,160dpi屏幕的density值為1, 320dpi屏幕的density值為2
- densityDpi:可以理解為絕對屏幕密度,也就是實際的屏幕密度值(dots per inch),比如160dpi屏幕的densityDpi值就是160
(2)計算Bitmap占用的內存
Bitmap占用的內存不僅與它的像素點數和色彩格式有關,還和具體設備的屏幕密度、所在的drawable文件夾有關。下面我們來通過一個實例介紹這些因素是如何影響Bitmap所占用的內存的大小的。這里我們使用的虛擬機的屏幕密度為240dpi,圖片文件(670 * 376)存放在drawable-xhdpi文件夾下。我們可以通過以下代碼獲取Bitmap對象並計算它所占用的內存大小:
Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.size); int size = bitmap.getByteCount();
我們可以得到size值為567384。以上代碼中我們通過getByteCount方法來獲取Bitmap對象以字節為單位的大小,我們來看一下這個方法的源碼:
public final int getByteCount() { // int result permits bitmaps up to 46,340 x 46,340 return getRowBytes() * getHeight(); }
其中getHeight方法會返回Bitmap對象的mHeight實例域,也就是圖片的高度(單位為px),而getRowBytes方法返回的是圖片的像素寬度與色彩深度的乘積。這樣綜合起來,我們知道了getByteCount方法的返回值是這樣計算的:像素寬 * 像素高 * 色彩深度。其中色彩深度與Bitmap的色彩格式有關,默認為ARGB_8888,也就是一個像素大小為32位(4字節)。根據這個公式我們來算一下:670 * 376 * 4 = 1007680。跟我們得到的567384差了不少,這是為什么呢?因為我們沒有考慮到的圖片所在資源文件夾以及設備的屏幕密度。
這兩個參數分別對應這BitmapFactory中的inDensity和inTargetDensity。比如我們的圖片在drawable-xhdpi文件夾下,那么inDensity值就為320;設備的屏幕密度為240dpi,因而inTargetDensity的值就為240。把圖片顯示到一個設備上要根據各自的屏幕密度進行縮放,這個縮放系數即為inTargetDensity除以inDensity。具體解釋以下:我們知道dpi代表着每inch的像素點數,那么設圖片像素寬高分別為pixWidth、pixHeight,我們把圖片放到了drawable-xhdpi文件夾下(inDensity為240dpi),pixWidth、pixHeight分別除以inDensity可以得到圖片的物理寬高(單位inch),然后我們把這個物理寬高分別乘以設備的屏幕密度再相乘,也就可以得到目標設備上圖片的像素數了。按照這個過程我們可以得到目標設備上圖片的像素數的計算公式:(pixWidth / inDensity * inTargetDensity) * (pixHeight / inDensity * inTargetDensity) 。將這個像素數乘以4就可以得到在內存中的大小了,我們來驗證下:(670 / 320 * 240) * (376 / 320 *240) * 4 = 566830。和通過getByteCount得到的值近似相等。關於為什么不相等,大家可以參考這篇文章:Android 開發繞不過的坑:你的 Bitmap 究竟占多大內存? 而在實際開發中,這種影響我們通常可以忽略。
二、高效加載Bitmap
上面我們介紹了內存中Bitmap的大小的計算方法,我們當然希望Bitmap在圖像品質可以接受的前提下占用盡可能小的內存。下面我們來介紹一下如何更加高效的加載Bitmap對象。
1. BitmapFactory
BitmapFactory類提供了以下四個靜態方法用來以不同的“原料”生產一個Bitmap對象:
- decodeByteArray(byte[] data, int offset, int length): 把一個byte數組從offset開始的length個字節解析為一個Bitmap對象
- decodeFile(String pathName): 把pathName指定的文件解析成一個Bitmap對象
- decodeFileDescriptor(FileDescriptor fd): 把描述符fd指定的文件解析為一個Bitmap對象
- decodeResource(Resources res, int id, Bitmap.Options options): 根據id從給定的資源中解析出一個Bitmap對象,加載這個對象到內存中時應用options指定的選項
- decodeStream(InputStream is): 從給定的流中解析出一個Bitmap對象
我們下面的講解主要圍繞decodeResource方法來進行,通過對它的options進行合理的配置,我們就能夠將Bitmap對象調整到令我們滿意的大小。
2. Options類介紹
要實現高效加載Bitmap,首先我們要了解Options類的幾個參數,因為正是通過合理的配置這幾個參數,我們才能夠實現高效的加載Bitmap對象。Options類是BitmapFactory的一個靜態內部類,我們來看一下它的源碼:
1 public static class Options { 2 public Options() { 3 inDither = false; 4 inScaled = true; 5 inPremultiplied = true; 6 } 7 ... 8 public Bitmap inBitmap; //用於實現Bitmap的復用,下面會具體介紹 9 public int inSampleSize; //采樣率 10 public boolean inPremultiplied; 11 public boolean inDither; //是否開啟抖動 12 public int inDensity; //即上文我們提到的inDensity 13 public int inTargetDensity; //目標屏幕密度,同上文提到的含義相同 14 public boolean inScaled; //是否支持縮放 15 public int outWidth; //圖片的原始寬度 16 public int outHeight; //圖片的原始高度 17 ... 18 }
下面我們來具體介紹如何通過配置Options來實現Bitmap的高效加載。
(1)縮放系數
在上面的源碼中,我們看到Options類中存在一個inScaled參數,這個參數表示是否支持縮放,我們從Options的默然構造方法中可以看到這個參數被初始化為了true,也就是說默認是支持縮放的。那么將如何進行縮放呢?答案是根據縮放系數進行縮放。關於縮放系數的計算方法,其實我們在講解如何計算內存中Bitmap的大小時已經介紹過了。縮放系數就是inDensity除以inTargetDensity。inDensity表示我們的圖片所處的資源文件夾對應的dpi,inTargetDensity表示目標設備的屏幕密度。
通過以上的實踐我們了解到了,就算不給decodeResource方法傳入Options對象,它也會根據縮放系數對Bitmap進行縮放。我們當然也可以手動設置縮放系數,下面我們還是拿上面那個圖片舉例子,請看以下代碼:
BitmapFactory.Options options = new BitmapFactory.Options(); options.inDensity = 160; options.inTargetDensity = 320; Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.size, options); int size = bitmap.getByteCount();
我們先來計算下size應該為多大:(670 / 160 * 320) * (376 / 160 * 320) *4 = 4030720。我們運行程序,可得到size的實際大小為:4030720。由此可見我們的設置生效了。
(2)采樣率(inSampleSize)
下面我們來介紹inSampleSize這個參數,當這個參數為1時,采樣后的圖片大小和原來一樣;當這個參數為2時,采樣后的圖片寬高均為原來的1/2,大小也就成了原來的1/4。也就是說,采樣后的大小等於原始大小除以采樣率的平方。官方文檔規定,inSampleSize的值應為2的非負整數次冪(1,2,4,... ),否則會被系統向下取整並找到一個最接近的值。通過設置inSampleSize我們就能夠將圖片縮放到一個合理的大小,那么該如何設置inSampleSize的值呢?在講解這個之前,我們先來考慮以下情況:我們的ImageView的大小為100 * 100,要顯示的圖片大小為300 * 400,此時我們應該將inSampleSize設為多少呢。誰先我們通過計算可以得到圖片寬是ImageView的3倍,而圖片高是ImageView的4倍。那么我們應該將圖片寬高縮小為原來的4倍嗎?假如我們把圖片寬高都變為原來的1/4,那么現在圖片大小為75 * 100,ImageView大小為100 * 100,圖片要顯示在ImageView中需要進行拉伸,而拉伸的話可能會導致圖片失真。所以我們應該把圖片寬高變為原來的1/3,以保證它不小於ImageView的大小,這樣盡管多占用一些內存,但不會造成圖片質量的下降,這還是很有必要的。通過以上分析,我們知道了在設置inSampleSize時應該注意使得縮放后的圖片大小不小於相應的ImageView大小。
計算inSampleSize的步驟通常如下:
- 第一步,獲取圖片的原始寬高,通過將Options的inJustDecodeBounds屬性設為true后調用decodeResource方法,可以實現不真正加載圖片而只是獲取圖片的尺寸信息,請看以下代碼:
BitmapFactory.Options options = new BitmapFactory.Options(); options.inJustDecodeBounds = true; BitmapFactory.decodeResource(getResources(), resId, options); //現在原始寬高以存儲在了options對象的outWidth和outHeight實例域中
- 第二步,根據原始寬高計算出inSampleSize,代碼如下:
1 //dstWidth和dstHeight分別為目標ImageView的寬高 2 public static int calSampleSize(BitmapFactory.Options options, int dstWidth, int dstHeight) { 3 int rawWidth = options.outWidth; 4 int rawHeight = options.outHeight; 5 int inSampleSize = 1; 6 if (rawWidth > dstWidth || rawHeight > dstHeight) { 7 float ratioHeight = (float) rawHeight / dstHeight; 8 float ratioWidth = (float) rawWidth / dstHeight; 9 inSampleSize = (int) Math.min(ratioWidth, ratioHeight); 10 } 11 return inSampleSize; 12 }
以上代碼的邏輯很直接,唯一需要注意的就是要記得使采樣后的圖片能夠“覆蓋”ImageView,以防止圖片質量下降。計算inSampleSize並加載采樣后圖片的完整demo請見這里:計算inSampleSize並顯示圖片的完整示例
下面我們來介紹下inBitmap這個參數的作用。
(3)inBitmap參數
這個參數用來實現Bitmap內存的復用,但復用存在一些限制,具體體現在:在Android 4.4之前只能重用相同大小的Bitmap的內存,而Android 4.4及以后版本則只要后來的Bitmap比之前的小即可。使用inBitmap參數前,每創建一個Bitmap對象都會分配一塊內存供其使用,而使用了inBitmap參數后,多個Bitmap可以復用一塊內存,這樣可以提高性能。
關於這個復用Bitmap內存的詳細方法以及注意事項Android Developer網站已給出了詳細的說明(Managing Bitmap Memory)。這里簡單的貼出部分示例代碼了解下它的大致用法:
private static void addInBitmapOptions(BitmapFactory.Options options, ImageCache cache) { // inBitmap only works with mutable bitmaps, so force the decoder to // return mutable bitmaps. options.inMutable = true; if (cache != null) { // Try to find a bitmap to use for inBitmap. Bitmap inBitmap = cache.getBitmapFromReusableSet(options); if (inBitmap != null) { // If a suitable bitmap has been found, // set it as the value of inBitmap. options.inBitmap = inBitmap; } } } static boolean canUseForInBitmap( Bitmap candidate, BitmapFactory.Options targetOptions) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { // From Android 4.4 (KitKat) onward we can re-use // if the byte size of the new bitmap is smaller than // the reusable bitmap candidate // allocation byte count. int width = targetOptions.outWidth / targetOptions.inSampleSize; int height = targetOptions.outHeight / targetOptions.inSampleSize; int byteCount = width * height * getBytesPerPixel(candidate.getConfig()); return byteCount <= candidate.getAllocationByteCount(); } // On earlier versions, // the dimensions must match exactly and the inSampleSize must be 1 return candidate.getWidth() == targetOptions.outWidth && candidate.getHeight() == targetOptions.outHeight && targetOptions.inSampleSize == 1; }
Android Developer上的 Displaying Bitmap Efficiently 系列教程對Android開發中如何高效使用Bitmap做出了權威地描述,學好這個系列,玩兒轉Bitmap自然就不在話下了:)
三、參考資料
1. Displaying Bitmap Efficiently
2. Android 開發繞不過的坑:你的 Bitmap 究竟占多大內存?
3. 《Android開發藝術探索》