乾貨:Bitmap 複用時的一個異常

orangex發表於2018-05-24

原文連結

本文基於Android 8.0,API 26,關鍵程式碼不一定貼的全,結合原始碼食用更佳。

一個異常

Fresco 發起解碼行為的大致流程:DecodeProducer 的內部類 ProgressiveDecoder 中 doDecode() 方法對未解碼的圖片進行解碼。 Fresco 根據圖片不同的格式呼叫不同的解碼方法,每種解碼方法再呼叫不同平臺的解碼方法。 PlatformDecoder 是不同平臺解碼器都應該實現的介面。只有兩個基本方法:

  • decodeFromEncodedImage()
  • decodeJPEGFromEncodedImage()

區別只在於後者為 JPEG 做了一些小處理,前者是通用版,他倆最後都會走到 decodeStaticImageFromStream() 然後走到 BitmapFactory.decodeStream() 交由 native 去執行真正的解碼。

事情從使用 Fresco 時的一個異常開始,可以看到 Message 大意是複用出了問題:

-java.lang.IllegalArgumentException: Problem decoding into existing bitmap android.graphics.BitmapFactory.decodeStream()
com.facebook.imagepipeline.platform.ArtDecoder.decodeStaticImageFromStream()
com.facebook.imagepipeline.platform.ArtDecoder.decodeFromEncodedImage()
com.facebook.imagepipeline.platform.ArtDecoder.decodeJPEGFromEncodedImage()
複製程式碼

這個堆疊其實是比較繞的,丟擲異常的地方在 BitmapFactory.decodeStream() 中:

if (bm == null && opts != null && opts.inBitmap != null) {
    throw new IllegalArgumentException("Problem decoding into existing bitmap");
}
複製程式碼

但實際上 BitmapFactory.decodeStream() 第一次丟擲異常會被上層 catch,我們看到的堆疊是第二次丟擲異常,被上層也丟擲的堆疊。梳理一下,這個異常發生的流程如下,在 ArtDecoder.decodeJPEGFromEncodedImage()中:

boolean retryOnFail=options.inPreferredConfig != Bitmap.Config.ARGB_8888;
try {
  return decodeStaticImageFromStream(jpegDataStream, options);
} catch (RuntimeException re) {
  if (retryOnFail) {
    return decodeFromEncodedImage(encodedImage, Bitmap.Config.ARGB_8888);
  }
  throw re;
}
複製程式碼

注意到如果 options.inPreferredConfig 不是 ARGB_8888的話,第一次 decodeStaticImageFromStream() 如果有異常會將 config 改為ARGB_8888重試。 ArtDecoder.decodeStaticImageFromStream()方法不長:

protected CloseableReference<Bitmap> decodeStaticImageFromStream(
    InputStream inputStream, BitmapFactory.Options options) {
    Preconditions.checkNotNull(inputStream);
    //長*寬*每畫素位元組大小 
    int sizeInBytes = BitmapUtil.getSizeInByteForBitmap(
        options.outWidth,
        options.outHeight,
        options.inPreferredConfig);
    // 從 Fresco 的 bitmap 池中去出一個大於等於該 size 的 bitmap 用來複用
    final Bitmap bitmapToReuse = mBitmapPool.get(sizeInBytes);
    if (bitmapToReuse == null) {
      throw new NullPointerException("BitmapPool.get returned null");
    }
    options.inBitmap = bitmapToReuse;

    Bitmap decodedBitmap;
    ByteBuffer byteBuffer = mDecodeBuffers.acquire();
    if (byteBuffer == null) {
      byteBuffer = ByteBuffer.allocate(DECODE_BUFFER_SIZE);
    }
    try {
      options.inTempStorage = byteBuffer.array();
      decodedBitmap = BitmapFactory.decodeStream(inputStream, null, options);
    } catch (RuntimeException re) {
      //放回到 bitmap 池中
      mBitmapPool.release(bitmapToReuse);
      throw re;
    } finally {
      mDecodeBuffers.release(byteBuffer);
    }

    if (bitmapToReuse != decodedBitmap) {
      mBitmapPool.release(bitmapToReuse);
      decodedBitmap.recycle();
      throw new IllegalStateException();
    }

    return CloseableReference.of(decodedBitmap, mBitmapPool);
  }
