干货:Bitmap 复用时的一个异常

原文链接

本文基于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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值