探索Bitmap使用姿勢

暴打小女孩發表於2019-02-03

前言

早些時候對Android下GC呼叫時機比較好奇,所以寫了一些case測試各種情況下Android GC呼叫時機與現象,感興趣的話可以跳過去瞅瞅 : 《Android GC機制實踐調研》

在這個過程中發現一個讓人非常震驚的問題:從資原始檔中載入一張110kb的圖片建立Bitmap物件,佔用的記憶體高達40MB! 為什麼為什麼為什麼??

於是這篇部落格便產生了,我希望可以通過一系列測試case,來了解Bitmap在各種場景下的各種使用姿勢將會在記憶體佔用和載入速度兩方面都有哪些表現,從而從中探索可能的優化點和最佳實踐。

各種場景下建立Bitmap記憶體佔用

從資原始檔建立Bitmap

1.不同解析度的drawable資料夾下載入相同素材,Bitmap的記憶體佔用大小

這裡我們準備了一張117.16kb 1200*900的jpg圖片放到了res/各種解析度的drawabe目錄下。對他們進行分別載入然後輸出各種值進行對比,需要說明一下這裡載入的意思可以是:執行bitmapFactory.decodeResource 。 與給ImageView設定Resource 、給佈局設定背景等建立建立Bitmap或進行圖片顯示的操作相同。

看下實驗資料

【努比亞Z9 Nubia NX508J】 解析度1080 * 1920 畫素密度:424ppi

資料夾 getByteCount getRowBytes getHeight getWidth
drawable 38880000b ≈ 37mb 14400b 2700 3600
mdip 38880000b ≈ 37mb 14400b 2700 3600
xhdip 9720000b ≈ 9mb 7200b 1350 1800
xxhdip 4320000b ≈ 4mb 4800b 900 1200

38880000b是什麼概念?37MB!! 想一下,你的應用還啥都沒幹呢,就僅是載入了一張圖片將近40MB的記憶體就被佔用了,再加上其他一些操作,記憶體妥妥的就跳到臨界值了,如果再有一些不當的溢位,OOM指日可待!

似乎,圖片放在解析度越高的資料夾下,記憶體佔用越小

2.不同格式的圖片建立Bitmap記憶體佔用大小

上面測試用的是jpg,而通常我們開發中使用的都是png,看到這麼大的記憶體佔用,我有想過是否是因為圖片格式的問題,於是把這張圖片丟到美圖秀秀裡(美圖秀秀真好用),然後分別匯出了長寬一樣的jpg和png兩張圖片,放到資原始檔夾中進行載入。

【努比亞Z9  Nubia NX508J】
drawable_jpg_1.jpg 1200*900  135.76kb
drawable_png_1.png 1200*900  1.64mb

jpg getByteCount : 38880000 getRowBytes:14400 getHeight:2700 getWidth:3600
png getByteCount : 38880000 getRowBytes:14400 getHeight:2700 getWidth:3600
複製程式碼

記憶體佔用和之前一樣,並且雖然png的圖片本身高達1.64mb,但記憶體佔用依然只是37mb。

從資原始檔中載入圖片的記憶體佔用與圖片格式、圖片佔硬碟大小無關!(但和apk包體積有關)

3.不同的解析度的裝置載入同一張素材,Bitmap記憶體佔用大小

Android存在著很多解析度適配問題,不同drawable資料夾也是為了適配而存在的,所以我們還要挑幾個解析度不一樣的手機看一下:

【榮耀暢玩4X】 解析度:1280 * 720 畫素密度:267ppi

資料夾 getByteCount getRowBytes getHeight getWidth
drawable 17280000b ≈ 16mb 9600b 1800 2400
mdip 17280000b ≈ 16mb 9600b 1800 2400
xhdip 4320000b ≈ 4mb 4800b 900 1200
xxhdip 1920000b ≈ 2mb 3200b 600 800

誒?很明顯啊,選一個解析度低一點的手機,果然相同條件的圖片載入記憶體佔用是不一樣的。我這正好還有一個和努比亞解析度一樣的手機,用這個也測一下:

【樂視 le x620】 解析度:1080 * 1920 畫素密度:401ppi

資料夾 getByteCount getRowBytes getHeight getWidth
drawable 29773800b ≈ 28mb 12600b 2363 3150
mdip 29773800b ≈ 28mb 12600b 2363 3150
xhdip 7440300b ≈ 7mb 6300b 1181 1575
xxhdip 3309600b ≈ 3mb 4200b 788 1050

問題來了,雖然解析度是一樣的,但是記憶體佔用卻不同,關鍵因素不在解析度,那在什麼呢?

我們都知道我們的應用程式在不同的裝置上,Android系統會從不同的資原始檔夾下獲取圖片資源,而其選擇的本質不是螢幕的長寬比,是畫素密度。

所以這裡的關鍵在於畫素密度!從資原始檔中載入圖片的記憶體佔用與畫素密度有關!

OK,上面的結論都是通過資料推理出來的一些表象現狀。這裡先進行一個小總結:

  • 從資原始檔中建立Bitmap,圖片所在解析度越高的drawable資料夾,Bitmap佔用記憶體越小。(單從記憶體的角度可以這樣考量,但從實際應用過程中,所有素材都放到解析度最高的資料夾並不是合適的做法)
  • 從資原始檔中建立Bitmap,Bitmap佔用記憶體大小與圖片寬高極為有關,與圖片本身格式以及佔硬碟大小無關。
  • 從資原始檔中建立Bitmap,Bitmap佔用記憶體大小與手機畫素密度極為有關。