複製程式碼

可以看到 Line 21呼叫 BitmapFactory.decodeStream() 如果有異常就拋上去,於是將 config 改為ARGB_8888重試,重試走的是 decodeFromEncodedImage():

public CloseableReference<Bitmap> decodeFromEncodedImage(
    EncodedImage encodedImage,
    Bitmap.Config bitmapConfig) {
    //提前走一遍 native dodecode 得到 options (native 中通過 jni 更新 java 層的這個 options),得到以後,再處理一遍寬高,將原始寬高除以降取樣係數。
  final BitmapFactory.Options options = getDecodeOptionsForStream(encodedImage, bitmapConfig);
  boolean retryOnFail=options.inPreferredConfig != Bitmap.Config.ARGB_8888;
  try {
    return decodeStaticImageFromStream(encodedImage.getInputStream(), options);
  } catch (RuntimeException re) {
    if (retryOnFail) {
      return decodeFromEncodedImage(encodedImage, Bitmap.Config.ARGB_8888);
    }
    throw re;
  }
}
複製程式碼

這次 retryOnfail 顯然不成立,而這次如果依然有異常,就會真的丟擲去,同時圖片解碼、載入也就失敗了。如果一開始走的是 decodeFromEncodedImage() 其實也是同理。

疑問,所謂根據圖片格式呼叫不同的解碼方法是根據的 EncodeImage 物件內的一個關於格式的欄位,該欄位生成的邏輯是什麼,上文中的異常其實來自是一張 webp 格式的圖片,為什麼會走到 decodeJPEGFromEncodedImage() 方法呢?圖片以 .jpg.webp 結尾,貌似是從 jpg 轉成 webp 的,與這有關嗎?如果有關,我觀察到同樣是以 .jpg.webp 結尾的圖片,有的走的還是 decodeFromEncodedImage()。請指點。

真正的案發現場/順帶梳理 native decode 流程

再回頭看看拋異常的地方

 try {
            if (is instanceof AssetManager.AssetInputStream) {
                final long asset = ((AssetManager.AssetInputStream) is).getNativeAsset();
                bm = nativeDecodeAsset(asset, outPadding, opts);
            } else {
                bm = decodeStreamInternal(is, outPadding, opts);
            }

            if (bm == null && opts != null && opts.inBitmap != null) {
                throw new IllegalArgumentException("Problem decoding into existing bitmap");
            }

            setDensityFromOptions(bm, opts);
        } finally {
            Trace.traceEnd(Trace.TRACE_TAG_GRAPHICS);
        }
複製程式碼

結合上文 Fresco 發起解碼的過程來看,if 條件中的 opts != null && opts.inBitmap != null 都是滿足的,也就是說 bm == null 造成了該異常。對於本文討論的情況,bm 來自 decodeStreamInternal()方法,decodeStreamInternal()呼叫了 nativeDecodeStream() 方法:

static jobject nativeDecodeStream(JNIEnv* env, jobject clazz, jobject is, jbyteArray storage,jobject padding, jobject options) {
    jobject bitmap = NULL;
    std::unique_ptr<SkStream> stream(CreateJavaInputStreamAdaptor(env, is, storage));

    if (stream.get()) {
        std::unique_ptr<SkStreamRewindable> bufferedStream(
                SkFrontBufferedStream::Create(stream.release(), SkCodec::MinBufferedBytesNeeded()));
        SkASSERT(bufferedStream.get() != NULL);
        bitmap = doDecode(env, bufferedStream.release(), padding, options);
    }
    return bitmap;
}
複製程式碼

其實就是帶著流和 options 交給 doDecode() 方法,這個方法非常的長,簡化+虛擬碼+註釋如下:

SK開頭的這些類都來自 SKia。SKia 是一個開源的二維圖形庫,提供各種常用的API,並可在多種軟硬體平臺上執行。谷歌Chrome瀏覽器、Chrome OS、安卓、火狐瀏覽器、火狐作業系統以及其它許多產品都使用它作為圖形引擎。關於解碼,不管是 java 層還 native 層其實都只是在轉換、封裝、傳遞,給 Android 與 SKia 搭橋罷了。

