Android應用性能優化系列視圖篇——隱藏在資源圖片中的內存殺手
圖片加載性能優化永遠是 Android 領域中一個無法繞過的話題,經過數年的發展,涌現了很多成熟的圖片加載開源庫,比如Fresco、Picasso、UIL等等,使得圖片加載不再是一個頭疼的問題,并且大幅降低了OOM發生的概率。然而,在圖片加載方面我們是否可以就此放松警惕了呢?
開源圖片加載庫能為我們解決絕大部分有關圖片的問題,然而并不是所有!
首先,圖片從來源上可以分成三大類:網絡圖片、手機圖片、APK資源圖片。網絡圖片和手機圖片都在圖片加載庫功能的覆蓋范圍內,基本上不用開發者太操心,但是APK資源圖片卻不在此范圍!
關于APK資源圖片有3個特征:
1、資源圖片基本都是在xml中引用 ,在Java中也是通過資源ID查找 。
2、資源圖片一般不使用異步記載,不會出現loading圖這些中間狀態。
3、資源圖片不會加載失敗,如果失敗了那么APP也掛掉了。
正是由于這3點特征,所以圖片加載庫實在鞭長莫及。那么就很容易出現一個問題:圖片過大導致OOM!
很多APP為了追求酷炫的效果,熱衷于設計絢麗全屏背景頁面。既然是為了炫酷,考慮到用戶體驗,這些全屏背景圖自然不能使用網絡圖片了,所以,這些圖片都被放在apk包中作為資源文件直接引用。
使用這些資源圖片的方式一般都是:
android:background="@drawable/xxx"
正常情況下,這樣使用自然不會出現問題,但是如果APP內存緊張,很容易就出現OOM,尤其是5.0版本以下的手機,經常跑著跑著就Crash了,始作俑者就是這個。
為了解決這種問題,最常用的方式是找設計師壓縮圖片。而壓縮圖片有兩種方式:縮小尺寸和降低質量。那么,這兩種方式是否有效呢?
1、縮小尺寸:壓縮圖片的寬度和高度。由于圖片的內存占用與寬高成正比,這種方式確實有效,但是圖片顯示時會被拉伸導致變形,從而失卻美感。
2、降低質量:降低圖片的色彩度,像素顏色密度。這其實是一個誤區,很多人認為圖片的存儲占用空間小,圖片的內存占用就會小,其實是錯誤的觀點。這是方式并不會影響圖片的內存占用,反而由于質量降低(下文具體分析),使得頁面缺乏質感。必須記住: 圖片的內存占用與圖片質量毫無干系!
為了尋求一個合理的解決方案,必須知彼知己。下面,我們來詳細分析下資源圖片的內存占用的情況!(后文所說的圖片,除非特殊指明,否則都默認指APK資源圖片)。
1、計算Bitmap的內存占用
我們以一張標準720p的全屏圖片為例,寬高比為720×1280,對應的資源文件夾為drawable-xhdpi。同樣,設備以標準720p的小米2S手機為例,density=320。
首先,android設備上圖片都被處理成Bitmap對象。生成Bitmap有一個非常重要的參數Config,屬性值有ALPHA_8、RGB_565、ARGB_4444、ARGB_8888四種。不同的屬性值對應的圖片每個像素點占用內存大小不同,ALPHA_8每個像素占用1byte,RGB_565和ARGB_4444占用2byte,ARGB_8888占用4byte,其中ARGB_4444在高版本中已經廢棄。
那么,資源圖片被decode成Bitmap的時候,Config參數值是哪個呢?來看幾段代碼。
Resources.java
private Drawable loadDrawableForCookie(TypedValue value, int id, Theme theme) {
...
final String file = value.string.toString();
...
final Drawable dr;
if (file.endsWith(".xml")) {
final XmlResourceParser rp = loadXmlResourceParser(file, id, value.assetCookie, "drawable");
dr = Drawable.createFromXml(this, rp, theme);
rp.close();
} else {
final InputStream is = mAssets.openNonAsset(value.assetCookie, file, AssetManager.ACCESS_STREAMING);
dr = Drawable.createFromResourceStream(this, value, is, file, null);
is.close();
}
...
}
Drawable.java
public static Drawable createFromResourceStream(Resources res, TypedValue value,
InputStream is, String srcName, BitmapFactory.Options opts) {
...
if (opts == null) opts = new BitmapFactory.Options();
opts.inScreenDensity = res != null ? res.getDisplayMetrics().noncompatDensityDpi : DisplayMetrics.DENSITY_DEVICE;
Bitmap bm = BitmapFactory.decodeResourceStream(res, value, is, pad, opts);
...
return null;
}
BitmapFactory.java
public static class Options {
...
/**
* Image are loaded with the {@link Bitmap.Config#ARGB_8888} config by default.
*/
public Bitmap.Config inPreferredConfig = Bitmap.Config.ARGB_8888;
...
}
從上面資源文件生成BitmapDrawable的代碼可知,Bitmap.Config使用的是默認值ARGB_8888,即圖像每個像素點占用內存4byte。
我們圖片的尺寸是720×1280,也就是說像素點個數是720×1280=921600,所有像素點占用內存=720x1280x4=3686400 byte=3.515625M,這個大小是圖片不做任何處理時占用的內存大小。
另外,不管圖片的內容是什么樣子,體現在內存中的也僅僅是每個像素點對應的字節的值不同,大小是一樣的,即一張720×1280的空白圖和一張720×1280的彩色絢圖占用內存大小是一致的。 所以說想要降低占用內存,唯有減小寬高尺寸 。
剛剛說過,計算出來的3.515625M大小是圖片未作任何處理時的大小,但是系統在將圖片處理成Drawable對象的時候是否未作處理呢?答案是:不!
BitmapFactory.java
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);
}
代碼中Options有兩個非常重要的參數, inDensity 和 inTargetDensity ,先來解釋一下這倆參數的作用。
inDensity表示被設定的圖像密度,決定這個值的是圖片所放置的文件目錄,比如drawable-hdpi、drawable-xhdpi等等,其對應的density如下表:
代碼中opts.inDensity 被賦值為 value.density,也就是資源維度對應的密度值。如果圖片放在drawable-hdpi下,inDensity=240,如果放在drawable-xhdpi下,inDensity=320。
inTargetDensity表示最終需要適配到的圖片密度,這個值由手機設備來決定,上面代碼中其值為DisplayMetrics的densityDpi,手機屏幕越高清這個值越大,而我們例子中720p的小米2S對應的densityDpi=320。
如果inDensity的值和inTargetDensity的值不相等,那么圖片尺寸就被會縮放,縮放的比例為 inTargetDensity / inDensity。當然,寬高是需要同時等比縮放的,不然圖片就變形了。
前面說過圖片占用內存與圖片的尺寸有關,如果被尺寸縮放了,內存大小就變了。前面未作任何縮放處理的720×1280圖占用內存是3.515625M,假設放在drawable-ldpi目錄下inDensity=120,設備inTargetDensity=320,那么最終的占用內存大小將是3.515625Mx(320/120)x(320/120)=25M。
一張圖片占用25M大小,很恐怖的一個值,這種情況下,app估計直接掛了,如果放在drawable-hdpi下,占用就是6.25M,drawable-xhdpi下占用是3.515625M。由此可見,圖片放置的目錄一定要慎重。
最終我們得出一個公式:
資源圖片內存大小 = 寬 x 高 x 4 x (設備密度 / 資源維度密度)x(設備密度 / 資源維度密度)
2、圖像后門inPurgeable
前面說到,資源圖片防止的目錄不對會導致內存占用翻倍,但也不是放的密度維度越高越好,畢竟還是要做適配,不然小尺寸圖片顯示在高清大屏幕上就不好看了。而即使圖片放對位置,占用內存大小也是相當驚人的,來個十張大圖應用內存就蹭蹭上去了,冷不丁還來個OOM。
相信很多人都找到過解決方案: inPurgeable ,代碼網上一搜一大推:
public static Bitmap readBitmap(Context context, int resId) {
BitmapFactory.Options opt = new BitmapFactory.Options();
opt.inPurgeable = true;
opt.inInputShareable = true;
InputStream is =context.getResources().openRawResource(resId);
return BitmapFactory.decodeStream(is, null, opt);
}
那么,這段代碼是否起效果呢?答案是肯定的,以前經常報OOM的現在都好了,而且用AS的內存監視器一看,加載圖片時基本上不占內存。不管有沒有其它問題,姑且把這個稱之為圖像后門吧。
下面,我們來看這個后門為什么能起效果!
BitmapFactory.java
public static Bitmap decodeStream(InputStream is, Rect outPadding, Options opts) {
if (is == null) {
return null;
}
Bitmap bm = null;
...
if (is instanceof AssetManager.AssetInputStream) {
...
} else {
bm = decodeStreamInternal(is, outPadding, opts);
}
}
private static Bitmap decodeStreamInternal(InputStream is, Rect outPadding, Options opts) {
byte [] tempStorage = null;
if (opts != null) tempStorage = opts.inTempStorage;
if (tempStorage == null) tempStorage = new byte[DECODE_BUFFER_SIZE];
return nativeDecodeStream(is, tempStorage, outPadding, opts);
}
private static native Bitmap nativeDecodeStream(InputStream is, byte[] storage,
Rect padding, Options opts);
很明顯,decodeStream這段代碼最終調用的是native層的類庫,我們追蹤下去查看(下面以JellyBean源碼為例)。
BitmapFactory.cpp
static jobject doDecode(JNIEnv* env, SkStream* stream, jobject padding,
jobject options, bool allowPurgeable, bool forcePurgeable = false,
bool applyScale = false, float scale = 1.0f) {
...
if (!isPurgeable) {
decoder->setAllocator(&javaAllocator);
}
...
if (isPurgeable) {
decodeMode = SkImageDecoder::kDecodeBounds_Mode;
}
...
if (isPurgeable) {
pr = installPixelRef(bitmap, stream, sampleSize, doDither);
} else {
pr = bitmap->pixelRef();
}
...
}
static SkPixelRef* installPixelRef(SkBitmap* bitmap, SkStream* stream,
int sampleSize, bool ditherImage) {
SkImageRef* pr;
// only use ashmem for large images, since mmaps come at a price
if (bitmap->getSize() >= 32 * 1024) {
pr = new SkImageRef_ashmem(stream, bitmap->config(), sampleSize);
} else {
pr = new SkImageRef_GlobalPool(stream, bitmap->config(), sampleSize);
}
...
return pr;
}
相關isPurgeable的代碼就這么多,最終關于圖片的decode邏輯都在installPixelRef中,有一段邏輯值得玩味。如果圖片大小(占用內存)大于32×1024=32K,那么就使用Ashmem,否則就就放入一個引用池中。
這個做法也很容易理解,如果圖片不大,直接放到native層內存中,讀取方便且迅速。如果圖片過大,放到native層內存也就不合理了,不然圖片一多,native層內存很難管理。但是如果使用Ashmem匿名共享內存方式,寫入到設備文件中,需要時再讀取就能避免很大的內存消耗了,另外,這塊內存是由Linux系統的內存管理來管理的,系統內存不足可以直接回收。而且,由于Ashmem跨進程的特性,同一張圖片內存是可以跨進程共享的,這也是inInputShareable屬性的由來。
由此可見,如果inPurgeable=true,圖片所占用的內存就完全與Java Heap無關了,自然就不會有OOM這種煩惱了。
但是,萬事有利有弊,一件事情的成功往往是用犧牲其它方面換來的。
前面說過,使用Resources獲取圖片Drawable的時候,會默認使用inDensity和inTargetDensity屬性縮放圖片來達到適配不同分辨率屏幕的目的。 但是如果設置了inPurgeable=true來避免在Java Heap中分配內存,inDensity和inTargetDensity這兩個屬性就不能再使用了,否則即使inPurgeable=true,圖片仍然會在Java Heap中分配內存。 關于這一點,從以下代碼中可以驗證:
BitmapFactory.cpp
static jobject doDecode(JNIEnv* env, SkStream* stream, jobject padding,
jobject options, bool allowPurgeable, bool forcePurgeable = false,
bool applyScale = false, float scale = 1.0f) {
...
bool willScale = applyScale && scale != 1.0f;
bool isPurgeable = !willScale &&
(forcePurgeable || (allowPurgeable && optionsPurgeable(env, options)));
...
}
在doDecode方法中,isPurgeable會重新賦值,首決條件是圖片不會縮放(willScale),其次才會判斷Options中的isPurgeable屬性。很明顯,如果inDensity和inTargetDensity兩個屬性斷定圖片需要縮放,isPurgeable會被強制設定成false。這么做的原因很簡單,Ashmem不可能維護多套不同尺寸的相同圖片。
如果要解決這種適配問題,唯一的解決方案就是 圖片切成不同的尺寸,放到不同維度的drawable目錄下 。這樣雖然不能做到精準適配,但是可以做到大體適配。原理就是,不同分辨率的屏幕decode相匹配密度維度目錄下的對應尺寸圖片。
說完適配的問題,你以為坑就到此結束了?其實不然,真正的大問題不是這個!
我們來看inPurgeable屬性的一段官方注釋:
While inPurgeable can help avoid big Dalvik heap allocations (from API level 11 onward), it sacrifices performance predictability since any image that the view system tries to draw may incur a decode delay which can lead to dropped frames。
意思就是:雖然inPurgeable能避免在Heap中分配一大段內存,但這個是以犧牲性能為代價的,如果圖片要繪制到View上可能出現延時導致掉幀。
前面說過,inPurgeable=true的情況下,大圖使用Ashmem共享內存存儲圖片,但是這部分內存存儲的僅僅是解碼前的圖片數據,我們獲取的Bitmap只是一個空包彈,不含任何像素信息。當圖片需要渲染的時候,先要對一個個像素點進行解碼,這個過程是比較耗時的,而偏偏又發生在UI線程中,必須等圖像解碼完成,UI線程才能繼續渲染。如果圖片像素點過多,計算量大,很容易就導致卡幀。最坑爹的是,Ashmem內存是由Linux系統統一管理的,如果系統內存緊張,這塊兒圖片內存很容易被回收,當圖片再次被渲染時,Ashmem設備文件就需要重新映射內存再重新解碼。
綜上所訴,雖然inPurgeable既能導致適配問題,又可能導致性能問題,那么我們為什么還要使用呢?理由很簡單:相對于出現OOM導致Crash,這兩點犧牲仍然是值得的!
非死book出品的大名鼎鼎的圖片加載庫Fresco中對圖片的處理都使用了inPurgeable=true,代碼如下 :
BitmapFactory.Options = new BitmapFactory.Options();
options.inPurgeable = true;
Bitmap bitmap = BitmapFactory.decodeByteArray(jpeg, 0, jpeg.length, options);
雖然,Fresco和我們所說的資源圖片干系并不大,但是很多思想還是值得我們借鑒。另外,關于inPurgeable的問題點以及Fresco為什么仍然繼續使用這個屬性。
3、被堵上的后門你還在用?
很多時候,知其然而不知其所以然,很容易出問題,如果又不關注版本變化,就肯定會出問題,inPurgeable就是一個很典型的例子。
在5.0版本及以上,inPurgeable這個屬性已經被標志為過時了!即使inPurgeable=true,也不會再使用Ashmem內存存放圖片,而是直接放到了Java Heap中,簡而言之就是inPurgeable屬性被忽略了。
因為Android系統從5.0開始對Java Heap內存管理做了大幅的優化。和以往不同的是,對象不再統一管理和回收,而是在Java Heap中單獨開辟了一塊區域用來存放大型對象,比如Bitmap這種,同時這塊內存區域的垃圾回收機制也是和其它區域完全分開的,這樣就使得OOM的概率大幅降低,而且讀取效率更高。所以,用Ashmem來存儲圖片就完全沒有必要了,何況后者還會導致性能問題。
既然這樣,我們就需要考慮繼續使用inPurgeable方式處理資源圖片是否合理了。
如果仔細閱讀過Resources的源碼,會發現對于Drawable對象有一套緩存機制,比如當一張圖片被解碼成BitmapDrawable對象后,會被存儲到緩存中,下次再使用這張圖片將優先從緩存中獲取,既避免了圖片重復decode的耗時,又做到了內存的復用,大體代碼如下:
Resources.java
Drawable loadDrawable(TypedValue value, int id, Theme theme) throws NotFoundException {
...
// First, check whether we have a cached version of this drawable
// that was inflated against the specified theme.
if (!mPreloading) {
final Drawable cachedDrawable = caches.getInstance(key, theme);
if (cachedDrawable != null) {
return cachedDrawable;
}
}
// Next, check preloaded drawables. These may contain unresolved theme
// attributes.
final ConstantState cs;
if (isColorDrawable) {
cs = sPreloadedColorDrawables.get(key);
} else {
cs = sPreloadedDrawables[mConfiguration.getLayoutDirection()].get(key);
}
...
// If we were able to obtain a drawable, store it in the appropriate
// cache: preload, not themed, null theme, or theme-specific.
if (dr != null) {
dr.setChangingConfigurations(value.changingConfigurations);
cacheDrawable(value, isColorDrawable, caches, theme, canApplyTheme, key, dr);
}
...
}
在inPurgeable后門被堵上之后,如果我們仍然通過BitmapFactory.decodeStream的方式獲取資源圖片的Bitmap,就會導致相同的圖片重復decode,且多次在Java Heap中開辟內存。
同樣的方式,原本在5.0以下可以節省Java Heap內存占用,在5.0及以上反而成了真正內存殺手!
所以在真正使用inPurgeable時是需要區分版本的,最簡單的解決方案如下:
public static Drawable decodeLargeResourceImage(Resources resources, int resId) {
Drawable drawable;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
drawable = resources.getDrawable(resId, null);
} else {
try {
BitmapFactory.Options opt = new BitmapFactory.Options();
opt.inPurgeable = true;
opt.inInputShareable = true;
InputStream is = resources.openRawResource(resId);
drawable = new BitmapDrawable(resources, BitmapFactory.decodeStream(is, null, opt));
} catch (OutOfMemoryError e) {
drawable = null;
}
}
return drawable;
}
4、總結
之前看過一篇文章,阿里手機淘寶客戶端對存到Ashmem的圖片解碼做了優化,在工作線程中對圖片真正解碼,從而避免在UI線程渲染圖片時解碼,同時鎖住Ashmem內存,防止在系統內存緊張時回收出現二次解碼。
再者,針對資源圖片,目前出現了SVG矢量圖代替常規PNG圖片的解決方案,但也僅僅限于線條簡易的Icon圖。
對于圖片處理這一塊,需要學習和研究的方面太多,路漫漫其修遠兮,吾將上下而求索!
來自:http://www.androidchina.net/5412.html