從網路或本地儲存建立Bitmap

通過資原始檔建立Bitmap,Android系統會為了適配不同螢幕,而對圖片進行一些調整,導致不同情況下記憶體佔用區別很大。那麼如果是從網路或本地儲存中建立的Bitmap也會因為裝置的畫素密度而有很大差異嗎?

我們來實驗一下,我從網路下載一張圖片,然後觀察記憶體情況。

我選了一張216932b ≈ 212kb 1600 *1280 的jpg圖片下載,並建立一個Bitmap

【努比亞Z9  Nubia NX508J 解析度1080 * 1920  畫素密度:424ppi 】
網路下載:
byte[] size : 216932 ≈ 212kb
bitmap size : 8192000 ≈ 7.8125mb
同一張圖片放到資原始檔中載入:
drawable getByteCount : 73728000 ≈ 70mb getRowBytes:19200 getHeight:3840 getWidth:4800
mdip getByteCount : 73728000 ≈ 70mb getRowBytes:19200 getHeight:3840 getWidth:4800
xhdip getByteCount : 18432000 ≈ 17.5mb getRowBytes:9600 getHeight:1920 getWidth:2400
xxhdip getByteCount : 8192000 ≈ 8mb getRowBytes:6400 getHeight:1280 getWidth:1600
複製程式碼

Bitmap大小還是要比圖片本身大出好多,而且似乎和從xxhdip資料夾下載入大小是一樣的,這一個示例不足以證明是否和手機解析度有關,我們換個手機再看看:

【魅族MX6 解析度1080 * 1920  畫素密度:401ppi 】
網路下載:
byte[] size : 216932 ≈ 212kb
bitmap size : 8192000 ≈ 7.8125mb
資原始檔載入:
drawable getByteCount : 73728000 ≈ 70mb getRowBytes:19200 getHeight:3840 getWidth:4800
mdip getByteCount : 73728000 ≈ 70mb getRowBytes:19200 getHeight:3840 getWidth:4800
xhdip getByteCount : 18432000 ≈ 17.5mb getRowBytes:9600 getHeight:1920 getWidth:2400
xxhdip getByteCount : 8192000 ≈ 8mb getRowBytes:6400 getHeight:1280 getWidth:1600
複製程式碼

好像看起來一樣,不過這兩臺裝置解析度一樣,畫素密度也差不太多,還是不足以說明問題,我們找個畫素密度更低一點的看一下:

【虛擬機器-5.4FWVGA 解析度480 * 584  畫素密度:mdpi 】
網路下載:
byte[] size : 216932 ≈ 212kb
bitmap size : 8192000 ≈ 7.8125mb
資原始檔載入:
drawable getByteCount : 8192000 ≈ 8mb getRowBytes:6400 getHeight:1280 getWidth:1600
mdip getByteCount : 8192000 ≈ 8mb getRowBytes:6400 getHeight:1280 getWidth:1600
xhdip getByteCount : 2048000 ≈ 2mb getRowBytes:3200 getHeight:640 getWidth:800
xxhdip getByteCount : 910364 ≈ 1mb getRowBytes:2132 getHeight:427 getWidth:533
複製程式碼

哦~ 這回有點說明性了,即使在畫素密度不同情況下,從網路下載的圖片建立的Bitmap大小都是固定的,從資原始檔中載入則因為畫素密度不同會產生很多變化。

從網路直接下載得到的byte陣列大小等同於原圖片大小,不經處理,直接用byte建立得到Bitmap寬高會以原圖片寬高建立,得到的Bitmap所佔記憶體遠大於原圖在硬碟上的大小。

做個小總結:

  • 從網路或本地儲存載入圖片建立Bitmap,記憶體佔用僅與圖片自身寬高有關,與裝置畫素密度無關。
  • 從網路或本地讀取的byte陣列大小等同於圖片大小,未經處理建立Bitmap記憶體佔用遠大於byte陣列大小。

Bitmap佔用記憶體的大小是如何計算的?

上一節的測試case,幫助我們大概的瞭解了Bitmap不同場景下建立的一些特性,看起來很有道理,但case覆蓋不夠充足的歸納法並不足以服人。

但他確實已經激起了我們很濃厚的興趣,所以下一步我們要通過原始碼來了解其中真正的原理。

Bitmap的原始碼解析的細節比較繁瑣,有興趣可以一層層追下去,這裡就直接放結果了。

還是因為有適配的問題,所以我們還要從兩個方面去說明:從網路或本地載入,和從資原始檔中載入。

從網路或本地儲存載入圖片

從網路或本地載入圖片不會受到裝置畫素密度影響,其記憶體佔用的大小可以用下面的公式描述:

**size = 實際顯示的寬 * 實際顯示的高 * Bitmap.Config **

說到Bitmap.Config,這個又要老生常談了,Android為圖片提供了4種解碼格式,不同的解碼格式佔用的記憶體大小不同,當然顯示效果也不同。