static jobject doDecode(JNIEnv* env, SkStreamRewindable* stream, jobject padding, jobject options) {
	//(1) 用一些區域性變數預設一些預設值 sampleSize、isMutable、scale 等等,對應著 options 中的欄位
    SetDefaultValues()
        
	//(2)根據客戶端也就是 Java 層傳進來 options 將(1)種能更新的值更新
	jobject jconfig = env->GetObjectField(options, gOptions_configFieldID);
    set ( prefColorType, jobject jcolorSpace ,prefColorSpace ,isHardware ,isMutable ,
        requireUnpremultiplied,
        //對應  Options.inBitmap 即拿來複用的 bitmap
        javaBitmap ) from jconfig/options
	if (傳入 option 有 scale 欄位)) {
        update (density,targetDensity,screenDensity,scale) from options
        }
    
    //(3) 建立解碼器 SkAndroidCodec(其實是個殼),其持有一個根據流建立的針對不同圖片型別的真正的解碼器,如 SKWebpCodec,真正的底層的解碼操作(演算法)會交給它去做,這就不在本文的討論範圍之內了
      std::unique_ptr<SkAndroidCodec> codec(SkAndroidCodec::NewFromStream(streamDeleter.release(), &peeker));
    
    //(4) 建立解碼器時已經從流中拿到了尺寸資訊,可以稱之為原始尺寸.
    size = codec.getSampledDimensions(samplesize)
    
        
    //(5) 決定 output color type , 根據客戶端傳入的 config (即上面的更新的 prefColorType),選擇最合適的色彩型別。 這裡後文還會詳細分析,關乎一些複用問題上的坑。
    decodeColorType = codec.computeOutputColorType(prefColorType);
    decodeColorSpace=codec.computeOutputColorSpace(decodeColorType, prefColorSpace);
    
    //(6) 更新,如果客戶端只是想往 options 裡填 size ,直接 return。回頭看 java 層傳入的 options  ,裡面的寬高是怎麼來的?其實就是提前執行了一遍 nativeDecodeStream(),並在這裡就 return ,不做後面的真正的解碼過程。
    set (height,width,colortype ,colortype,colorspace) in options (in java)
    if (inJustDecodeBounds){ 
        return nullptr;
    }
    // 縮放後的寬高計算,精度補償
    if (scale != 1.0f) {
        willScale = true;
        scaledWidth = static_cast<int>(scaledWidth * scale + 0.5f);
        scaledHeight = static_cast<int>(scaledHeight * scale + 0.5f);
    }
        
    //(7) 由 javaBitmap 建立對應 native 層的 bitmap ,reuseBitmap,兩者關聯方式是 java 層 bitmap 持有 long 型成員 mNativePtr,它指向 native bitmap 的地址。(注意這裡指的是 8.0,API 26 對於 bitmap 是有大改動的,主要是畫素資料存放位置的改動)。並計算關乎複用的 existingBufferSize,即拿來複用的 reuseBitmap 畫素資料所佔用的記憶體大小,計算呼叫了 java 層的方法 getAllocationByteCount(),然而該方法又呼叫的 nativeGetAllocationByteCount() 附在文末,這裡繞了一大圈看似很麻煩大概是因為版本改動,8.0之前 java 層 getAllocationByteCount() 的實現也是 java 層的實現。
    reuseBitmap = &bitmap::toBitmap(env, javaBitmap);
    existingBufferSize = bitmap::getBitmapAllocationByteCount(env, javaBitmap);
    
    //(8) 根據是否複用、縮放、是否設定 isHardware 選定記憶體分配器,複用&縮放:ScaleCheckingAllocator ,複用&不縮放:RecyclingPixelAllocator,不復用&(縮放|hardware): SkBitmap::HeapAllocator,其他: HeapAllocator。
    decodeAllocator = chooseAllocator();
    
    //(9) 建立色碼錶, 以防解碼不時之需
    、、、
        
    //(10) 建立 SKImageInfo ,寬高,colortype、alphatype( 用以描述畫素的格式),colorspace(描述顏色的範圍)
    、、、
    
    //(11) 將 info 設定給 SKBitmap,SKBitmap 當然是持有畫素記憶體地址的,info 就完完全全存放在對應記憶體區域內的任意地方。用剛剛選定的記憶體分配器分配記憶體,就是這裡的複用失敗導致了文章開始提到的異常。如英文註釋說的,decodingBitmap.tryAllocPixels() 會在拿來複用的 bitmap 的大小裝不下新的 bitmap 需要的大小時返回 false。
    SkBitmap decodingBitmap;
    if (!decodingBitmap.setInfo(bitmapInfo) ||
            !decodingBitmap.tryAllocPixels(decodeAllocator, colorTable.get())) {
        // SkAndroidCodec should recommend a valid SkImageInfo, so setInfo()
        // should only only fail if the calculated value for rowBytes is too
        // large.
        // tryAllocPixels() can fail due to OOM on the Java heap, OOM on the
        // native heap, or the recycled javaBitmap being too small to reuse.
        return nullptr;
    }
}
複製程式碼

