每一個 Android App 中都會使用到 Bitmap,它也是程序中內(nèi)存消耗的大戶,當(dāng) Bitmap 使用內(nèi)存超過可用空間,則會報 OOM。
Bitmap 占用內(nèi)存分析
Bitmap 用來描述一張圖片的長、寬、顏色等信息。通常情況下,我們可以使用 BitmapFactory 來將某一路徑下的圖片解析為 Bitmap 對象。
當(dāng)一張圖片加載到內(nèi)存后,具體需要占用多大內(nèi)存呢?
getAllocationByteCount 探索
我們可以通過 Bitmap.getAllocationByteCount() 方法獲取 Bitmap 占用的字節(jié)大小,比如以下代碼:

上圖中 rodman 是保存在 res/drawable-xxhdpi 目錄下的一張 600*600,大小為 65Kb 的圖片。打印結(jié)果如下:
I/Bitmap: bitmap size is 1440000
默認(rèn)情況下 BitmapFactory 使用 Bitmap.Config.ARGB_8888 的存儲方式來加載圖片內(nèi)容,而在這種存儲模式下,每一個像素需要占用 4 個字節(jié)。因此上面圖片 rodman 的內(nèi)存大小可以使用如下公式來計算:
寬 * 高 * 4 = 600 * 600 * 4 = 1440000
屏幕自適應(yīng)
但是如果我們在保證代碼不修改的前提下,將圖片 rodman 移動到(注意是移動,不是拷貝)res/drawable-xhdpi 目錄下,重新運行代碼,則打印日志如下:
I/Bitmap: bitmap size is 3240000
可以看出我們只是移動了圖片的位置,Bitmap 所占用的空間竟然上漲了 125%。這是為什么呢?
實際上 BitmapFactory 在解析圖片的過程中,會根據(jù)當(dāng)前設(shè)備屏幕密度和圖片所在的 drawable 目錄來做一個對比,根據(jù)這個對比值進(jìn)行縮放操作。具體公式為如下所示:
1. 縮放比例 scale = 當(dāng)前設(shè)備屏幕密度 / 圖片所在 drawable 目錄對應(yīng)屏幕密度
2.Bitmap 實際大小 = 寬 * scale * 高 * scale * Config 對應(yīng)存儲像素數(shù)
在 Android 中,各個 drawable 目錄對應(yīng)的屏幕密度分別為下:
| 目錄 | drawable-mdpi | drawable-hdpi | drawable-xhdpi | drawable-xxhdpi | drawable-xxxhdpi |
|---|---|---|---|---|---|
| density | 1 | 1.5 | 2 | 3 | 4 |
| densityDpi | 160 | 240 | 320 | 480 | 640 |
我運行的設(shè)備是 Nexus 5,屏幕密度為 480。如果將 rodman 放到 drawable-hdpi 目錄下,最終的計算公式如下:
rodman 實際占用內(nèi)存大小 = 600 * (480 / 320) * 600 * (480 / 320) * 4 = 3240000
assets 中的圖片大小
Android 中的圖片不僅可以保存在 drawable 目錄中,還可以保存在 assets 目錄下,然后通過 AssetManager 獲取圖片的輸入流。那這種方式加載生成的 Bitmap 是多大呢?同樣是上面的 rodman.png,這次將它放到 assets 目錄中,使用如下代碼加載:
try {
val inputStream = assets.open("rodman.png")
val bitmap = BitmapFactory.decodeStream(inputStream)
Log.i("Bitmap", "bitmap size is " + bitmap.allocationByteCount)
image.setImageBitmap(bitmap)
} catch (e: Exception) {
}
最終打印結(jié)果如下:
I/Bitmap: bitmap size is 1440000
可以看出,加載 assets 目錄中的圖片,系統(tǒng)并不會對其進(jìn)行縮放操作。
Bitmap 加載優(yōu)化
上面的例子也能看出,一張 65Kb 大小的圖片被加載到內(nèi)存后,竟然占用了 3240000 個字節(jié),也就是 3.24M 左右。因此適當(dāng)時候,我們需要對需要加載的圖片進(jìn)行縮略優(yōu)化。
修改圖片加載的 Config
修改占用空間少的存儲方式可以快速有效降低圖片占用內(nèi)存。比如通過 BitmapFactory.Options 的 inPreferredConfig 選項,將存儲方式設(shè)置為 Bitmap.Config.RGB_565。這種存儲方式一個像素占用 2 個字節(jié),所以最終占用內(nèi)存直接減半。如下:

打印日志如下:
I/Bitmap: bitmap size is 1620000
另外 Options 中還有一個 inSampleSize 參數(shù),可以實現(xiàn) Bitmap 采樣壓縮,這個參數(shù)的含義是寬高維度上每隔 inSampleSize 個像素進(jìn)行一次采集。比如以下代碼:
因為寬高都會進(jìn)行采樣,所以最終圖片會被縮略 4 倍,最終打印效果如下:
I/Bitmap: bitmap size is 405000
Bitmap 復(fù)用
場景描述
如果在 Android 某個頁面創(chuàng)建很多個 Bitmap,比如有兩張圖片 A 和 B,通過點擊某一按鈕需要在 ImageView 上切換顯示這兩張圖片,實現(xiàn)效果如下所示:

可以使用以下代碼實現(xiàn)上述效果:
class BitmapPoolActivity : AppCompatActivity() {
var reuseBitmap: Bitmap? = null
var resIndex = 0
val resId = arrayOf(R.drawable.rodman, R.drawable.rodman2)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_bitmap_pool)
val options = BitmapFactory.Options()
options.inMutable = true
reuseBitmap = BitmapFactory.decodeResource(resources, resId[0], options)
switchImage.setOnClickListener {
poolImage.setImageBitmap(getBitmap())
}
}
private fun getBitmap(): Bitmap {
val options = BitmapFactory.Options()
return BitmapFactory.decodeResource(resources, resId[resIndex++ % 2], options)
}
}
但是在每次調(diào)用 switchImage 切換圖片時,都需要通過 BitmapFactory 創(chuàng)建一個新的 Bitmap 對象。當(dāng)方法執(zhí)行完畢后,這個 Bitmap 又會被 GC 回收,這就造成不斷地創(chuàng)建和銷毀比較大的內(nèi)存對象,從而導(dǎo)致頻繁 GC(或者叫內(nèi)存抖動)。像 Android App 這種面相最終用戶交互的產(chǎn)品,如果因為頻繁的 GC 造成 UI 界面卡頓,還是會影響到用戶體驗的??梢栽?Android Studio Profiler 中查看內(nèi)存情況,多次切換圖片后,顯示的效果如下:

使用 Options.inBitmap 優(yōu)化
實際上經(jīng)過第一次顯示之后,內(nèi)存中已經(jīng)存在了一個 Bitmap 對象。每次切換圖片只是顯示的內(nèi)容不一樣,我們可以重復(fù)利用已經(jīng)占用內(nèi)存的 Bitmap 空間,具體做法就是使用 Options.inBitmap 參數(shù)。將 getBitmap 方法修改如下:
class BitmapPoolActivity : AppCompatActivity() {
var reuseBitmap: Bitmap? = null
var resIndex = 0
val resId = arrayOf(R.drawable.rodman, R.drawable.rodman2)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_bitmap_pool)
val options = BitmapFactory.Options()
options.inMutable = true
reuseBitmap = BitmapFactory.decodeResource(resources, resId[0], options)
switchImage.setOnClickListener {
poolImage.setImageBitmap(getBitmap())
}
}
private fun getBitmap(): Bitmap {
val options = BitmapFactory.Options()
options.inJustDecodeBounds = true
BitmapFactory.decodeResource(resources, resId[resIndex % 2], options)
if (canUseForInBitmap(reuseBitmap!!, options)) {
Log.e("BitmapPoolActivity", "reuseBitmap is reusable")
options.inMutable = true
options.inBitmap = reuseBitmap
}
options.inJustDecodeBounds = false
return BitmapFactory.decodeResource(resources, resId[resIndex++ % 2], options)
}
private fun canUseForInBitmap(candidate: Bitmap, targetOptions: BitmapFactory.Options): Boolean {
val width = targetOptions.outWidth / Math.max(targetOptions.inSampleSize, 1)
val height = targetOptions.outHeight / Math.max(targetOptions.inSampleSize, 1)
val byteCount: Int = width * height * getBytesPerPixel(candidate.config)
return byteCount <= candidate.allocationByteCount
}
private fun getBytesPerPixel(config: Bitmap.Config): Int {
return when (config) {
Bitmap.Config.ALPHA_8 -> 1
Bitmap.Config.RGB_565,
Bitmap.Config.ARGB_4444 -> 2
else -> 4
}
}
}
解釋說明:
- 代碼中 var reuseBitmap: Bitmap? = null 處創(chuàng)建一個可以用來復(fù)用的 Bitmap 對象。
- 代碼中 options.inBitmap = reuseBitmap 處,將 options.inBitmap 賦值為之前創(chuàng)建的 reuseBitmap 對象,從而避免重新分配內(nèi)存。
重新運行代碼,并查看 Profiler 中的內(nèi)存情況,可以發(fā)現(xiàn)不管我們切換圖片多少次,內(nèi)存占用始終處于一個水平線狀態(tài)。

