本篇文章已授權微信公衆号 hongyangAndroid (鴻洋)獨家釋出
最近封裝了個高斯模糊元件,正好将圖檔相關的理論基礎也梳理了下,是以,這次就來講講,在 Android 中,怎麼計算一張圖檔在記憶體中占據的大小,如果要優化,可以從哪些方向着手。
提問
閱讀本篇之前,先來想一些問題:
Q1:一張 png 格式的圖檔,圖檔檔案大小為 55.8KB,那麼它加載進記憶體時所占的大小是多少?
Q2:為什麼有時候,同一個 app,app 内的同個界面,界面上同張圖檔,但在不同裝置上所耗記憶體卻不一樣?
Q3:圖檔占用的記憶體大小公式:圖檔分辨率 * 每個像素點大小,這種說法正确嗎,或者嚴謹嗎?
Q4:優化圖檔的記憶體大小有哪些方向可以着手?
正文
在 Android 開發中,經常需要對圖檔進行優化,因為圖檔很容易耗盡記憶體。那麼,就需要知道,一張圖檔的大小是如何計算的,當加載進記憶體中時,占用的空間又是多少?
先來看張圖檔:
這是一張普通的 png 圖檔,來看看它的具體資訊:
圖檔的分辨率是 1080*452,而我們在電腦上看到的這張 png 圖檔大小僅有 55.8KB,那麼問題來了:
我們看到的一張大小為 55.8KB 的 png 圖檔,它在記憶體中占有的大小也是 55.8KB 嗎?
理清這點蠻重要的,因為碰到過有人說,我一張圖檔就幾 KB,雖然界面上顯示了上百張,但為什麼記憶體占用卻這麼高?
是以,我們需要搞清楚一個概念:我們在電腦上看到的 png 格式或者 jpg 格式的圖檔,png(jpg) 隻是這張圖檔的容器,它們是經過相對應的壓縮算法将原圖每個像素點資訊轉換用另一種資料格式表示,以此達到壓縮目的,減少圖檔檔案大小。
而當我們通過代碼,将這張圖檔加載進記憶體時,會先解析圖檔檔案本身的資料格式,然後還原為位圖,也就是 Bitmap 對象,Bitmap 的大小取決于像素點的資料格式以及分辨率兩者了。
是以,一張 png 或者 jpg 格式的圖檔大小,跟這張圖檔加載進記憶體所占用的大小完全是兩回事。你不能說,我 jpg 圖檔也就 10KB,那它就隻占用 10KB 的記憶體空間,這是不對的。
那麼,一張圖檔占用的記憶體空間大小究竟該如何計算?
末尾附上的一篇大神文章裡講得特别詳細,感興趣可以看一看。這裡不打算講這麼專業,還是按照我粗坯的了解來給大夥講講。
圖檔記憶體大小
網上很多文章都會介紹說,計算一張圖檔占用的記憶體大小公式:分辨率 * 每個像素點的大小。
這句話,說對也對,說不對也不對,我隻是覺得,不結合場景來說的話,直接就這樣表達有點不嚴謹。
在 Android 原生的 Bitmap 操作中,某些場景下,圖檔被加載進記憶體時的分辨率會經過一層轉換,是以,雖然最終圖檔大小的計算公式仍舊是分辨率*像素點大小,但此時的分辨率已不是圖檔本身的分辨率了。
我們來做個實驗,分别從如下的幾種考慮點互相組合的場景中,加載同一張圖檔,看一下占用的記憶體空間大小分别是多少:
- 圖檔的不同來源:磁盤、res 資源檔案
- 圖檔檔案的不同格式:png、jpg
- 圖檔顯示的不同大小的控件
- 不同的 Android 系統裝置
測試代碼模闆如下:
private void loadResImage(ImageView imageView) {
BitmapFactory.Options options = new BitmapFactory.Options();
Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.weixin, options);
//Bitmap bitmap = BitmapFactory.decodeFile("mnt/sdcard/weixin.png", options);
imageView.setImageBitmap(bitmap);
Log.i("!!!!!!", "bitmap:ByteCount = " + bitmap.getByteCount() + ":::bitmap:AllocationByteCount = " + bitmap.getAllocationByteCount());
Log.i("!!!!!!", "width:" + bitmap.getWidth() + ":::height:" + bitmap.getHeight());
Log.i("!!!!!!", "inDensity:" + options.inDensity + ":::inTargetDensity:" + options.inTargetDensity);
Log.i("!!!!!!", "imageview.width:" + imageView.getWidth() + ":::imageview.height:" + imageView.getHeight());
}
ps:這裡提一下,使用 Bitmap 的
getByteCount()
方法可以擷取目前圖檔占用的記憶體大小,當然在 api 19 之後有另外一個方法,而且當 bitmap 是複用時擷取的大小含義也有些變化,這些特殊場景就不細說,感興趣自行查閱。反正這裡知道,大部分場景可以通過
getByteCount()
列印圖檔占用的記憶體大小來驗證我們的實驗即可。
圖檔就是上圖那張:分辨率為 1080*452 的 png 格式的圖檔,圖檔檔案本身大小 56KB
序号 | 前提 | Bitmap記憶體大小 |
---|---|---|
1 | 圖檔位于res/drawable,裝置dpi=240,裝置1dp=1.5px,控件寬高=50dp | 4393440B(4.19MB) |
2 | 圖檔位于res/drawable,裝置dpi=240,裝置1dp=1.5px,控件寬高=500dp | |
3 | 圖檔位于res/drawable-hdpi,裝置dpi=240,裝置1dp=1.5px | 1952640B(1.86MB) |
4 | 圖檔位于res/drawable-xhdpi,裝置dpi=240,裝置1dp=1.5px | 1098360B(1.05MB) |
5 | 圖檔位于res/drawable-xhdpi,裝置dpi=160,裝置1dp=1px | 488160B(476.7KB) |
6 | 圖檔位于res/drawable-hdpi,裝置dpi=160,裝置1dp=1px | 866880(846.5KB) |
7 | 圖檔位于res/drawable,裝置dpi=160,裝置1dp=1px | |
8 | 圖檔位于磁盤中,裝置dpi=160,裝置1dp=1px | |
9 | 圖檔位于磁盤中,裝置dpi=240,裝置1dp=1.5px |
看見沒有,明明都是同一張圖檔,但在不同場景下,所占用的記憶體大小卻是有可能不一樣的,具體稍後分析。以上場景中列出了圖檔的不同來源,不同 Android 裝置,顯示控件的不同大小這幾種考慮點下的場景。我們繼續來看一種場景:同一張圖檔,儲存成不同格式的檔案(不是重命名,可借助ps);
圖檔:分辨率 1080*452 的 jpg 格式的圖檔,圖檔檔案本身大小 85.2KB
ps:還是同樣上面那張圖檔,隻是通過 PhotoShop 存儲為 jpg 格式
比較對象 | |||
---|---|---|---|
10 | 圖檔位于res/drawable,裝置dpi=240,裝置1dp=1.5px | 序号1 | |
11 | 序号3 | ||
12 | 序号4 | ||
13 | 序号9 |
這裡列出的幾種場景,每個場景比較的實驗對象序号也寫在每行最後了,大夥可以自己比對确認一下,是不是發現,資料都是一樣的,是以這裡可以先得到一點結論:
圖檔的不同格式:png 或者 jpg 對于圖檔所占用的記憶體大小其實并沒有影響
好了,我們開始來分析這些實驗資料:
首先,如果按照圖檔大小的計算公式:分辨率 * 像素點大小
那麼,這張圖檔的大小按照這個公式應該是:1080 * 452 * 4B = 1952640B ≈ 1.86MB
ps: 這裡像素點大小以 4B 來計算是因為,當沒有特别指定時,系統預設為 ARGB_8888 作為像素點的資料格式,其他的格式如下:
- ALPHA_8 -- (1B)
- RGB_565 -- (2B)
- ARGB_4444 -- (2B)
- ARGB_8888 -- (4B)
- RGBA_F16 -- (8B)
上述實驗中,按理就應該都是這個大小,那,為什麼還會出現一些其他大小的資料呢?是以,具體我們就一條條來分析下:
分析點1
先看序号 1,2 的實驗,這兩者的差別僅在于圖檔顯示的空間的大小上面。做這個測試是因為,有些人會認為,圖檔占據記憶體空間大小與圖檔在界面上顯示的大小會有關系,顯示控件越大占用記憶體越多。顯然,這種了解是錯誤的。
想想,圖檔肯定是先加載進記憶體後,才繪制到控件上,那麼當圖檔要申請記憶體空間時,它此時還不知道要顯示的控件大小的,怎麼可能控件的大小會影響到圖檔占用的記憶體空間呢,除非提前告知,手動參與圖檔加載過程。
分析點2
再來看看序号 2,3,4 的實驗,這三個的差別,僅僅在于圖檔在 res 内的不同資源目錄中。當圖檔放在 res 内的不同目錄中時,為什麼最終圖檔加載進記憶體所占據的大小會不一樣呢?
如果你們去看下
Bitmap.decodeResource()
源碼,你們會發現,系統在加載 res 目錄下的資源圖檔時,會根據圖檔存放的不同目錄做一次分辨率的轉換,而轉換的規則是:
新圖的高度 = 原圖高度 * (裝置的 dpi / 目錄對應的 dpi )
新圖的寬度 = 原圖寬度 * (裝置的 dpi / 目錄對應的 dpi )
目錄名稱與 dpi 的對應關系如下,drawable 沒帶字尾對應 160 dpi:
是以,我們來看下序号 2 的實驗,按照上述理論的話,我們來計算看看這張圖檔的記憶體大小:
轉換後的分辨率:1080 * (240/160) * 452 * (240/160) = 1620 * 678
顯然,此時的分辨率已不是原圖的分辨率了,經過一層轉換,最後計算圖檔大小:
1620 * 678 * 4B = 4393440B ≈ 4.19MB
這下知道序号 2 的實驗結果怎麼來的了吧,同樣的道理,序号 3 資源目的是 hdpi 對應的是 240,而裝置的 dpi 剛好也是 240,是以轉換後的分辨率還是原圖本身,結果也才會是 1.86MB。
小結一下:
位于 res 内的不同資源目錄中的圖檔,當加載進記憶體時,會先經過一次分辨率的轉換,然後再計算大小,轉換的影響因素是裝置的 dpi 和不同的資源目錄。
分析點3
基于分析點 2 的理論,看下序号 5,6,7 的實驗,這三個實驗其實是用于跟序号 2,3,4 的實驗進行對比的,也就是這 6 個實驗我們可以得出的結論是:
- 同一圖檔,在同一台裝置中,如果圖檔放在 res 内的不同資源目錄下,那麼圖檔占用的記憶體空間是會不一樣的
- 同一圖檔,放在 res 内相同的資源目錄下,但在不同 dpi 的裝置中,圖檔占用的記憶體空間也是會不一樣的
是以,有可能出現這種情況,同一個 app,但跑在不同 dpi 裝置上,同樣的界面,但所耗的記憶體有可能是不一樣的。
為什麼這裡還要說是有可能不一樣呢?按照上面的理論,同圖檔,同目錄,但不同 dpi 裝置,那顯然分辨率轉換就不一樣,所耗記憶體應該是肯定不一樣的啊,為什麼還要用有可能這種說辭?
emmm,繼續看下面的分析點吧。
分析點4
序号 8,9 的實驗,其實是想驗證是不是隻有當圖檔的來源是 res 内才會存在分辨率的轉換,結果也确實證明了,當圖檔在磁盤中,SD 卡也好,assert 目錄也好,網絡也好(網絡上的圖檔其實最終也是下載下傳到磁盤),隻要不是在 res 目錄内,那麼圖檔占據記憶體大小的計算公式,就是按原圖的分辨率 * 像素點大小來。
其實,有空去看看 BitmapFactory 的源碼,确實也隻有
decodeResource()
方法内部會根據 dpi 進行分辨率的轉換,其他
decodeXXX()
就沒有了。
那麼,為什麼在上個小節中,要特别說明,即使同一個 app,但跑在不同 dpi 裝置上,同樣的界面,但所耗的記憶體有可能是不一樣的。這裡為什麼要特别用有可能這個詞呢?
是吧,大夥想想。明明按照我們梳理後的理論,圖檔的記憶體大小計算公式是:分辨率*像素點大小,然後如果圖檔的來源是在 res 的話,就需要注意,圖檔是放于哪個資源目錄下的,以及裝置本身的 dpi 值,因為系統取 res 内的資源圖檔會根據這兩點做一次分辨率轉換,這樣的話,圖檔的記憶體大小不是肯定就不一樣了嗎?
emmm,這就取決于你本人的因素了,如果你開發的 app,圖檔的相關操作都是通過 BitmapFactory 來操作,那麼上述問題就可以換成肯定的表述。但現在,哪還有人自己寫原生,Github 上那麼多強大的圖檔開源庫,而不同的圖檔開源庫,内部對于圖檔的加載處理,緩存政策,複用政策都是不一樣的。
是以,如果使用了某個圖檔開源庫,那麼對于加載一張圖檔到記憶體中占據了多大的空間,就需要你深入這個圖檔開源庫中去分析它的處理了。
因為基本所有的圖檔開源庫,都會對圖檔操作進行優化,那麼下面就繼續來講講圖檔的優化處理吧。
圖檔優化
有了上述的理論基礎,現在再來想想如果圖檔占用記憶體空間太多,要進行優化,可以着手的一些方向,也比較有眉目了吧。
圖檔占據記憶體大小的公式也就是:分辨率*像素點大小,隻是在某些場景下,比如圖檔的來源是 res 的話,可能最終圖檔的分辨率并不是原圖的分辨率而已,但歸根結底,對于計算機來說,确實是按照這個公式計算。
是以,如果單從圖檔本身考慮優化的話,也就隻有兩個方向:
- 降低分辨率
- 減少每個像素點大小
除了從圖檔本身考慮外,其他方面可以像記憶體預警時,手動清理,圖檔弱引用等等之類的操作。
減少像素點大小
第二個方向很好操作,畢竟系統預設是以 ARGB_8888 格式進行處理,那麼每個像素點就要占據 4B 的大小,改變這個格式自然就能降低圖檔占據記憶體的大小。
常見的是,将 ARGB_8888 換成 RGB_565 格式,但後者不支援透明度,是以此方案并不通用,取決于你 app 中圖檔的透明度需求,當然也可以緩存 ARGB_4444,但會降低品質。
由于基本是使用圖檔開源庫了,以下列舉一些圖檔開源庫的處理方式:
//fresco,預設使用ARGB_8888
Fresco.initialize(context, ImagePipelineConfig.newBuilder(context).setBitmapsConfig(Bitmap.Config.RGB_565).build());
//Glide,不同版本,像素點格式不一樣
public class GlideConfiguration implements GlideModule {
@Override
public void applyOptions(Context context, GlideBuilder builder) {
builder.setDecodeFormat(DecodeFormat.PREFER_ARGB_8888);
}
@Override
public void registerComponents(Context context, Glide glide) {
}
}
//在AndroidManifest.xml中将GlideModule定義為meta-data
<meta-data android:name="com.inthecheesefactory.lab.glidepicasso.GlideConfiguration" android:value="GlideModule"/>
//Picasso,預設 ARGB_8888
Picasso.with(imageView.getContext()).load(url).config(Bitmap.Config.RGB_565).into(imageView);
以上代碼摘抄自網絡,正确性應該可信,沒驗證過,感興趣自行去相關源碼确認一下。
如果能夠讓系統在加載圖檔時,不以原圖分辨率為準,而是降低一定的比例,那麼,自然也就能夠達到減少圖檔記憶體的效果。
同樣的,系統提供了相關的 API:
BitmapFactory.Options.inSampleSize
設定 inSampleSize 之後,Bitmap 的寬、高都會縮小 inSampleSize 倍。例如:一張寬高為 2048x1536 的圖檔,設定 inSampleSize 為 4 之後,實際加載到記憶體中的圖檔寬高是 512x384。占有的記憶體就是 0.75M而不是 12M,足足節省了 15 倍
上面這段話摘抄自末尾給的連結那篇文章中,網上也有很多關于如何操作的講解文章,這裡就不細說了。我還沒去看那些開源圖檔庫的内部處理,但我猜想,它們對于圖檔的優化處理,應該也都是通過這個 API 來操作。
其實,不管哪個圖檔開源庫,在加載圖檔時,内部肯定就有對圖檔進行了優化處理,即使我們沒手動說明要進行圖檔壓縮處理。這也就是我在上面講的,為什麼當你使用了開源圖檔庫後,就不能再按照圖檔記憶體大小一節中所講的理論來計算圖檔占據記憶體大小的原因。
我們可以來做個實驗,先看下 fresco 的實驗:
開源庫 | ||
---|---|---|
fresco | ||
如果使用 fresco,那麼不管圖檔來源是哪裡,分辨率都是已原圖的分辨率進行計算的了,從得到的資料也能夠證明,fresco 對于像素點的大小預設以 ARGB_8888 格式處理。
我猜想,fresco 内部對于加載 res 的圖檔時,應該先以它自己的方式擷取圖檔檔案對象,最後有可能是通過 BitmapFactory 的
decodeFile()
或者
decodeByteArray()
等等之類的方式加載圖檔,反正就是不通過
decodeResource()
來加載圖檔,這樣才能說明,為什麼不管放于哪個 res 目錄内,圖檔的大小都是以原圖分辨率來進行計算。有時間可以去看看源碼驗證一下。
再來看看 Glide 的實驗:
Glide | 圖檔位于res/drawable,裝置dpi=240,裝置1dp=1.5px,顯示到寬高500dp的控件 | 94200B(91.99KB) |
圖檔位于res/drawable-hdpi,裝置dpi=240,裝置1dp=1.5px,顯示到寬高500dp的控件 | ||
圖檔位于res/drawable-hdpi,裝置dpi=240,裝置1dp=1.5px,不顯示到控件,隻擷取 Bitmap 對象 | ||
圖檔位于磁盤中,裝置dpi=240,裝置1dp=1.5px,不顯示到控件,隻擷取 Bitmap 對象 | ||
圖檔位于磁盤中,裝置dpi=240,裝置1dp=1.5px,顯示到全屏控件(1920*984) | 7557120B(7.21MB) |
可以看到,Glide 的處理與 fresco 又有很大的不同:
如果隻擷取 bitmap 對象,那麼圖檔占據的記憶體大小就是按原圖的分辨率進行計算。但如果有通過
into(imageView)
将圖檔加載到某個控件上,那麼分辨率會按照控件的大小進行壓縮。
比如第一個,顯示的控件寬高均為 500dp = 750px,而原圖分辨率 1080*452,最後轉換後的分辨率為:750 * 314,是以圖檔記憶體大小:750 * 314 * 4B = 94200B;
比如最後一個,顯示的控件寬高為 1920*984,原圖分辨率轉換後為:1920 * 984,是以圖檔記憶體大小:1920 * 984 * 4B = 7557120B;
至于這個轉換的規則是什麼,我不清楚,有時間可以去源碼看一下,但就是說,Glide 會自動根據顯示的控件的大小來先進行分辨率的轉換,然後才加載進記憶體。
但不管是 Glide,fresco,都不管圖檔的來源是否在 res 内,也不管裝置的 dpi 是多少,是否需要和來源的 res 目錄進行一次分辨率轉換。
是以,我在圖檔記憶體大小這一章節中,才會說到,如果你使用了某個開源庫圖檔,那麼,那麼理論就不适用了,因為系統開放了 inSampleSize 接口設定,允許我們對需要加載進記憶體的圖檔先進行一定比例的壓縮,以減少記憶體占用。
而這些圖檔開源庫,内部自然會利用系統的這些支援,做一些記憶體優化,可能還涉及其他圖檔裁剪等等之類的優化處理,但不管怎麼說,此時,系統原生的計算圖檔記憶體大小的理論基礎自然就不适用了。
降低分辨率這點,除了圖檔開源庫内部預設的優化處理外,它們自然也會提供相關的接口來給我們使用,比如:
//fresco
ImageRequestBuilder.newBuilderWithSource(uri)
.setResizeOptions(new ResizeOptions(500, 500)).build()
對于 fresco 來說,可以通過這種方式,手動降低分辨率,這樣圖檔占用的記憶體大小也會跟着減少,但具體這個接口内部對于傳入的 (500, 500) 是如何處理,我也還不清楚,因為我們知道,系統開放的 API 隻支援分辨率按一定比例壓縮,那麼 fresco 内部肯定會進行一層的處理轉換了。
需要注意一點,我使用的 fresco 是 0.14.1 版本,高版本我不清楚,此版本的
setResizeOptions()
接口隻支援對 jpg 格式的圖檔有效,如果 png 圖檔的處理,網上很多,自行查閱。
Glide 的話,本身就已經根據控件大小做了一次處理,如果還要手動處理,可以使用它的
override()
方法。
總結
最後,來稍微總結一下:
- 一張圖檔占用的記憶體大小的計算公式:分辨率 * 像素點大小;但分辨率不一定是原圖的分辨率,需要結合一些場景來讨論,像素點大小就幾種情況:ARGB_8888(4B)、RGB_565(2B) 等等。
- 如果不對圖檔進行優化處理,如壓縮、裁剪之類的操作,那麼 Android 系統會根據圖檔的不同來源決定是否需要對原圖的分辨率進行轉換後再加載進記憶體。
- 圖檔來源是 res 内的不同資源目錄時,系統會根據裝置目前的 dpi 值以及資源目錄所對應的 dpi 值,做一次分辨率轉換,規則如下:新分辨率 = 原圖橫向分辨率 * (裝置的 dpi / 目錄對應的 dpi ) * 原圖縱向分辨率 * (裝置的 dpi / 目錄對應的 dpi )。
- 其他圖檔的來源,如磁盤,檔案,流等,均按照原圖的分辨率來進行計算圖檔的記憶體大小。
- jpg、png 隻是圖檔的容器,圖檔檔案本身的大小與它所占用的記憶體大小沒有什麼關系。
- 基于以上理論,以下場景的出現是合理的:
- 同個 app,在不同 dpi 裝置中,同個界面的相同圖檔所占的記憶體大小有可能不一樣。
- 同個 app,同一張圖檔,但圖檔放于不同的 res 内的資源目錄裡時,所占的記憶體大小有可能不一樣。
- 以上場景之所說有可能,是因為,一旦使用某個熱門的圖檔開源庫,那麼,以上理論基本就不适用了。
- 因為系統支援對圖檔進行優化處理,允許先将圖檔壓縮,降低分辨率後再加載進記憶體,以達到降低占用記憶體大小的目的
- 而熱門的開源圖檔庫,内部基本都會有一些圖檔的優化處理操作:
- 當使用 fresco 時,不管圖檔來源是哪裡,即使是 res,圖檔占用的記憶體大小仍舊以原圖的分辨率計算。
- 當使用 Glide 時,如果有設定圖檔顯示的控件,那麼會自動按照控件的大小,降低圖檔的分辨率加載。圖檔來源是 res 的分辨率轉換規則對它也無效。
本篇所梳理出的理論、基本都是通過總結别人的部落格記憶體,以及自己做相關實驗驗證後,得出來的結論,正确性相比閱讀源碼本身梳理結論自然要弱一些,是以,如果有錯誤的地方,歡迎指點一下。有時間,也可以去看看相關源碼,來确認一下看看。
推薦閱讀
1. Android性能優化(五)之細說Bitmap
大家好,我是 dasu,歡迎關注我的公衆号(dasuAndroidTv),如果你覺得本篇内容有幫助到你,可以轉載但記得要關注,要标明原文哦,謝謝支援~