doDecode 方法的梳理先到這裡,看看 tryAllocPixels() 具體是怎麼返回 false 的吧。在我的案例中decodeAllocator 是 RecyclingPixelAllocator

  virtual bool allocPixelRef(SkBitmap* bitmap, SkColorTable* ctable) {
        const SkImageInfo& info = bitmap->info();
        if (info.colorType() == kUnknown_SkColorType) {
            ALOGW("unable to reuse a bitmap as the target has an unknown bitmap configuration");
            return false;
        }
		//這個 getSafeSize64() 裡面寫的很奇怪,調研過程浪費了很多注意力在這裡,正常寫 height*rowByte 就可以了,而該函式的實現為 (height-1)*rowbytes+width*bytesPerPixel,大概是出於 int64 型別安全相關的考慮。
        const int64_t size64 = info.getSafeSize64(bitmap->rowBytes());
      
        if (!sk_64_isS32(size64)) {
            ALOGW("bitmap is too large");
            return false;
        }

        const size_t size = sk_64_asS32(size64);
        if (size > mSize) {
            //記住這個地方,這個 log 很關鍵。
            ALOGW("bitmap marked for reuse (%u bytes) can't fit new bitmap "
                  "(%zu bytes)", mSize, size);
            return false;
        }

        mBitmap->reconfigure(info, bitmap->rowBytes(), ctable);
        mBitmap->ref();
        bitmap->setPixelRef(mBitmap)->unref();

        // since we're already allocated, we lockPixels right away
        // HeapAllocator behaves this way too
        bitmap->lockPixels();
        return true;
    }
複製程式碼

很簡單,size > mSize 時會返回 false,size 是計算出來的新 bitmap 需要的記憶體大小,mSize 是前面傳進來的existingBufferSize 表示拿來的複用的 bitmap 的記憶體大小,意思就是複用的記憶體裝不下新 bitmap。這就是案發現場了!

原因探究

複用的記憶體不夠大, 複用失敗,拋異常。native 這樣處理,道理我都懂,可是鴿子為什麼這麼大 可是,我們明明傳進來的是個足夠大的 bitmap 啊,怎麼算出來就不夠大呢?

捋一捋,捋一捋。在 Fresco 的 decodeStaticImageFromStream 方法中(往上翻),我們根據 options 中的 width * height * bytesPerPixel 算出了新 bitmap 需要的大小,並從 bitmap 池中取了一個“大小>=需要”的 bitmap,作為 inBitmap 引數,來複用。難道從池裡取得有問題?

我發現了某張很容易復現的圖片,原始尺寸為 1125*549 的 webp 圖片 ,Debug 發現:

decodeFromEncodedImage 方法中,提前 native decode (只為獲取寬高)後,得到 options.width = 1125,options.height = 549 , 然後除以取樣係數 2 後, options.width = 562 ,options.height =274 , config 預設為565,算出需要的大小為 562 * 274 * 2 = 307976 byte, 同時 bitmap 池中找到的恰好也是一個 307976 byte 的 bitmap ( 你肯定會問憑什麼這麼巧。。。其實是在這之前這個 bitmap 被存進了 Fresco bitmap 池,並且還重塑的寬高等資訊,具體待研究,這裡不重要 )。