Format byte 說明
ARGB_8888 4b 此配置非常靈活,提供最好的質量。應儘可能使用。
RGB_565 2b 此配置可能會根據源的配置產生輕微的視覺偽影。例如,沒有抖動,結果可能會顯示綠色的色調。為了獲得更好的效果,應該應用抖動。當使用不需要高色彩保真度的不透明點陣圖時,此配置可能很有用。
ARGB_4444 2b 如果應用程式需要儲存半透明資訊,而且還需要節省記憶體,則此配置最為有用。(已廢棄)
ALPHA_8 1b 每個畫素儲存為單透明(alpha)通道。這對於有效地儲存掩碼是非常有用的。沒有儲存顏色資訊。通過這種配置,每個畫素需要1個位元組的儲存器。

預設是ARGB_8888,雖然一直都在說建議不同情況使用不同的解碼格式,但往往因為一些“不可抗拒”的因素,任何時候我們都在使用預設的解碼格式。後面第三節會對不同的解碼格式進行case測試。

從資原始檔中載入圖片

從資原始檔中載入圖片會受到drawble資料夾不同、裝置畫素密度影響,公式略微複雜一點:

scaledWidth = int(width * targetDensity / density + 0.5f) scaledHeight = int(height * targetDensity / density + 0.5f) size = scaledWidth * scaledHeight * Bitmap.Config

width和height是原素材大小; targetDensity 是裝置畫素密度; density 是素材所在drawable資料夾大小;

這裡要說明一下targetDensity 和 density 的值是怎麼來的。給一個表來說明:

名稱 density 畫素密度範圍:targetDensity
mdpi 160dp 120dp ~ 160dp
hdpi 240dp 160dp ~ 240dp
xhdpi 320dp 240dp ~ 320dp
xxhdpi 480dp 320dp ~ 480dp
xxxhdpi 640dp 480dp ~ 640dp

圖片放到了哪個資料夾,density的值就是多少,如果每個資料夾都放了,Android會根據裝置的畫素密度自動選擇對應的資料夾。

而裝置的畫素密度往往並不會只有160、240、320、480、640這幾個,我們可以看到第一節測試資料的幾個裝置畫素密度都是 【努比亞Z9 畫素密度:424ppi】 【榮耀暢玩4X 畫素密度:267ppi】 【樂視 le x620 畫素密度:401ppi】

這些畫素密度值是硬體的實際引數,但在系統執行時,硬體需要給Android系統提供一個準確的整數值,通常你可以粗略的將硬體實際畫素密度套入上表中,去畫素密度範文的最大值。但還是會有一些特殊的裝置不會取標準值,比如樂視le x620的畫素密度並不是標準的320dp或480dp,而是420dp。

所以裝置畫素密度在系統執行中的值我們可以通過下面的程式碼獲取:

DisplayMetrics metric = new DisplayMetrics();
int densityDpi = metric.densityDpi;  // 螢幕密度DPI(120 / 160 / 240)
複製程式碼

系統執行中取得的畫素密度如下 【努比亞Z9 畫素密度:480dp】 【榮耀暢玩4X 畫素密度:320dp】 【樂視 le x620 畫素密度:420dp】 如果素材在每個資料夾都放了圖片,那麼會通過上表的畫素密度範圍中尋找最佳的素材進行載入。

簡單總結一下:

  • Bitmap消耗記憶體大小主要取決於實際顯示的大小和每個畫素所佔的位元組數
  • 從資原始檔載入Bitmap時,還受裝置畫素密度與圖片所在資料夾代表的畫素密度之比的影響

減少Bitmap的記憶體佔用

吶,現在要進入本文的重頭戲了,你當然不會看到現在網上大同小異的什麼不實際載入先獲取尺寸啊,各種壓縮方法啊什麼的說教類條目。

從公式引出的優化策略

第二節我們介紹了Bitmap載入佔用記憶體的計算公式,通過公式我們可以很容易的得出一些減少Bitmap記憶體佔用的方法。

減小圖片實際顯示的長寬

通常來說我們要顯示的圖片會大於控制元件本身的大小,這是一種很明顯的浪費,對圖片做適當的壓縮,貼近控制元件本身的大小可以有效的減少記憶體佔用。主要用到的技術是 BitmapFactory.Options.inSampleSize屬性,這個屬性在Bitmap優化上已經被講過無數次了,我們就不多介紹了。關鍵點:按照控制元件本身大小載入圖片

使用更合適的解碼格式載入Bitmap

Android提供了四種Bitmap解碼格式,每種格式佔用記憶體的大小不一樣,在合適的場景下選擇合適解碼格式可以有效的減少記憶體佔用。這個雖然也是老生常談,但裡面會有一些不符合我們預設觀念的東西,下面會詳細介紹。

為應用提供滿足當前裝置畫素密度的素材

Bitmap記憶體計算公式中除長、寬、解碼格式三者的乘積以外,還要乘以targetDensity與density比的平方。這是什麼概念呢?

如果我們只提供了低畫素密度的素材,那麼在高畫素密度的裝置上將佔用更大的記憶體。 反之,如果我們只提供了高畫素密度的素材,那麼在低畫素密度的裝置上將佔用更小的記憶體。

誒???好像發現了什麼??是不是我們只要在xxhdpi甚至xxxxxxxhdpi中放素材,記憶體佔用將會變得非常非常小??這簡直新大陸啊。