注意:在上述 getBitmap 方法中,復(fù)用 inBitmap 之前,需要調(diào)用 canUseForInBitmap 方法來判斷 reuseBitmap 是否可以被復(fù)用。這是因為 Bitmap 的復(fù)用有一定的限制:
- 在 Android 4.4 版本之前,只能重用相同大小的 Bitmap 內(nèi)存區(qū)域;
- 4.4 之后你可以重用任何 Bitmap 的內(nèi)存區(qū)域,只要這塊內(nèi)存比將要分配內(nèi)存的 bitmap 大就可以。
canUserForInBitmap 方法具體如下:
private fun canUseForInBitmap(candidate: Bitmap, targetOptions: BitmapFactory.Options): Boolean {
val width = targetOptions.outWidth / Math.max(targetOptions.inSampleSize, 1)
val height = targetOptions.outHeight / Math.max(targetOptions.inSampleSize, 1)
val byteCount: Int = width * height * getBytesPerPixel(candidate.config)
//新 Bitmap 內(nèi)存 < 可復(fù)用占用內(nèi)存
return byteCount <= candidate.allocationByteCount
}
private fun getBytesPerPixel(config: Bitmap.Config): Int {
return when (config) {
Bitmap.Config.ALPHA_8 -> 1
Bitmap.Config.RGB_565,
Bitmap.Config.ARGB_4444 -> 2
else -> 4
}
}
在每次加載之前,除了 inBitmap 參數(shù)之外,我還將 Options.inMutable 置為 true,這里如果不置為 true 的話,BitmapFactory 將不會重復(fù)利用 Bitmap 內(nèi)存,并輸出相應(yīng) warning 日志:
W/BitmapFactory: Unable to reuse an immutable bitmap as an image decoder target.
BitmapRegionDecoder 圖片分片顯示
有時候我們想要加載顯示的圖片很大或者很長,比如手機滾動截圖功能生成的圖片。
針對這種情況,在不壓縮圖片的前提下,不建議一次性將整張圖加載到內(nèi)存,而是采用分片加載的方式來顯示圖片部分內(nèi)容,然后根據(jù)手勢操作,放大縮小或者移動圖片顯示區(qū)域。
圖片分片加載顯示主要是使用 Android SDK 中的 BitmapRegionDecoder 來實現(xiàn)。
BitmapRegionDecoder 基本使用
首先需要使用 BitmapRegionDecoder 將圖片加載到內(nèi)存中,圖片可以以絕對路徑、文件描述符、輸入流的方式傳遞給 BitmapRegionDecoder,如下所示:
/**
* 顯示圖片左上角 200 * 200 區(qū)域
*/
private fun showRegionImage() {
try {
val inputStream = assets.open("rodman3.png")
val decoter = BitmapRegionDecoder.newInstance(inputStream, false)
val options = BitmapFactory.Options()
val bitmap = decoter.decodeRegion(Rect(0, 0, 200, 200), options)
regionImage.setImageBitmap(bitmap)
} catch (e: Exception) {
}
}
運行后顯示效果如下:

在此基礎(chǔ)上,我們可以通過自定義View,添加 touch 事件來動態(tài)地設(shè)置 Bitmap 需要顯示的區(qū)域 Rect。具體實現(xiàn)網(wǎng)上已經(jīng)有很多成熟的輪子可以直接使用,比如 LargeImageView
。也有一篇比較詳細(xì)文章對此介紹:Android 高清加載巨圖方案。。
Bitmap 緩存
當(dāng)需要在界面上同時展示一大堆圖片的時候,比如 ListView、RecyclerView 等,由于用戶不斷地上下滑動,某個 Bitmap 可能會被短時間內(nèi)加載并銷毀多次。這種情況下通過使用適當(dāng)?shù)木彺妫梢杂行У販p緩 GC 頻率保證圖片加載效率,提高界面的響應(yīng)速度和流暢性。
最常用的緩存方式就是 LruCache,基本使用方式如下:

解釋說明:
圖中 1 處指定 LruCache 的最大空間為 20M,當(dāng)超過 20M 時,LruCache 會根據(jù)內(nèi)部緩存策略將多余 Bitmap 移除。
圖中 2 處指定了插入 Bitmap 時的大小,當(dāng)我們向 LruCache 中插入數(shù)據(jù)時,LruCache 并不知道每一個對象會占用大多內(nèi)存,因此需要我們手動指定,并且根據(jù)緩存數(shù)據(jù)的類型不同也會有不同的計算方式。
總結(jié):
詳細(xì)介紹了 Bitmap 開發(fā)中的幾個常見問題:
- 一張圖片被加載成 Bitmap 后實際占用內(nèi)存是多大。
- 通過 Options.inBitmap 可以實現(xiàn) Bitmap 的復(fù)用,但是有一定的限制。
- 當(dāng)界面需要展示多張圖片,尤其是在列表視圖中,可以考慮使用 Bitmap 緩存。
- 如果需要展示的圖片過大,可以考慮使用分片加載的策略