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有兩個非常重要的參數, inDensityinTargetDensity ,先來解釋一下這倆參數的作用。

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

 

 本文由用戶 socz3313 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!