如果問題真的這麼簡單,Android系統本身也不會提供那麼多畫素密度的資料夾了,口說無憑,我們寫個Demo看看效果。

裝置資訊:【虛擬機器-5.4FWVGA 解析度480 * 584 畫素密度:mdpi 】 我將同一張圖片分別copy在和xxhdpi資料夾下和mhdpi資料夾下,然後進行顯示: (上面xxhdpi 下面 mhdpi)

探索Bitmap使用姿勢

很明顯的可以看出來與裝置畫素密度相同的mhdpi資料夾下素材顯示正常,xxhdpi已經非常模糊了。

將素材放到高畫素密度檔案下,以求減少記憶體佔用是一個愚蠢的行為。

那問題來了,為了減少apk包大小(或者是懶),大多數開發者都只會在專案中存放一套素材放到某個畫素密度的資料夾下。 這樣將引起的問題是:若放到低畫素密度資料夾下,遇到高畫素密度裝置時將佔用多餘的記憶體;若放到高畫素密度資料夾下,遇到低畫素密度裝置,素材將會變的模糊。

很痛苦對不對?所以如果對包的大小要求並沒有那麼嚴格,設定多套畫素密度素材,讓targetDensity與density比為1,保證顯示效果與記憶體佔用保持在最恰當的平衡才是正道。 但如果就只能用一套呢?要想辦法走歪路了……

素材大部分的應用都是一些尺寸較小控制元件,小尺寸控制元件即使圖片較為模糊也不會特別明顯,所以這些小素材我們可以選擇性忽略,是不是有點不放心?我們再跑下Demo看看效果。

裝置資訊:【虛擬機器-5.4FWVGA 解析度480 * 584 畫素密度:mdpi 】 下面是長寬150dp的控制元件,上面是xxhdpi下的素材,下面是mhdpi的素材。

探索Bitmap使用姿勢

相同的裝置相同的素材,縮小了控制元件大小後模糊的是不是不那麼明顯了?

那麼對於大尺寸的控制元件呢?這裡我的建議是放到assets或res/raw、中,從assets中載入圖片等同於從網路或本地載入,從raw中通過InputStream載入也可以實現同樣的效果,不會受到畫素密度干擾。我們可以在assets中放一張相對尺寸較大的圖片,然後依照控制元件大小載入Bitmap,在保證以最優記憶體佔用的同時保證圖片不會模糊。

當然如果圖片放到了src/drawable資料夾下,通過程式碼BitmapFactory.decodeStream(getResources().openRawResource(R.drawable.example)); 效果等同於放到res/raw,但這時編譯器會提示這裡期望的是raw型別,一條紅色的波浪線總是讓人難以接受且這樣的圖片容易被直接使用而導致上面提到問題。

將上面的程式碼封裝到一個方法裡可以避免這條紅線,但還是不能避免會有其他的小夥伴直接當做資源使用這張圖片。大家自己選擇吧

下面我們就看看分別放到xxhdpi、assets下面的對比圖。 裝置資訊:【虛擬機器-5.4FWVGA 解析度480 * 584 畫素密度:mdpi 】 上面是xxhdpi下的素材,下面是assets的素材。

探索Bitmap使用姿勢

又見清晰的屁股。 當然記憶體佔用上上面模糊的圖會更小,畢竟targetDensity與density比為0.25,相當於除以4。 不過這是一種在記憶體佔用、展示效果、Apk包大小三者間較為平和的載入方式。

此類方法適用於:全屏型別的展示素材(Splash、引導圖等)、大尺寸的示例圖片等。

不同解碼格式的效果

上面我們遺留一個問題,如何使用更合適的解碼格式載入Bitmap?下面就好好聊聊。

一直以來,Bitmap優化老生常談的一個問題:使用不同的Bitmap解碼格式,以降低Bitmap記憶體佔用。但實際過程中我們都希望圖片以最優的狀況展示給使用者,所以用的最多的是ARGB_8888.

這裡我好奇的是他們之間究竟有多少差異,分別適應什麼場景,我做了一些測試。

奧~測試之前,再把四種解碼格式的介紹列一下吧:

  • ALPHA_8模式 ALPHA_8模式表示的圖片資訊中只包含Alpha透明度資訊,不包含任何顏色資訊,所以ALPHA_8模式只能用在一些特殊場景。

  • RGB_565模式 顯然RGB_565模式不能表示所有的RGB顏色,它能表示的顏色數只有32 × 64 × 32 = 65536種,遠遠小於24位真彩色所能表示的顏色數(256 × 257 × 256 = 16677216)。當圖片中某個畫素的顏色不在RGB_565模式表示的顏色範圍內時,會使用相近的顏色來表示。

  • ARGB_4444模式 ARGB_4444已被Android標記為@Deprecated,Android推薦使用ARGB_8888來代替ARGB_4444,原因是ARGB_4444表示出來的圖片質量太差。

  • ARGB_8888模式 ARGB_8888模式用8位來表示透明度,有256個透明度等級,用24位來表示R,G,B三個顏色通道,能夠完全表示32位真彩色,但同時這種模式佔用的記憶體空間也最大,是RGB_565模式的兩倍,是ALPHA_8模式的4倍。

