原文首發于微信公衆号:躬行之(jzman-blog)
Android 開發中經常考慮的一個問題就是 OOM(Out Of Memory),也就是記憶體溢出,一方面大量加載圖檔時有可能出現 OOM, 通過采樣壓縮圖檔可避免 OOM,另一方面,如一張 1024 x 768 像素的圖像被縮略顯示在 128 x 96 的 ImageView 中,這種做法顯然是不值得的,可通過采樣加載一個合适的縮小版本到記憶體中,以減小記憶體的消耗,Bitmap 的優化主要有兩個方面如下,一是有效的處理較大的位圖,二是位圖的緩存,其中位圖緩存對應文章如下:
- Bitmap之記憶體緩存和磁盤緩存詳解
這篇文章主要側重于如何有效的處理較大的位圖。
此外,在 Android 中按照位圖采樣的方法加載一個縮小版本到記憶體中應該考慮因素?
- 估計加載完整圖像所需要的記憶體
- 加載這個圖檔所需的空間帶給其程式的其他記憶體需求
- 加載圖檔的目标 ImageView 或 UI 元件的尺寸
- 目前裝置的螢幕尺寸或密度
位圖采樣
圖像有不同的形狀的和大小,讀取較大的圖檔時會耗費記憶體。讀取一個位圖的尺寸和類型,為了從多種資源建立一個位圖,BitmapFactory 類提供了許多解碼的方法,根據圖像資料資源選擇最合适的解碼方法,這些方法試圖請求配置設定記憶體來構造位圖,是以很容易導緻 OOM 異常。每種類型的解碼方法都有額外的特征可以讓你通過 BitMapFactory.Options 類指定解碼選項。當解碼時設定 inJustDecodeBounds 為true,可在不配置設定記憶體之前讀取圖像的尺寸和類型,下面的代碼實作了簡單的位圖采樣:
/**
* 位圖采樣
* @param res
* @param resId
* @return
*/
public Bitmap decodeSampleFromResource(Resources res, int resId){
//BitmapFactory建立設定選項
BitmapFactory.Options options = new BitmapFactory.Options();
//設定采樣比例
options.inSampleSize = 200;
Bitmap bitmap = BitmapFactory.decodeResource(res,resId,options);
return bitmap;
}
注意:其他 decode… 方法與 decodeResource 類似,這裡都以 decodeRedource 為例。
實際使用時,必須根據具體的寬高要求計算合适的 inSampleSize 來進行位圖的采樣,比如,将一個分辨率為 2048 x 1536 的圖像使用 inSampleSize 值為 4 去編碼産生一個 512 x 384 的圖像,這裡假設位圖配置為 ARGB_8888,加載到記憶體中僅僅是 0.75M 而不是原來的 12M,關于圖像所占記憶體的計算将在下文中介紹,下面是根據所需寬高進行計算采樣比例的計算方法:
/**
* 1.計算位圖采樣比例
*
* @param option
* @param reqWidth
* @param reqHeight
* @return
*/
public int calculateSampleSize(BitmapFactory.Options option, int reqWidth, int reqHeight) {
//獲得圖檔的原寬高
int width = option.outWidth;
int height = option.outHeight;
int inSampleSize = 1;
if (width > reqWidth || height > reqHeight) {
if (width > height) {
inSampleSize = Math.round((float) height / (float) reqHeight);
} else {
inSampleSize = Math.round((float) width / (float) reqWidth);
}
}
return inSampleSize;
}
/**
* 2.計算位圖采樣比例
* @param options
* @param reqWidth
* @param reqHeight
* @return
*/
public int calculateSampleSize1(BitmapFactory.Options options, int reqWidth, int reqHeight) {
//獲得圖檔的原寬高
int height = options.outHeight;
int width = options.outWidth;
int inSampleSize = 1;
if (height > reqHeight || width > reqWidth) {
// 計算出實際寬高和目标寬高的比率
final int heightRatio = Math.round((float) height / (float) reqHeight);
final int widthRatio = Math.round((float) width / (float) reqWidth);
/**
* 選擇寬和高中最小的比率作為inSampleSize的值,這樣可以保證最終圖檔的寬和高
* 一定都會大于等于目标的寬和高。
*/
inSampleSize = heightRatio < widthRatio ? heightRatio : widthRatio;
}
return inSampleSize;
}
獲得采樣比例之後就可以根據所需寬高處理較大的圖檔了,下面是根據所需寬高計算出來的 inSampleSize 對較大位圖進行采樣:
/**
* 位圖采樣
* @param resources
* @param resId
* @param reqWidth
* @param reqHeight
* @return
*/
public Bitmap decodeSampleFromBitmap(Resources resources, int resId, int reqWidth, int reqHeight) {
//建立一個位圖工廠的設定選項
BitmapFactory.Options options = new BitmapFactory.Options();
//設定該屬性為true,解碼時隻能擷取width、height、mimeType
options.inJustDecodeBounds = true;
//解碼
BitmapFactory.decodeResource(resources, resId, options);
//計算采樣比例
int inSampleSize = options.inSampleSize = calculateSampleSize(options, reqWidth, reqHeight);
//設定該屬性為false,實作真正解碼
options.inJustDecodeBounds = false;
//解碼
Bitmap bitmap = BitmapFactory.decodeResource(resources, resId, options);
return bitmap;
}
在解碼過程中使用了 BitmapFactory.decodeResource() 方法,具體如下:
/**
* 解碼指定id的資源檔案
*/
public static Bitmap decodeResource(Resources res, int id, BitmapFactory.Options opts) {
...
/**
* 根據指定的id打開資料流讀取資源,同時為TypeValue進行複制擷取原始資源的density等資訊
* 如果圖檔在drawable-xxhdpi,那麼density為480dpi
*/
is = res.openRawResource(id, value);
//從輸入流解碼出一個Bitmap對象,以便根據opts縮放相應的位圖
bm = decodeResourceStream(res, value, is, null, opts);
...
}
顯然真正解碼的方法應該是 decodeResourceStream() 方法,具體如下:
/**
* 從輸入流中解碼出一個Bitmap,并對該Bitmap進行相應的縮放
*/
public static Bitmap decodeResourceStream(Resources res, TypedValue value,
InputStream is, Rect pad, BitmapFactory.Options opts) {
if (opts == null) {
//建立一個預設的Option對象
opts = new BitmapFactory.Options();
}
/**
* 如果設定了inDensity的值,則按照設定的inDensity來計算
* 否則将資源檔案夾所表示的density設定inDensity
*/
if (opts.inDensity == 0 && value != null) {
final int density = value.density;
if (density == TypedValue.DENSITY_DEFAULT) {
opts.inDensity = DisplayMetrics.DENSITY_DEFAULT;
} else if (density != TypedValue.DENSITY_NONE) {
opts.inDensity = density;
}
}
/**
* 同理,也可以通過BitmapFactory.Option對象設定inTargetDensity
* inTargetDensity 表示densityDpi,也就是手機的density
* 使用DisplayMetrics對象.densityDpi獲得
*/
if (opts.inTargetDensity == 0 && res != null) {
opts.inTargetDensity = res.getDisplayMetrics().densityDpi;
}
//decodeStream()方法中調用了native方法
return decodeStream(is, pad, opts);
}
設定完 inDensity 和 inTargetDensity 之後調用了 decodeStream() 方法,該方法傳回完全解碼後的 Bitmap 對象,具體如下:
/**
* 傳回解碼後的Bitmap,
*/
public static Bitmap decodeStream(InputStream is, Rect outPadding, BitmapFactory.Options opts) {
...
bm = nativeDecodeAsset(asset, outPadding, opts);
//調用了native方法:nativeDecodeStream(is, tempStorage, outPadding, opts);
bm = decodeStreamInternal(is, outPadding, opts);
Set the newly decoded bitmap's density based on the Options
//根據Options設定最新解碼的Bitmap
setDensityFromOptions(bm, opts);
...
return bm;
}
顯然,decodeStream() 方法主要調用了本地方法完成 Bitmap 的解碼,跟蹤源碼發現 nativeDecodeAsset() 和 nativeDecodeStream() 方法都調用了 dodecode() 方法,doDecode 方法關鍵代碼如下:
/**
* BitmapFactory.cpp 源碼
*/
static jobject doDecode(JNIEnv*env, SkStreamRewindable*stream, jobject padding, jobject options) {
...
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;
...
//原始位圖的寬高
int scaledWidth = decodingBitmap.width();
int scaledHeight = decodingBitmap.height();
//綜合density和targetDensity計算最終寬高
if (willScale && decodeMode != SkImageDecoder::kDecodeBounds_Mode) {
scaledWidth = int(scaledWidth * scale + 0.5f);
scaledHeight = int(scaledHeight * scale + 0.5f);
}
...
//x、y方向上的縮放比例,大概與scale相等
const float sx = scaledWidth / float(decodingBitmap.width());
const float sy = scaledHeight / float(decodingBitmap.height());
...
//将canvas放大scale,然後繪制Bitmap
SkCanvas canvas (outputBitmap);
canvas.scale(sx, sy);
canvas.drawARGB(0x00, 0x00, 0x00, 0x00);
canvas.drawBitmap(decodingBitmap, 0.0f, 0.0f, & paint);
}
上面代碼能看到縮放比例的計算,以及 density 與 targetDensity 對 Bitmap 寬高的影響,實際上間接影響了 Bitmap 在所占記憶體的大小,這個問題會在下文中舉例說明,注意 density 與目前 Bitmap 所對應資源檔案(圖檔)的目錄有關,如有一張圖檔位于 drawable-xxhdpi 目錄中,其對應的 Bitmap 的 density 為 480dpi,而 targetDensity 就是 DisPlayMetric 的 densityDpi,也就是手機螢幕代表的 density。那麼怎麼檢視 Android 中本地的 native 方法的實作呢,連結如下:
BitmapFactory.cpp,直接搜尋 native 方法的方法名即可,可以試一下咯。
Bitmap 記憶體計算
首先貢獻一張大圖 6000 x 4000 ,圖檔接近 12M,【可在公衆号零點小築索要】 當直接加載這張圖檔到記憶體中肯定會發生 OOM,當然通過适當的位圖采樣縮小圖檔可避免 OOM,那麼 Bitmap 所占記憶體又如何計算呢,一般情況下這樣計算:
Bitmap Memory = widthPix * heightPix * 4
可使用 bitmap.getConfig() 擷取 Bitmap 的格式,這裡是 ARGB_8888 ,這種 Bitmap 格式下一個像素點占 4 個位元組,是以要 x 4,如果将圖檔放置在 Android 的資源檔案夾中,計算方式如下:
scale = targetDensity / density
widthPix = originalWidth * scale
heightPix = orignalHeight * scale
Bitmap Memory = widthPix * scale * heightPix * scale * 4
上述簡單總結了一下 Bitmap 所占記憶體的計算方式,驗證時可使用如下方法擷取 Bitmap 所占記憶體大小:
BitmapMemory = bitmap.getByteCount()
由于選擇的這張圖檔直接加載會導緻 OOM,是以下文的事例中都是先采樣壓縮,然後在進行 Bitmap 所占記憶體的計算。
直接采樣
這種方式就是直接指定采樣比例 inSampleSize 的值,然後先采樣然後計算采樣後的記憶體,這裡指定 inSampleSize 為200。
- 将該圖檔放在 drawable-xxhdpi 目錄中,此時 drawable-xxhdpi 所代表的 density 為 480(density),我的手機螢幕所代表的 density 是 480(targetDensity),顯然,此時 scale 為1,當然首先對圖檔進行采樣,然後将圖檔加載到記憶體中, 此時 Bitmap 所占記憶體記憶體為:
inSampleSize = 200
scale = targetDensity / density} = 480 / 480 = 1
widthPix = orignalScale * scale = 6000 / 200 * 1 = 30
heightPix = orignalHeight * scale = 4000 / 200 * 1 = 20
Bitmap Memory = widthPix * heightPix * 4 = 30 * 20 * 4 = 2400(Byte)
- 将圖檔放在 drawable-xhdpi 目錄中,此時 drawable-xhdpi 所代表的 density 為 320,我的手機螢幕所代表的 density 是 480(targetDensity),将圖檔加載到記憶體中,此時 Bitmap 所代表的記憶體為:
inSampleSize = 200
scale = targetDensity / density = 480 / 320
widthPix = orignalWidth * scale = 6000 / 200 * scale = 45
heightPix = orignalHeight * scale = 4000 / 200 * 480 / 320 = 30
Bitmap Memory = widthPix * scale * heightPix * scale * 4 = 45 * 30 * 4 = 5400(Byte)
計算采樣
這種方式就是根據請求的寬高計算合适的 inSampleSize,而不是随意指定 inSampleSize,實際開發中這種方式最常用,這裡請求寬高為100x100,具體 inSampleSize 計算在上文中已經說明。
- 将圖檔放在 drawable-xxhdpi 目錄中,此時 drawable-xxhdpi 所代表的 density 為 480,我的手機螢幕所代表的 density 是 480(targetDensity),将圖檔加載到記憶體中,此時 Bitmap 所代表的記憶體為:
inSampleSize = 4000 / 100 = 40
scale = targetDensity / density = 480 / 480 = 1
widthPix = orignalWidth * scale = 6000 / 40 * 1 = 150
heightPix = orignalHeight * scale = 4000 / 40 * 1 = 100
BitmapMemory = widthPix * scale * heightPix * scale * 4 = 60000(Byte)
- 将圖檔放在 drawable-xhdpi 目錄中,此時 drawable-xhdpi 所代表的 density 為 320,我的手機螢幕所代表的 density 是 480(targetDensity),将圖檔加載到記憶體中,此時 Bitmap 所代表的記憶體為:
inSampleSize = 4000 / 100 = 40
scale = targetDensity / density = 480 / 320
widthPix = orignalWidth * scale = 6000 / 40 * scale = 225
heightPix = orignalHeight * scale = 4000 / 40 * scale = 150
BitmapMemory = widthPix * heightPix * 4 = 225 * 150 * 4 = 135000(Byte)
位圖采樣及 Bitmap 在不同情況下所占記憶體的計算大概過程如上所述。
測試效果
測試效果圖參考如下:
drawable-xhdpi | drawable-xxhdpi |
---|---|
如果感興趣,可以關注公衆号:jzman-blog,一起交流學習。