Android Bitmap 使用詳解及加載優(yōu)化

每一個 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) {
        }
    }

運行后顯示效果如下:


1598321470204.jpg

在此基礎(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 緩存。
  • 如果需要展示的圖片過大,可以考慮使用分片加載的策略
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容