介紹是這麼寫的,但真實使用情況是怎麼樣的?我們來測試一下:

我準備了一張圖片然後分別使用不同的解碼格式進行解碼,然後進行展示並輸出Bitmap的大小:

探索Bitmap使用姿勢

最好的解碼方式展示最優的效果,當然記憶體佔用也是最大的:1038000 ≈ 0.98mb。

探索Bitmap使用姿勢

果然是要放棄的解碼格式,大腿都花掉了,雖然記憶體佔用小了將近一半,但也不能再用你了。

探索Bitmap使用姿勢

誒?這個看起來好像很不錯的樣子,記憶體佔用僅有ARGB_8888的四分之一,但現實上幾乎看不出什麼不同,還是細膩的大腿。贊贊贊。(理論上size的大小不應該只有ARGB_4444的一半,應該是相等的,這個不能理解)

探索Bitmap使用姿勢

誒誒誒??ALPHA_8這麼強大嗎???同樣的幾乎無損圖,按照說明它應該是顯示最差的啊,不是說不包含顏色的嗎?。size的大小和RGB_565一樣又是怎麼回事???

好了,這裡簡單解釋一下,前面三張圖重複的展示ARGB_8888、ARGB_4444、RGB_565三種解碼格式在記憶體佔用上的不同。ARGB_4444展會效果太差已經是不用質疑的了,但RGB_565記憶體佔用僅有ARGB_8888的四分之一,顯示上卻沒有明顯的區別,難道說可以用RGB_565完全的代替ARGB_8888嗎?

不不不,當然不是這樣的,我們看下RGB_565的解釋:當圖片中某個畫素的顏色不在RGB_565模式表示的顏色範圍內時,會使用相近的顏色來表示。 之所以我們沒有感覺到特別大的區別,原因在與圖片本身色調過於單一(滿眼黃黃的大腿),RGB_565所能表示的顏色已經夠用或者代替的顏色色差足夠小。如果你需要展示色彩特別豐富的圖片還是會看出區別的。

然後我們再解釋一下ALPHA_8的問題。當你設定op1.inPreferredConfig = Bitmap.Config.ALPHA_8為某個屬性時,並不是說Bitmap解碼器必然使用這種解碼格式,僅是優先使用這種解碼格式。不包含顏色資訊的ALPHA_8怎麼能解碼出來黃黃的大腿呢?ALPHA_8不可以,RGB_565可以。所以解碼器使用了RGB_565,具體其內部的優先順序和使用策略還沒有具體研究。

Bitmap解碼器最終使用的解碼格式在很大程度上取決於圖片本身。

既然上面的圖片ALPHA_8沒法解碼,那黑白的二維碼圖片ALPHA_8可以解碼嗎?試一下:

探索Bitmap使用姿勢

挺好的……

探索Bitmap使用姿勢

簡單的二維碼圖片,ARGB_4444也挑不出啥毛病來……

探索Bitmap使用姿勢

這回size的大小合理了,和ARGB_4444一樣。

探索Bitmap使用姿勢

更小的size,顯示效果也無不同。贊!

簡單總結一下:

  • 設定圖片解碼格式並不一定會使用這種解碼格式,關鍵取決與圖片本身。
  • ALPHA_8適合類似二維碼一類的簡單黑白圖
  • RGB_565似乎可以滿足大多數要求不高的展示場景

Bitmap記憶體複用

通常來說我們在需要使用一張新的圖片時,都會為這個重新分配一塊記憶體,然後建立一個新的Bitmap物件,一個兩個不會存在太大的問題,但當有大量的零時Bitmap物件被頻繁建立時,將會引起頻繁的GC。所以Google在很早之前釋出的效能優化典範中推薦開發者使用inBitmap屬性來對Bitmap做記憶體複用,通過該屬性告知解碼器嘗試使用已經存在的記憶體區域,從而避免記憶體的重新分配。

當然inBitmap是有較大限制的,有著一定的場景依賴,所以通常被使用的頻率不是很高,具體限制我們後面會有簡單提到。這裡我們先通過Demo測試一下inBitmap的複用效果。

首先我用下面的方法測試未複用Bitmap記憶體的情況下,在一個ImageView依次顯示三張圖片時記憶體佔用情況:

private void unRecycle() {
        byte[] welcome1 = Tool.readFile(this, bitmapPaths[index++]);
        imageView.setImageBitmap(BitmapFactory.decodeByteArray(welcome1, 0, welcome1.length));
        if (index >= 3) {
            index = 0;
        }
    }
複製程式碼

探索Bitmap使用姿勢

通過記憶體監控可知,三張圖片依次載入時,記憶體成階梯狀上升,執行GC後,記憶體成斷崖式下跌。在實際使用過程中,很可能因為記憶體無法即時回收而導致OOM,或因為大量記憶體需要回收而引起卡頓。

然後我們在用下面的方法測試複用Bitmap記憶體的情況下,在一個ImageView依次顯示三張圖片時記憶體佔用情況:

