寫在前面
雪糕刺客是最近被網(wǎng)友們玩壞了的梗,指的是那些以平平無奇的外表混跡于眾多平價雪糕之中的貴價雪糕坷澡。由于沒有明確標明價格托呕,通常要等到結(jié)賬的時候才會發(fā)現(xiàn),猶如一個潛藏于普通人群中的刺客般频敛,伺機對那些大意的顧客們的錢包刺上一劍项郊,因此得名。
而在Android中斟赚,也有這么一個內(nèi)存刺客着降,其作為我們?nèi)粘i_發(fā)中經(jīng)常接觸的對象之一,卻常常因為使用方式的不當(dāng)拗军,時不時地就會給我們有限的內(nèi)存來上一個背刺任洞,甚至毫不留情地就給我們拋出一個OOM蓄喇,它,就是Bitmap交掏。
為了講好Bitmap這個話題妆偏,本系列文章將分為上下兩篇,上篇從圖像基礎(chǔ)知識出發(fā)耀销,結(jié)合源碼講解Bitmap內(nèi)存的計算方式楼眷;下篇則基于Android系統(tǒng)提供的API,講解在實際開發(fā)中如何管理好Bitmap的內(nèi)存熊尉,包括縮放罐柳、緩存、內(nèi)存復(fù)用等狰住,敬請期待张吉。
本文為上篇,開始之前催植,先奉上的思維導(dǎo)圖一張肮蛹,方便后續(xù)復(fù)習(xí):
從一個問題出發(fā)
假設(shè)有這么一張PNG格式的圖片,其大小為15.3KB创南,尺寸為96x96伦忠,色深為32 bit,放到xhdpi目錄下稿辙,并加載到一臺dpi為480的Android設(shè)備上顯示昆码,那么請問,該圖片實際會占用多大的內(nèi)存邻储?
如果你回答不了這個問題赋咽,那你就有必要深入往下讀了。
壓縮格式大小≠占用內(nèi)存大小
首先我們要明確的是吨娜,無論是JPEG還是PNG脓匿,它們本質(zhì)上都是一種壓縮格式,壓縮的目的是為了降低存儲和傳輸?shù)某杀?/strong>宦赠。
區(qū)別就在于:
JPEG是一種有損壓縮格式陪毡,壓縮比大,壓縮后的體積比較小勾扭,但其高壓縮率是通過去除冗余的圖像數(shù)據(jù)進行的缤骨,因此解壓后無法還原出完整的原始圖像數(shù)據(jù)。
PNG則是一種無損壓縮格式尺借,不會損失圖片質(zhì)量,解壓后能還原出完整的原始圖像數(shù)據(jù)精拟,但也因此壓縮比小燎斩,壓縮后的體積仍然很大虱歪。
開篇問題中所特意強調(diào)的圖片大小,實際指的就是壓縮格式文件的大小栅表。而問題最后所問的圖片實際占用的內(nèi)存笋鄙,指的則是解壓縮后顯示在設(shè)備屏幕上的原始圖像數(shù)據(jù)所占用的內(nèi)存。
在實際的Android開發(fā)中怪瓶,我們經(jīng)常直接接觸到的原始圖像數(shù)據(jù)萧落,就是通過各種decode方法解碼出的Bitmap對象。
Bitmap即位圖洗贰,它還有另外一個名稱叫做點陣圖找岖,相對來說,點陣圖這個名稱更能表述Bitmap的特征敛滋。
點指的是像素點许布,陣指的是陣列。點陣圖绎晃,就是以像素為最小單位構(gòu)成的圖蜜唾,縮放會失真。每個像素實則都是一個非常小的正方形庶艾,并被分配不同的顏色袁余,然后通過不同的排列來構(gòu)成像素陣列,最終呈現(xiàn)出完整的圖像咱揍。
那么每個像素是如何存儲自己的顏色信息的呢颖榜?這涉及到圖片的色深。
色深是什么述召?
色深朱转,又叫色彩深度(Color Depth)。假設(shè)色深的數(shù)值為n积暖,代表每個像素會采用n個二進制位來存儲顏色信息藤为,也即2的n次方,表示的是每個像素能顯示2^n種顏色**夺刑。
常見的色深有:
1 bit:只能顯示黑與白兩個中的一個岩灭。因為在色深為1的情況下喘先,每個像素只能存儲2^1=2種顏色。
8 bit:可以存儲2^8=256種的顏色,典型的如GIF圖像的色深就為8 bit败潦。
-
24 bit:可以存儲2^24=16,777,216種的顏色。每個像素的顏色由紅(Red)朋其、綠(Green)澄阳、藍(Blue)3個顏色通道合成,每個顏色通道用8bit來表示坞笙,其取值范圍是:
- 二進制:00000000~11111111
- 十進制:0~255
- 十六進制:00~FF
這里很自然地就讓人聯(lián)想起Android中常用于表示顏色兩種形式岩饼,即:
-
Color.rgb(float red, float green, float blue)
荚虚,對應(yīng)十進制 -
Color.parceColor(String colorString)
,對應(yīng)十六進制
32 bit:在24位的基礎(chǔ)上籍茧,增加多8個位的透明通道版述。
色深會影響圖片的整體質(zhì)量,我們可以來看同一張圖片在不同色深下的表現(xiàn):
可以看出寞冯,色深越大渴析,能表示的顏色越豐富,圖片也就越鮮艷吮龄,顏色過渡就越平滑俭茧。但相對的,圖片的體積也會增加螟蝙,因為每個像素必須存儲更多的顏色信息恢恼。
Android中與色深配置相關(guān)的類是Bitmap.Config,其取值會直接影響位圖的質(zhì)量(色彩深度)以及顯示透明/半透明顏色的能力胰默。在Android 2.3(API 級別 9)及更高版本中的默認配置是ARGB_8888场斑,也即32 bit的色深,1 byte = 8 bit牵署,因此該配置下每個像素的大小為4 byte漏隐。
位圖內(nèi)存 = 像素數(shù)量(分辨率) * 每個像素的大小,想要進一步計算加載位圖所需要的內(nèi)存奴迅,我們還需要得知像素的總數(shù)量青责,而描述像素數(shù)量的說法就是分辨率。
分辨率是什么取具?
如果說脖隶,色深決定了位圖顏色的豐富程度,那么分辨率決定的則是位圖圖像細節(jié)的精細程度暇检。圖像的分辨率越高产阱,所包含的像素就越多,圖像也就越清晰块仆,同樣的构蹬,它也會相應(yīng)增加圖片的體積。
通常悔据,我們用每一個方向上的像素數(shù)量來表示分辨率庄敛,也即水平像素數(shù)×垂直像素數(shù),比如320×240科汗,640×480藻烤,1280×1024等。
一張分辨率為640x480的圖片,其像素數(shù)量就達到了307200隐绵,也就是我們常說的30萬像素之众。
現(xiàn)在,我們明白了公式中2個變量的含義依许,就可以代入開篇問題中的例子來計算位圖內(nèi)存:
96 * 96 * 4 byte = 36864 bytes = 36KB
Bitmap提供了兩個方法用于獲取系統(tǒng)為該Bitmap存儲像素所分配的內(nèi)存大小,分別為:
public int getByteCount ()
public int getAllocationByteCount ()
一般情況下缀蹄,兩個方法返回的值是相同的峭跳。但如果我們手動重新配置了Bitmap的屬性(寬、高缺前、Bitmap.Config等)蛀醉,或者將BitmapFactory.Options.inBitmap屬性設(shè)為true以支持其他更小的Bitmap復(fù)用其內(nèi)存時,那么getAllocationByteCount ()返回的值就有可能會大于getByteCount()衅码。
我們暫時不考慮以上兩種場景拯刁,所以直接選擇調(diào)用getByteCount方法 ()來獲取為Bitmap分配的字節(jié)數(shù),得到的結(jié)果是:82944 bytes = 81KB逝段。
可以看到垛玻,getByteCount方法返回的值與我們的計算結(jié)果有差異,是我們的計算公式有問題嗎奶躯?
探究getByteCount()的計算公式
為了驗證我們的計算公式是否準確帚桩,我們需要深入getByteCount()方法的源碼進行探究。
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ù)又是怎么計算的呢?
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()方法的實現(xiàn)是在Native層郭蕉。先別灰心,接下來坐好扶穩(wěn)了喂江,我們省去一些不重要的步驟召锈,乘坐飛船一路跨越Bitmap.cpp、SkBitmap.h开呐,途徑SkBitmap.cpp時稍微停下:
size_t SkBitmap::ComputeRowBytes(Config c, int width) {
return SkColorTypeMinRowBytes(SkBitmapConfigToColorType(c), width);
}
并最終到達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ù)顏色類型獲取每個像素對應(yīng)的字節(jié)數(shù),再去乘以其寬度瓦戚。
那么沮尿,結(jié)合Bitmap.java的getByteCount()方法的實現(xiàn),我們最終得出,系統(tǒng)為Bitmap存儲像素所分配的內(nèi)存大小 = 寬度 * 每個像素的大小 * 高度畜疾,與我們上面的計算公式一致赴邻。
公式?jīng)]錯,那問題究竟出在哪里呢啡捶?
其實姥敛,如果我們的圖片是從磁盤、網(wǎng)絡(luò)等地方獲取的瞎暑,理論上確實是按照上面的公式那樣計算沒錯彤敛。但你還記得嗎?我們在開篇的問題中了赌,還特意強調(diào)了圖片是放在xhdpi目錄下的墨榄。在Android設(shè)備上,這種情況下計算位圖內(nèi)存勿她,還有一個維度要考慮進來袄秩,那就是像素密度。
像素密度是什么逢并?
像素密度指的是屏幕單位面積內(nèi)的像素數(shù)之剧,稱為dpi(dots per inch,每英寸點數(shù))筒狠。當(dāng)兩個設(shè)備的尺寸相同而像素密度不同時猪狈,圖像的效果呈現(xiàn)如下:
是不是感覺跟分辨率的概念有點像?區(qū)別就在于辩恼,前者是屏幕單位面積內(nèi)的像素數(shù)雇庙,后者是屏幕上的總像素數(shù)。
由于Android是開源的灶伊,任何硬件制造商都可以制造搭載Android系統(tǒng)的設(shè)備疆前,因此從手表、手機到平板電腦再到電視聘萨,各種屏幕尺寸和屏幕像素密度的設(shè)備層出不窮竹椒。
為了優(yōu)化不同屏幕配置下的用戶體驗,確保圖像能在所有屏幕上顯示最佳效果米辐,Android建議應(yīng)針對常見的不同的屏幕尺寸和屏幕像素密度胸完,提供對應(yīng)的圖片資源。于是就有了Android工程res目錄下翘贮,加上各種配置限定符的drawable/mipmap文件夾赊窥。
為了簡化不同的配置,Android針對不同像素密度范圍進行了歸納分組狸页,如下:
我們通常選取中密度 (mdpi) 作為基準密度(1倍圖)锨能,并保持ldpi~xxxhdpi這六種主要密度之間 3:4:6:8:12:16 的縮放比,來放置相應(yīng)尺寸的圖片資源。
例如址遇,在創(chuàng)建Android工程時IDE默認為我們添加的ic_launcher圖標熄阻,就遵循了這個規(guī)則。該圖標在中密度 (mdpi)目錄下的大小為48x48倔约,在其他各種密度的目錄下的大小則分別為:
- 36x36 (0.75x) - 低密度 (ldpi)
- 48x48(1.0x 基準)- 中密度 (mdpi)
- 72x72 (1.5x) - 高密度 (hdpi)
- 96x96 (2.0x) - 超高密度 (xhdpi)
- 144x144 (3.0x) - 超超高密度 (xxhdpi)
- 192x192 (4.0x) - 超超超高密度 (xxxhdpi)
當(dāng)我們引用該圖標時秃殉,系統(tǒng)就會根據(jù)所運行設(shè)備屏幕的dpi,與不同密度目錄名稱中的限定符進行比較跺株,來選取最符合當(dāng)前設(shè)備的圖片資源复濒。如果在該密度目錄下沒有找到合適的圖片資源,系統(tǒng)會有對應(yīng)的規(guī)則查找另外一個可能的匹配資源乒省,并對其進行相應(yīng)的縮放,以適配屏幕畦木,由此可能造成圖片有明顯的模糊失真袖扛。
那么,具體的查找規(guī)則是怎樣的呢十籍?
Android查找最佳匹配資源的規(guī)則
一般來說蛆封,Android會更傾向于縮小較大的原始圖像,而非放大較小的原始圖像勾栗。在此前提下:
- 假設(shè)最接近設(shè)備屏幕密度的目錄選項為xhdpi惨篱,如果圖片資源存在,則匹配成功围俘;
- 如果不存在砸讳,系統(tǒng)就會從更高密度的資源目錄下查找,依次為xxhdpi界牡、xxxhdpi簿寂;
- 如果還不存在,系統(tǒng)就會從像素密度無關(guān)的資源目錄nodpi下查找宿亡;
- 如果還不存在常遂,系統(tǒng)就會向更低密度的資源目錄下查找,依次為hdpi挽荠、mdpi克胳、ldpi。
那么圈匆,當(dāng)匹配到其他密度目錄下的圖片資源后漠另,對于原始圖像的放大或縮小,Android是怎么實現(xiàn)的呢臭脓?又會對加載位圖所需要的內(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方法讀取原始圖片資源葫录,同時傳遞一個TypedValue對象用于持有圖片資源的相關(guān)信息着裹,并返回一個輸入流作為內(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方法的主要工作米同,則是負責(zé)Options(解碼選項)類2個重要參數(shù)inDensity和inTargetDensity的初始化骇扇,其中:
- inDensity代表的是Bitmap的像素密度,取決于原始圖片資源所存放的密度目錄面粮。
- inTargetDensity代表的是Bitmap將繪制到的目標的像素密度少孝,通常就是指屏幕的像素密度。
這兩個參數(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層方法了稍走,讓我們重新開動星際飛船再次跨越到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)如何對其他密度目錄下的原始圖像進行縮放的具體實現(xiàn)柴底,我們來梳理一下它的執(zhí)行邏輯:
- 首先婿脸,設(shè)置scale值也即初始的縮放比為1。
- 取出關(guān)鍵的density值以及targetDensity值柄驻,以目標像素密度/位圖像素密度重新計算縮放比狐树。
- 如果縮放比不再為1,則說明原始圖像需要進行縮放鸿脓。
- 取出待解碼的位圖的寬度抑钟,按int(scaledWidth * scale + 0.5f)計算縮放后的寬度,高度同理答憔。
- 重新填充縮放后的寬高回Options味赃。
基于以上內(nèi)容,我們重新調(diào)整下我們的計算公式:
位圖內(nèi)存 = (位圖寬度 * 縮放比) * 每個像素的大小 * (位圖高度 * 縮放比)
= (96 * 1.5) * 4 * (96 * 1.5)
= 82944 bytes = 81KB
可以看到虐拓,這樣計算得出來的結(jié)果則與Bitmap#getByteCount()返回的值一致心俗。
總結(jié)
匯總上述的所有內(nèi)容后,我們可以得出結(jié)論蓉驹,即:
Android系統(tǒng)為Bitmap存儲像素所分配的內(nèi)存大小城榛,取決于以下幾個因素:
- 色深,也即每個像素的大小态兴,對應(yīng)的是Bitmap.Config的配置狠持。
- 分辨率,也即像素的總數(shù)量瞻润,對應(yīng)的是Bitmap的高度和寬度
- 像素密度喘垂,對應(yīng)的是圖片資源所在的密度目錄甜刻,以及設(shè)備的屏幕像素密度
由此我們還衍生出其他的結(jié)論,即:
- 圖片資源放到正確的密度目錄很重要正勒,否則可能對會較大尺寸的圖片進行不合理的縮放得院,從而加大不必要的內(nèi)存占用。
- 如果是為了減少包體積而不想提供所有密度目錄下不同尺寸的圖片章贞,應(yīng)優(yōu)先提供更高密度目錄下的圖片資源祥绞,可以避免圖片失真。
- ...