寫在前面
雪糕刺客是最近被網(wǎng)友們玩壞了的梗,指的是那些以平平無奇的外表混跡于眾多平價(jià)雪糕之中的貴價(jià)雪糕。由于沒有明確標(biāo)明價(jià)格,通常要等到結(jié)賬的時(shí)候才會(huì)發(fā)現(xiàn),猶如一個(gè)潛藏于普通人群中的刺客般,伺機(jī)對(duì)那些大意的顧客們的錢包刺上一劍,因此得名。
而在Android中,也有這么一個(gè)內(nèi)存刺客,其作為我們?nèi)粘i_發(fā)中經(jīng)常接觸的對(duì)象之一,卻常常因?yàn)槭褂梅绞降牟划?dāng),時(shí)不時(shí)地就會(huì)給我們有限的內(nèi)存來上一個(gè)背刺,甚至毫不留情地就給我們拋出一個(gè)OOM,它,就是Bitmap。
為了講好Bitmap這個(gè)話題,本系列文章將分為上下兩篇,上篇從圖像基礎(chǔ)知識(shí)出發(fā),結(jié)合源碼講解Bitmap內(nèi)存的計(jì)算方式;下篇?jiǎng)t基于Android系統(tǒng)提供的API,講解在實(shí)際開發(fā)中如何管理好Bitmap的內(nèi)存,包括縮放、緩存、內(nèi)存復(fù)用等,敬請(qǐng)期待。
本文為上篇,開始之前,先奉上的思維導(dǎo)圖一張,方便后續(xù)復(fù)習(xí):
從一個(gè)問題出發(fā)
假設(shè)有這么一張PNG格式的圖片,其大小為15.3KB,尺寸為96x96,色深為32 bit,放到xhdpi目錄下,并加載到一臺(tái)dpi為480的Android設(shè)備上顯示,那么請(qǐng)問,該圖片實(shí)際會(huì)占用多大的內(nèi)存?
如果你回答不了這個(gè)問題,那你就有必要深入往下讀了。
壓縮格式大小≠占用內(nèi)存大小
首先我們要明確的是,無論是JPEG還是PNG,它們本質(zhì)上都是一種壓縮格式,壓縮的目的是為了降低存儲(chǔ)和傳輸?shù)某杀?/strong>。
區(qū)別就在于:
JPEG是一種有損壓縮格式,壓縮比大,壓縮后的體積比較小,但其高壓縮率是通過去除冗余的圖像數(shù)據(jù)進(jìn)行的,因此解壓后無法還原出完整的原始圖像數(shù)據(jù)。
PNG則是一種無損壓縮格式,不會(huì)損失圖片質(zhì)量,解壓后能還原出完整的原始圖像數(shù)據(jù),但也因此壓縮比小,壓縮后的體積仍然很大。
開篇問題中所特意強(qiáng)調(diào)的圖片大小,實(shí)際指的就是壓縮格式文件的大小。而問題最后所問的圖片實(shí)際占用的內(nèi)存,指的則是解壓縮后顯示在設(shè)備屏幕上的原始圖像數(shù)據(jù)所占用的內(nèi)存。
在實(shí)際的Android開發(fā)中,我們經(jīng)常直接接觸到的原始圖像數(shù)據(jù),就是通過各種decode方法解碼出的Bitmap對(duì)象。
Bitmap即位圖,它還有另外一個(gè)名稱叫做點(diǎn)陣圖,相對(duì)來說,點(diǎn)陣圖這個(gè)名稱更能表述Bitmap的特征。
點(diǎn)指的是像素點(diǎn),陣指的是陣列。點(diǎn)陣圖,就是以像素為最小單位構(gòu)成的圖,縮放會(huì)失真。每個(gè)像素實(shí)則都是一個(gè)非常小的正方形,并被分配不同的顏色,然后通過不同的排列來構(gòu)成像素陣列,最終呈現(xiàn)出完整的圖像。
那么每個(gè)像素是如何存儲(chǔ)自己的顏色信息的呢?這涉及到圖片的色深。
色深是什么?
色深,又叫色彩深度(Color Depth)。假設(shè)色深的數(shù)值為n,代表每個(gè)像素會(huì)采用n個(gè)二進(jìn)制位來存儲(chǔ)顏色信息,也即2的n次方,表示的是每個(gè)像素能顯示2^n種顏色**。
常見的色深有:
1 bit:只能顯示黑與白兩個(gè)中的一個(gè)。因?yàn)樵谏顬?的情況下,每個(gè)像素只能存儲(chǔ)2^1=2種顏色。
8 bit:可以存儲(chǔ)2^8=256種的顏色,典型的如GIF圖像的色深就為8 bit。
-
24 bit:可以存儲(chǔ)2^24=16,777,216種的顏色。每個(gè)像素的顏色由紅(Red)、綠(Green)、藍(lán)(Blue)3個(gè)顏色通道合成,每個(gè)顏色通道用8bit來表示,其取值范圍是:
- 二進(jìn)制:00000000~11111111
- 十進(jìn)制:0~255
- 十六進(jìn)制:00~FF
這里很自然地就讓人聯(lián)想起Android中常用于表示顏色兩種形式,即:
-
Color.rgb(float red, float green, float blue)
,對(duì)應(yīng)十進(jìn)制 -
Color.parceColor(String colorString)
,對(duì)應(yīng)十六進(jìn)制
32 bit:在24位的基礎(chǔ)上,增加多8個(gè)位的透明通道。
色深會(huì)影響圖片的整體質(zhì)量,我們可以來看同一張圖片在不同色深下的表現(xiàn):
可以看出,色深越大,能表示的顏色越豐富,圖片也就越鮮艷,顏色過渡就越平滑。但相對(duì)的,圖片的體積也會(huì)增加,因?yàn)?strong>每個(gè)像素必須存儲(chǔ)更多的顏色信息。
Android中與色深配置相關(guān)的類是Bitmap.Config,其取值會(huì)直接影響位圖的質(zhì)量(色彩深度)以及顯示透明/半透明顏色的能力。在Android 2.3(API 級(jí)別 9)及更高版本中的默認(rèn)配置是ARGB_8888,也即32 bit的色深,1 byte = 8 bit,因此該配置下每個(gè)像素的大小為4 byte。
位圖內(nèi)存 = 像素?cái)?shù)量(分辨率) * 每個(gè)像素的大小,想要進(jìn)一步計(jì)算加載位圖所需要的內(nèi)存,我們還需要得知像素的總數(shù)量,而描述像素?cái)?shù)量的說法就是分辨率。
分辨率是什么?
如果說,色深決定了位圖顏色的豐富程度,那么分辨率決定的則是位圖圖像細(xì)節(jié)的精細(xì)程度。圖像的分辨率越高,所包含的像素就越多,圖像也就越清晰,同樣的,它也會(huì)相應(yīng)增加圖片的體積。
通常,我們用每一個(gè)方向上的像素?cái)?shù)量來表示分辨率,也即水平像素?cái)?shù)×垂直像素?cái)?shù),比如320×240,640×480,1280×1024等。
一張分辨率為640x480的圖片,其像素?cái)?shù)量就達(dá)到了307200,也就是我們常說的30萬像素。
現(xiàn)在,我們明白了公式中2個(gè)變量的含義,就可以代入開篇問題中的例子來計(jì)算位圖內(nèi)存:
96 * 96 * 4 byte = 36864 bytes = 36KB
Bitmap提供了兩個(gè)方法用于獲取系統(tǒng)為該Bitmap存儲(chǔ)像素所分配的內(nèi)存大小,分別為:
public int getByteCount ()
public int getAllocationByteCount ()
一般情況下,兩個(gè)方法返回的值是相同的。但如果我們手動(dòng)重新配置了Bitmap的屬性(寬、高、Bitmap.Config等),或者將BitmapFactory.Options.inBitmap屬性設(shè)為true以支持其他更小的Bitmap復(fù)用其內(nèi)存時(shí),那么getAllocationByteCount ()返回的值就有可能會(huì)大于getByteCount()。
我們暫時(shí)不考慮以上兩種場(chǎng)景,所以直接選擇調(diào)用getByteCount方法 ()來獲取為Bitmap分配的字節(jié)數(shù),得到的結(jié)果是:82944 bytes = 81KB。
可以看到,getByteCount方法返回的值與我們的計(jì)算結(jié)果有差異,是我們的計(jì)算公式有問題嗎?
探究getByteCount()的計(jì)算公式
為了驗(yàn)證我們的計(jì)算公式是否準(zhǔn)確,我們需要深入getByteCount()方法的源碼進(jìn)行探究。
public final int getByteCount() {
if (mRecycled) {
Log.w(TAG, "Called getByteCount() on a recycle()'d bitmap! "
+ "This is undefined behavior!");
return 0;
}
// int result permits bitmaps up to 46,340 x 46,340
return getRowBytes() * getHeight();
}
可以看到,getByteCount()方法的返回值是每一行的字節(jié)數(shù) * 高度,那么每一行的字節(jié)數(shù)又是怎么計(jì)算的呢?
public final int getRowBytes() {
if (mRecycled) {
Log.w(TAG, "Called getRowBytes() on a recycle()'d bitmap! This is undefined behavior!");
}
return nativeRowBytes(mFinalizer.mNativeBitmap);
}
正如你所見,getRowBytes()方法的實(shí)現(xiàn)是在Native層。先別灰心,接下來坐好扶穩(wěn)了,我們省去一些不重要的步驟,乘坐飛船一路跨越Bitmap.cpp、SkBitmap.h,途徑SkBitmap.cpp時(shí)稍微停下:
size_t SkBitmap::ComputeRowBytes(Config c, int width) {
return SkColorTypeMinRowBytes(SkBitmapConfigToColorType(c), width);
}
并最終到達(dá)SkImageInfo.h:
static int SkColorTypeBytesPerPixel(SkColorType ct) {
static const uint8_t gSize[] = {
0, // Unknown
1, // Alpha_8
2, // RGB_565
2, // ARGB_4444
4, // RGBA_8888
4, // BGRA_8888
1, // kIndex_8
};
SK_COMPILE_ASSERT(SK_ARRAY_COUNT(gSize) == (size_t)(kLastEnum_SkColorType + 1),
size_mismatch_with_SkColorType_enum);
SkASSERT((size_t)ct < SK_ARRAY_COUNT(gSize));
return gSize[ct];
}
static inline size_t SkColorTypeMinRowBytes(SkColorType ct, int width) {
return width * SkColorTypeBytesPerPixel(ct);
}
都說正確清晰的函數(shù)名有替代注釋的作用,這就是優(yōu)秀的典范。
讓我們把目光停留在width * SkColorTypeBytesPerPixel(ct)
這一行,不難看出,其計(jì)算方式是先根據(jù)顏色類型獲取每個(gè)像素對(duì)應(yīng)的字節(jié)數(shù),再去乘以其寬度。
那么,結(jié)合Bitmap.java的getByteCount()方法的實(shí)現(xiàn),我們最終得出,系統(tǒng)為Bitmap存儲(chǔ)像素所分配的內(nèi)存大小 = 寬度 * 每個(gè)像素的大小 * 高度,與我們上面的計(jì)算公式一致。
公式?jīng)]錯(cuò),那問題究竟出在哪里呢?
其實(shí),如果我們的圖片是從磁盤、網(wǎng)絡(luò)等地方獲取的,理論上確實(shí)是按照上面的公式那樣計(jì)算沒錯(cuò)。但你還記得嗎?我們?cè)陂_篇的問題中,還特意強(qiáng)調(diào)了圖片是放在xhdpi目錄下的。在Android設(shè)備上,這種情況下計(jì)算位圖內(nèi)存,還有一個(gè)維度要考慮進(jìn)來,那就是像素密度。
像素密度是什么?
像素密度指的是屏幕單位面積內(nèi)的像素?cái)?shù),稱為dpi(dots per inch,每英寸點(diǎn)數(shù))。當(dāng)兩個(gè)設(shè)備的尺寸相同而像素密度不同時(shí),圖像的效果呈現(xiàn)如下:
是不是感覺跟分辨率的概念有點(diǎn)像?區(qū)別就在于,前者是屏幕單位面積內(nèi)的像素?cái)?shù),后者是屏幕上的總像素?cái)?shù)。
由于Android是開源的,任何硬件制造商都可以制造搭載Android系統(tǒng)的設(shè)備,因此從手表、手機(jī)到平板電腦再到電視,各種屏幕尺寸和屏幕像素密度的設(shè)備層出不窮。
為了優(yōu)化不同屏幕配置下的用戶體驗(yàn),確保圖像能在所有屏幕上顯示最佳效果,Android建議應(yīng)針對(duì)常見的不同的屏幕尺寸和屏幕像素密度,提供對(duì)應(yīng)的圖片資源。于是就有了Android工程res目錄下,加上各種配置限定符的drawable/mipmap文件夾。
為了簡(jiǎn)化不同的配置,Android針對(duì)不同像素密度范圍進(jìn)行了歸納分組,如下:
我們通常選取中密度 (mdpi) 作為基準(zhǔn)密度(1倍圖),并保持ldpi~xxxhdpi這六種主要密度之間 3:4:6:8:12:16 的縮放比,來放置相應(yīng)尺寸的圖片資源。
例如,在創(chuàng)建Android工程時(shí)IDE默認(rèn)為我們添加的ic_launcher圖標(biāo),就遵循了這個(gè)規(guī)則。該圖標(biāo)在中密度 (mdpi)目錄下的大小為48x48,在其他各種密度的目錄下的大小則分別為:
- 36x36 (0.75x) - 低密度 (ldpi)
- 48x48(1.0x 基準(zhǔn))- 中密度 (mdpi)
- 72x72 (1.5x) - 高密度 (hdpi)
- 96x96 (2.0x) - 超高密度 (xhdpi)
- 144x144 (3.0x) - 超超高密度 (xxhdpi)
- 192x192 (4.0x) - 超超超高密度 (xxxhdpi)
當(dāng)我們引用該圖標(biāo)時(shí),系統(tǒng)就會(huì)根據(jù)所運(yùn)行設(shè)備屏幕的dpi,與不同密度目錄名稱中的限定符進(jìn)行比較,來選取最符合當(dāng)前設(shè)備的圖片資源。如果在該密度目錄下沒有找到合適的圖片資源,系統(tǒng)會(huì)有對(duì)應(yīng)的規(guī)則查找另外一個(gè)可能的匹配資源,并對(duì)其進(jìn)行相應(yīng)的縮放,以適配屏幕,由此可能造成圖片有明顯的模糊失真。
那么,具體的查找規(guī)則是怎樣的呢?
Android查找最佳匹配資源的規(guī)則
一般來說,Android會(huì)更傾向于縮小較大的原始圖像,而非放大較小的原始圖像。在此前提下:
- 假設(shè)最接近設(shè)備屏幕密度的目錄選項(xiàng)為xhdpi,如果圖片資源存在,則匹配成功;
- 如果不存在,系統(tǒng)就會(huì)從更高密度的資源目錄下查找,依次為xxhdpi、xxxhdpi;
- 如果還不存在,系統(tǒng)就會(huì)從像素密度無關(guān)的資源目錄nodpi下查找;
- 如果還不存在,系統(tǒng)就會(huì)向更低密度的資源目錄下查找,依次為hdpi、mdpi、ldpi。
那么,當(dāng)匹配到其他密度目錄下的圖片資源后,對(duì)于原始圖像的放大或縮小,Android是怎么實(shí)現(xiàn)的呢?又會(huì)對(duì)加載位圖所需要的內(nèi)存有什么影響呢?
想解決這些疑惑,我們還是得從源碼中找尋答案。
decode*方法的貓膩
眾所周知,在Android中要讀取drawable/mipmap目錄下的圖片資源,需要用到的是BitmapFactory類下的decodeResource方法:
public static Bitmap decodeResource(Resources res, int id, Options opts) {
...
final TypedValue value = new TypedValue();
is = res.openRawResource(id, value);
bm = decodeResourceStream(res, value, is, null, opts);
...
}
decodeResource方法的主要工作,就只是調(diào)用Resource#openRawResource方法讀取原始圖片資源,同時(shí)傳遞一個(gè)TypedValue對(duì)象用于持有圖片資源的相關(guān)信息,并返回一個(gè)輸入流作為內(nèi)部繼續(xù)調(diào)用decodeResourceStream方法的參數(shù)。
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) {
opts.inDensity = DisplayMetrics.DENSITY_DEFAULT;
} else if (density != TypedValue.DENSITY_NONE) {
opts.inDensity = density;
}
}
if (opts.inTargetDensity == 0 && res != null) {
opts.inTargetDensity = res.getDisplayMetrics().densityDpi;
}
return decodeStream(is, pad, opts);
}
decodeResourceStream方法的主要工作,則是負(fù)責(zé)Options(解碼選項(xiàng))類2個(gè)重要參數(shù)inDensity和inTargetDensity的初始化,其中:
- inDensity代表的是Bitmap的像素密度,取決于原始圖片資源所存放的密度目錄。
- inTargetDensity代表的是Bitmap將繪制到的目標(biāo)的像素密度,通常就是指屏幕的像素密度。
這兩個(gè)參數(shù)起什么作用呢,讓我們繼續(xù)往下看:
public static Bitmap decodeStream(InputStream is, Rect outPadding, Options opts) {
···
if (is instanceof AssetManager.AssetInputStream) {
final long asset = ((AssetManager.AssetInputStream) is).getNativeAsset();
bm = nativeDecodeAsset(asset, outPadding, opts);
} else {
bm = decodeStreamInternal(is, outPadding, opts);
}
···
}
private static Bitmap decodeStreamInternal(InputStream is, Rect outPadding, Options opts) {
byte [] tempStorage = null;
if (tempStorage == null) tempStorage = new byte[DECODE_BUFFER_SIZE];
return nativeDecodeStream(is, tempStorage, outPadding, opts);
}
又見到熟悉的Native層方法了,讓我們重新開動(dòng)星際飛船再次跨越到BitmapFactory.cpp下查看:
static jobject nativeDecodeStream(JNIEnv* env, jobject clazz, jobject is, jbyteArray storage, jobject padding, jobject options) {
···
bitmap = doDecode(env, bufferedStream, padding, options);
···
}
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;
}
}
···
const bool willScale = scale != 1.0f;
···
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);
}
if (options != NULL) {
env->SetIntField(options, gOptions_widthFieldID, scaledWidth);
env->SetIntField(options, gOptions_heightFieldID, scaledHeight);
env->SetObjectField(options, gOptions_mimeFieldID,
getMimeTypeString(env, decoder->getFormat()));
}
...
}
以上節(jié)選的doDecode方法的部分源碼,就是Android系統(tǒng)如何對(duì)其他密度目錄下的原始圖像進(jìn)行縮放的具體實(shí)現(xiàn),我們來梳理一下它的執(zhí)行邏輯:
- 首先,設(shè)置scale值也即初始的縮放比為1。
- 取出關(guān)鍵的density值以及targetDensity值,以目標(biāo)像素密度/位圖像素密度重新計(jì)算縮放比。
- 如果縮放比不再為1,則說明原始圖像需要進(jìn)行縮放。
- 取出待解碼的位圖的寬度,按int(scaledWidth * scale + 0.5f)計(jì)算縮放后的寬度,高度同理。
- 重新填充縮放后的寬高回Options。
基于以上內(nèi)容,我們重新調(diào)整下我們的計(jì)算公式:
位圖內(nèi)存 = (位圖寬度 * 縮放比) * 每個(gè)像素的大小 * (位圖高度 * 縮放比)
= (96 * 1.5) * 4 * (96 * 1.5)
= 82944 bytes = 81KB
可以看到,這樣計(jì)算得出來的結(jié)果則與Bitmap#getByteCount()返回的值一致。
總結(jié)
匯總上述的所有內(nèi)容后,我們可以得出結(jié)論,即:
Android系統(tǒng)為Bitmap存儲(chǔ)像素所分配的內(nèi)存大小,取決于以下幾個(gè)因素:
- 色深,也即每個(gè)像素的大小,對(duì)應(yīng)的是Bitmap.Config的配置。
- 分辨率,也即像素的總數(shù)量,對(duì)應(yīng)的是Bitmap的高度和寬度
- 像素密度,對(duì)應(yīng)的是圖片資源所在的密度目錄,以及設(shè)備的屏幕像素密度
由此我們還衍生出其他的結(jié)論,即:
- 圖片資源放到正確的密度目錄很重要,否則可能對(duì)會(huì)較大尺寸的圖片進(jìn)行不合理的縮放,從而加大不必要的內(nèi)存占用。
- 如果是為了減少包體積而不想提供所有密度目錄下不同尺寸的圖片,應(yīng)優(yōu)先提供更高密度目錄下的圖片資源,可以避免圖片失真。
- ...