byte[] welcome1 = Tool.readFile(this, bitmapPaths[index++]);
        if (bitmap == null) {
            BitmapFactory.Options option1 = new BitmapFactory.Options();
            option1.inMutable = true;
            bitmap = BitmapFactory.decodeByteArray(welcome1, 0, welcome1.length, option1);
        } else {
            BitmapFactory.Options option1 = new BitmapFactory.Options();
            option1.inBitmap = bitmap;
            option1.inMutable = true;
            bitmap = BitmapFactory.decodeByteArray(welcome1, 0, welcome1.length, option1);
        }
        imageView.setImageBitmap(bitmap);
        if (index >= 3) {
            index = 0;
        }
複製程式碼

探索Bitmap使用姿勢

通過記憶體監控可知,僅在第一張圖片載入時,系統分配了一塊記憶體給Bitmap,後面兩張圖沒用再重新進行記憶體分配。避免了大塊記憶體的重新分配和GC回收。

Bitmap複用場景實操 - 拍照後圖片載入與顯示的優化對比

這裡介紹一個最簡單的適合使用inBitmap屬性的場景:拍照!

裝置:【努比亞Z9 畫素密度:480dp】 Demo的介面很簡單,一個ImageView用來展示圖片,初次進入預設展示示例圖片,點選拍照按鈕呼叫系統相機進入拍照介面,成功拍照後將照片展示到ImageView上,可多次拍照,ImageView僅展示最新照片。

這裡我們考察的點是,進入Activity後進行多次拍照,然後觀察記憶體變化。主要關注示例圖片的記憶體佔用與拍照後的記憶體佔用。 下面是Demo的介面展示,優化前後介面展示保持不變。考慮篇幅問題,這裡不再貼程式碼,僅以文字描述,詳細程式碼可以檢視Demo程式碼

探索Bitmap使用姿勢
探索Bitmap使用姿勢

老的拍照操作

先說我們通常最普通的做法,僅做了簡單的拍照後圖片壓縮顯示。

1.示例圖片放在src/xhdpi資料夾下,通過photoImg.setImageResource(R.drawable.example);設定。 2.拍照後將圖片儲存為本地檔案,在onActivityResult回撥方法中。以預設長寬1024x768為標準進行壓縮,通過BitmapFactory.decodeStream建立Bitmap。(預設長寬通常為UED給出的設計稿尺寸)。

然後我們看一下Demo跑起來以後的的記憶體監控圖:

探索Bitmap使用姿勢

解釋一下:

1.第一個記憶體上升主要是因為頁面進入後,載入示例圖造成的,大約佔用記憶體8MB左右。src/xhdpi與本次測試的裝置畫素密度相同,如果xxhdpi畫素密度的裝置,記憶體佔用更大;如果遇到畫素密度更小的裝置,則示例圖可能會變得模糊。

2.圓圈表示拍照後記憶體的上升,每一次拍照都將建立一個新的Bitmap,大約佔用記憶體9MB左右。

3.觀察第三個圓圈,系統發生一次GC,系統回收一個Bitmap,但顯而易見並沒有回收乾淨。

4.觀察第五個圓圈,出現一次記憶體尖峰,再次發生GC,但同樣沒有回收乾淨,記憶體整體呈持續持續上升趨勢。

總結:記憶體並沒有洩露,五次拍照均產生的為臨時變數,但大記憶體的佔用導致GC回收非常不乾淨。在實際使用中,未被即時回收的記憶體將可能導致OOM。 即使不會引起OOM,大塊記憶體分配引起的GC同樣極易引起介面卡頓,GC執行在主執行緒。

新的拍照操作

針對上面老的拍照操作,新的拍照操作主要做了如下優化:

1.不在直接通過photoImg.setImageResource(R.drawable.example);設定圖片,改為BitmapFactory.decodeStream(getResources().openRawResource(srcId), null, options);。 提高Bitmap載入速度的同時(decodeStream直接呼叫JNI方法),跳過Android系統針對裝置畫素密度對圖片做的優化,直接對圖片本身進行操作。

2.以Config.RGB_565解碼格式進行解碼,縮小Bitmap一半記憶體佔用。

3.以ImageView實際大小為標準對示例圖與照片做壓縮。

4.對多次拍照產生的Bitmap做複用,最終實際僅佔用一個Bitmap記憶體。

我們看下優化後的記憶體監控圖:

探索Bitmap使用姿勢

記憶體曲線過於平緩……看的不太清晰……

1.示例圖因為經過壓縮,且跳過畫素密度的適配,最終僅佔用約0.3MB記憶體。

2.因為示例圖與壓縮後的照片尺寸不一樣,不能進行Bitmap複用,所以第一次拍照後又建立了一個Bitmap,大約佔用記憶體1.9MB,之後多次拍照複用第二個Bitmap,沒有進行記憶體分配,所以也沒有GC發生。

總結

優化結果很明顯啦~ 主要的優化點:

  • 跳過畫素密度適配直接通過 decodeStream對圖片進行載入。
  • 按照控制元件大小載入圖片。
  • 對Bitmap進行復用。