還記得案發現場的那個 log 嗎?然後在這一次的解碼過程中打出的 log 顯示

bitmap marked for reuse (307976 bytes) can't fit new bitmap (309650 bytes)

複用失敗後,config 被改為 8888 重試,隨即打下第二次 log,並直接丟擲異常

bitmap marked for reuse (615952 bytes) can't fit new bitmap (619300 bytes)

很明顯 reuse bitmap 的大小符合理論值,而 new bitmap 的值為什麼多出了一丟丟呢。

看這裡,不說也明白了

// 縮放後的寬高計算,精度補償 
if (scale != 1.0f) { 
	willScale = true; 
	scaledWidth = static_cast<int>(scaledWidth * scale + 0.5f); 
	scaledHeight = static_cast<int>(scaledHeight * scale + 0.5f); 
}
複製程式碼

算 new bitmap 的大小時用到的寬高被動了些手腳,

cast (562 +0.5f ) = 563 , cast (274 +0.5f ) = 275

563 * 275 * 2 = 309650

563 * 275 * 4 = 619300

齊活。原因就是這麼簡單,但要從行行程式碼中找到它不輕鬆。這個根本原因我們是改不了了,可能谷歌認為這影響不大吧。

解決辦法

應用層的應對方法有

  1. Fresco 的一個補救方案, 異常不是因為重用 bitmap嗎?索性重用失敗後在 decode 一次,這一次就是普通的 decode(stream),不帶 options 引數了,也就不復用了。Fresco 自己也說了,這可能很沒效率,但至少能讓你的圖片顯示出來。

  2. 自己想的:

    oldWidth= scaledWidth;

    oldHeight=scaledHeight;

    scaledWidth = static_cast(scaledWidth * scale + 0.5f);

    scaledHeight = static_cast(scaledHeight * scale + 0.5f);

    誤差為 scaledWidth * scaledHeight - oldHeight * oldWidth

    我們可以調整一下 bitmap 池的存取策略,對於需要的大小 x,返回一個大小為 x+function(誤差),這個 function 可以設計一下,或者索性設成倍數比如 0.1x,這樣能降低甚至消除因本文討論的這種複用失敗的情況,代價就是 bitmap 池的利用效率有所下降。

另一種原因

復現過程中發現了另一種情況:

bitmap marked for reuse (307976 bytes) can't fit new bitmap (615952 bytes)

後面是前面的兩倍,現在看來很好猜測,大概是 config 即 colortype 的原因。

客戶端可以指定的 preferConfig 有

ALPHA_8     
RGB_565    常用
ARGB_4444  廢棄
ARGB_8888  常用
RGBA_F16   
HARDWARE  8.0加入,只改變畫素的儲存區域至 GPU 記憶體,colortype 仍以 ARGB_8888 處理。
複製程式碼

這些 colortype 會被 native 對映成真正的 SKColorType (參見 SK 文件),並作為參考,得出最終的 SKColorType 。也就是說你可能指定的是每畫素 4 位元組的 ARGB_8888 ,到了 native 變成了每畫素 8 位元組的 RGBA_F16 。

而 Fresco 目前只支援前四種型別,可能會出現 config 前後不一的問題,導致該異常發生。可以參考Fresco 的某 Issue,前面的解決辦法1,其實就是為了解決這個問題而生的。

nativeGetAllocationByteCount() 的實現如下:

//mPixelStorageType 由建立 bitmap 時決定,8.0中 若沒有指定 ishardware(將畫素儲存位置改至GPU 記憶體中),則 type 為 Type.Ashmem,而不是 Type.Heap。rowBytes 為每行位元組數,這個值大於等於每行實際佔用位元組數,因為畫素儲存時,行的末尾(其實所謂行都是邏輯上的,記憶體是線性的)會有不用的位元組。
size_t Bitmap::getAllocationByteCount() const {
    switch (mPixelStorageType) {
    case PixelStorageType::Heap:
        return mPixelStorage.heap.size;
    default:
        return rowBytes() * height();
    }
}
複製程式碼

native decode 的後半部分待續


我這個人很簡單,你關注,我就讓你點贊23333

乾貨:Bitmap 複用時的一個異常

相關文章