但裡面會有一些坑點:

  • 在Activity沒有將介面完全展示時,無法獲取控制元件寬高。此類場景如何獲取請自行搜尋。我在這個Demo中使用的方式是imageView.post(new Runnable() { void run()}
  • Bitmap複用有較大限制,4.4之前只能複用大小一樣的,4.4之後只能複用大小等於或更小的。
  • Bitmap複用有較大限制,只能複用相同解碼格式的,可能會有某些圖片沒有辦法用Config.RGB_565解碼,此時將不能複用。Demo中我用try catch捕獲複用失敗的異常,然後降級建立新的Bitmap.

Bitmap載入速度探索

上面我們主要分析的是Bitmap佔用記憶體方面的一些場景,在實際使用過程中,除了記憶體以外,Bitmap的快速載入也是非常值得我們關注的問題。

這裡我們僅討論最常用的三種Bitmap載入方法。

//從資原始檔中載入
BitmapFactory.decodeResource();
//從流中載入
BitmapFactory.decodeStream();
//從byte[]中載入
BitmapFactory.decodeByteArray();

複製程式碼

從資原始檔中載入與流中載入對比

我將同一張1080x1920 655.45k的圖片放在資原始檔中和Assets目錄下用分別用BitmapFactory.decodeResource();BitmapFactory.decodeStream();兩種方法載入,然後測算其載入速度。

同時因為每一次Bitmap的載入耗時都不一樣,所以我會列出多次執行的資料。

【time1】
資原始檔載入Bitmap 耗時:160ms
decodeStream載入本地圖片 耗時:57ms
【time2】
資原始檔載入Bitmap 耗時:157ms
decodeStream載入本地圖片 耗時:47ms
【time3】
資原始檔載入Bitmap 耗時:162ms
decodeStream載入本地圖片 耗時:56ms
【time4】
資原始檔載入Bitmap 耗時:124ms
decodeStream載入本地圖片 耗時:43ms
【time5】
資原始檔載入Bitmap 耗時:123ms
07decodeStream載入本地圖片 耗時:43ms

複製程式碼

資料已經很明顯的說明問題了。因為BitmapFactory.decodeResource()方法會在圖片載入完成後做一些適配工作,而decodeStream直接讀取了位元組碼,速度更快。

但因為缺少了適配處理,所以載入的圖片是圖片原本的大小,在使用中需要對其進行處理。但在載入一些明顯圖片尺寸大於控制元件尺寸的場景,decodeStream顯然更為合適。

I/O耗時和圖片解碼耗時

從接觸程式設計開始,我們都一直在接受I/O是很耗時的觀點。那麼是否可以假想,在從本地檔案中載入圖片的場景,從本地讀取資料到記憶體的過程消耗了很重要的一部分時間,無論這段時間多與少,都是一個優化點。

OK,那麼接下來我們只要通過測算其具體時間就可以驗證假設了。

還是那張圖片,我們先從本地讀取其為byte[],然後在從byte[]通過BitmapFactory.decodeByteArray();轉為Bitmap。

【time1】
讀取本地圖片到byte[] 耗時:1ms
byte[] to Bitmap 耗時:43ms

【time2】
讀取本地圖片到byte[] 耗時:2ms
byte[] to Bitmap 耗時:40ms

【time3】
讀取本地圖片到byte[] 耗時:1ms
byte[] to Bitmap 耗時:43ms

【time4】
讀取本地圖片到byte[] 耗時:1ms
byte[] to Bitmap 耗時:39ms

【time5】
讀取本地圖片到byte[] 耗時:1
byte[] to Bitmap 耗時:39

複製程式碼

結果還是較為失望的,從本地讀取到記憶體中的時間消耗僅為1ms,主要耗時依然在解碼上。

BitmapFactory.decodeByteArray()與BitmapFactory.decodeStream()對比

BitmapFactory.decodeStream()直接通過流讀取圖片位元組碼,然後進行圖片解碼操作,對比BitmapFactory.decodeByteArray(),直觀上要多出一步本地到記憶體的過程,雖然從本地讀取資料到記憶體耗時僅為1ms,但我還是想知道這兩者的直接對比是怎麼樣的。

【time1】
decodeStream載入本地圖片 耗時:42ms
讀取本地圖片到byte[] 再到Bitmap 耗時:40ms
讀取本地圖片到byte[] 耗時:1ms

【time2】
decodeStream載入本地圖片 耗時:55ms
讀取本地圖片到byte[] 再到Bitmap 耗時:44ms
讀取本地圖片到byte[] 耗時:1ms

【time3】
decodeStream載入本地圖片 耗時:43ms
讀取本地圖片到byte[] 再到Bitmap 耗時:40ms
讀取本地圖片到byte[] 耗時:1ms

【time4】
decodeStream載入本地圖片 耗時:85ms
讀取本地圖片到byte[] 再到Bitmap 耗時:60ms
讀取本地圖片到byte[] 耗時:2ms

【time5】
decodeStream載入本地圖片 耗時:73ms
讀取本地圖片到byte[] 再到Bitmap 耗時:57ms
讀取本地圖片到byte[] 耗時:2ms

複製程式碼

時間相差從3ms到20ms都有,雖然不大,但還是有一丟丟改善。 如果對圖片載入速度非常苛刻的話,可以考慮提前將圖片快取到記憶體中,然後通過BitmapFactory.decodeByteArray()方式進行載入。但這需要消耗額外的記憶體空間,是典型的空間換時間。但考慮其20ms左右優化效果,考慮這種方式還需謹慎。

新的快取代替品?

跟上一節。雖然從載入速度考慮,BitmapFactory.decodeByteArray()代替BitmapFactory.decodeStream()的收益不大,但換一種姿勢,有沒有可能讓收益翻番?

一直以來圖片快取大多都是指將圖片儲存到本地或網路,載入後得到Bitmap儲存的記憶體中,其優化通常是指將用過的Bitmap用快取容器儲存起來避免重複從硬碟或網路載入

這樣的方式我們關注的更多是減小Bitmap從本地或網路建立的時間,但這樣的快取方式將會佔用大量的記憶體空間,一般情況我們都會選擇將六分之一的記憶體空間劃分給圖片快取,以空間換時間,其代價還是很大的。

但看過前面的一大波測試資料,我們可以很明顯的感受到載入後的Bitmap佔用記憶體大小遠大於圖片原本大小。究其原因,載入Bitmap會對檔案本身做解碼以用於顯示,類似於解壓操作,而圖片本身是一種壓縮操作。

同時經過前面的測試,也許你發現了一個細節,從網路或本地讀取後得到的byte[]大小是圖片原本大小,那麼是否可以犧牲一些byte[]到Bitmap的轉換時間,僅快取byte[]在記憶體中?

以時間換空間策略,是否可行的關鍵在於從byte[] - Bitmap的解碼時間與解釋的記憶體開銷的權衡,我們通過資料來驗證。 我準備了一張png圖片,分別匯出了不同的解析度,並且copy一份對png檔案進行壓縮做對比測試,然後執行程式碼輸出其各方面資料。 此次測試我們主要考量兩個標準:byte[]代替Bitmap節省的空間和byte[]轉Bitmap耗費的時間。

圖片檔案解析度 是否壓縮 byte.length bitmap.size use time
50*80 false 10501b≈10kb 17600b≈17kb 1ms
50*80 true 3523b≈3kb 17600b≈17kb 1ms
200*355 false 140360b≈137kb 284000b≈277kb 11ms
200*355 true 27690b≈27kb 284000b≈277kb 3ms
500*888 false 870554b≈850kb 1776000b≈1734kb 36ms
500*888 true 171101b≈167kb 1776000b≈1734kb 10ms
1080*1920 false 984712b≈961kb 8294400b≈8100kb 65ms
1080*1920 true 631610b≈616kb 8294400b≈8100kb 34ms

我們對上面資料做一個簡單的總結:

  • png檔案壓縮不會減少生成的Bitmap大小,但可以明顯減少byte大小
  • 解析度越高,byte[]替換Bitmap節省記憶體的越明顯
  • 解析度越高,png解碼為Bitmap的耗時越久
  • 壓縮後可以明顯減少解碼為Bitmap的耗時(byte[]越小,解碼越快)

同時我們也知道byte[]到Bitmap佔用的時間並不是一成不變的,也就是說會在不同的裝置上有不同的體現,以我目前測試的努比亞Z9來說,不同資料的差異在10~15ms之間徘徊,為了保證測試資料的說服力,我將1080*1920解析度圖片壓縮前後的use time的多次資料進行展示:

壓縮前 壓縮後
65 34
82 40
81 40
98 41
98 44
87 41

另外說道byte[]越小,解碼越快的問題,我們不難聯想到webp,webp比png,jpg更小,讀取後的byte[]也更小,是否解壓的更快呢?測試一下:

ico_1080_1920.png  bytes:984712b ≈ 961kb  bitmap:8294400b ≈ 8100kb  use:91ms
ico_1080_1920_compress.png  bytes:631610b ≈ 616kb  bitmap:8294400b ≈ 8100kb  use:44ms
un_compress.webp  bytes:367018b ≈ 358kb  bitmap:8294400b ≈ 8100kb  use:152ms
compress.webp  bytes:361200b ≈ 352kb  bitmap:8294400b ≈ 8100kb  use:141ms

複製程式碼

結果顯而易見,下面兩張圖是上面兩張圖的webp版,雖然大幅度減少byte的大小,但解碼時間也大幅度增加了。究其原因,webp的高強度壓縮增加了解碼複雜度,webp在其官網也早已對這種情況進行了說明。

而byte[] - Bitmap所消耗的時間對系統流程度的影響又是如何呢?

我寫了一個demo,介面如下:

探索Bitmap使用姿勢

經過實際測試,快取Bitmap到記憶體中的策略中,第一次載入圖片時,快速滑動列表,會有明顯示卡頓;但在圖片全部快取後,頁面無卡頓。

而快取byte[]到記憶體中,在顯示時才解碼為Bitmap,第一次載入圖片時,快速滑動列表,會有明細卡頓;byte[]全部快取後,普通滑動速度幾乎無卡頓;快速滑動有卡頓感。

所以從使用者體驗的角度上來說,快取byte[]可能並不適合在圖片列表這樣可以快速滑動的場景代替Bitmap快取。 而在ViewPager這樣的場景,因為頁面轉換不可能像列表一樣快速,byte[] - Bitmap所消耗的時間幾乎無感,似乎適合。 但在頁面展示如此遲鈍的場景,似乎直接從檔案中載入Bitmap才是最優的選擇。

關於快取替代品byte[] - Bitmap,仁者見仁智者見智吧。

(要提一點,為了避免byte[] - Bitmap的過程中產生大量的臨時Bitmap物件,快取byte[]的策略中應用了inBitmap屬性,而這一屬性的使用幾乎不會影響到Bitmap的載入速度)

相關文章