查看原文
其他

内存大户Bitmap

鸿洋
2024-08-24

The following article is from 半行代码 Author 半行代码

前段时间工作中治理了一些 oom,针对内存大户 Bitmap 进行了了一次原理层面的分析。


1如何计算Bitmap的内存占用


日常我们提到图片大小的时候,一般都会把关注点放在图片的文件大小。因为一般来说,图片文件越小,内存占用也会越小。但是其实图片文件大小和内存占用大小没有什么直接的必然联系,我们可以通过查看 Android 的 Bitmap 的内存分配,来查看 Bitmap 的内存大小是被哪些因素影响的。

在 Android 的架构里, Bitmap 相关的内容分为下面几个模块:

• Java:包括 Bitmap、BitmapFactory等类,上层直接使用创建 Bitmap。
• native:包括 android::Bitmap 对象等,负责决定内存分配方式,调用skia。
• sk:包括 SkBitmap, skia 引擎去绘制 Bitmap。

这里绘制一个简单的调用时序图方便缕清逻辑:

在Android里,android5-8 和 android8 以上的 Bitmap 内存分配策略是不同的,但是通过源码对比,虽然代码有了比较大的改动,但是调用流程和内存大小的计算方式是基本没有什么大的变化。


解码配置-每像素字节

Bitmap里面,我们可以通过  getByteCount 方法来得到图片内存大小的字节数,它的计算方法则是:

getRowBytes() * getHeight();

 getRowBytes 是调取了底层逻辑,最终调用到 SkBitmap里:

size_t rowBytes() const { return fRowBytes; }

skkia里面则通过 minRowBytes 计算行字节数:


size_t minRowBytes() const {
        uint64_t minRowBytes = this->minRowBytes64();
        if (!SkTFitsIn<int32_t>(minRowBytes)) {
            return 0;
        }
        return (size_t)minRowBytes;
}

uint64_t minRowBytes64() const {
 return (uint64_t)sk_64_mul(this->width(), this->bytesPerPixel());
}

int bytesPerPixel() const { return fColorInfo.bytesPerPixel(); }

这里我们得到行字节数的计算:

行字节 = 行像素 * 每像素字节数

这里的 fColorInfo 就对应 Option里的 inPreferredConfig。这个代表了图片的解码配置,包括:

• ALPHA_8 单通道,总共8位,1个字节。
• RGB_565 每像素16为。
• ARGB-4444 每像素16位,(2字节),已经废弃,传的话会被改为 ARGB_8888。
• ARGB_8888 每个像素32位(总共4字节),也就是 argb 四个通过各8位。
• RGBA_F16  每个像素16位,总共8个字节。
• HARDWARE 硬件加速,如果图片只在内存中,使用这个配置最合适。

这里我们可以先简单理解为图片内存大小就是:

宽 * 高(尺寸) * 每像素字节数

图片尺寸

在上层,我们会通过 BitmapFactory 去创建一个 Bitmap,例如通过

public static Bitmap decodeResource(Resources res, int id)

通过resource里的图片资源创建 Bitmap。类似的函数比较多,但是都会转成stream执行到

public static Bitmap decodeStream(@Nullable InputStream is@Nullable Rect outPadding,
            @Nullable Options opts)

这里传入的 Options 参数其实就会影响最终图片尺寸的计算。接着我们继续看 decodeStream的逻辑。这个会执行 native 的nativeDecodeStream函数。进行图片的解码:解码之前会读取java层传入的配置。其中当 inScale 为ture(默认也是true)的时候:

if (env->GetBooleanField(options, gOptions_scaledFieldID)) {
  const int density = env->GetIntField(options, gOptions_densityFieldID);
  const int targetDensity = env->GetIntField(options, gOptions_targetDensityFieldID);
  const int screenDensity = env->GetIntField(options, gOptions_screenDensityFieldID);
  if (density != 0 && targetDensity != 0 && density != screenDensity) {
   scale = (float) targetDensity / density;
  }
}

这里读取  inDensityinTargetDensity inScreenDensity 参数,来确定缩放比例。这几个参数看着挺抽象的,我们看下传入的具体是什么东西inDensity

final int density = value.density;
if (density == TypedValue.DENSITY_DEFAULT) {
 opts.inDensity = DisplayMetrics.DENSITY_DEFAULT;
else if (density != TypedValue.DENSITY_NONE) {
 opts.inDensity = density;
}

传入源图的density,如果是默认值的话就传160,inTargetDensity

opts.inTargetDensity = res.getDisplayMetrics().densityDpi;

这个其实也是设备的 dpi。这个值具体可以通过

adb shell dumpsys window displays

进行查看。


screenDensity

static int resolveDensity(@Nullable Resources r, int parentDensity) {
 final int densityDpi = r == null ? parentDensity : r.getDisplayMetrics().densityDpi;
 return densityDpi == 0 ? DisplayMetrics.DENSITY_DEFAULT : densityDpi;
}

一般情况下和 inTargetDensity 的一样的。所以这里计算出来的scale是用来适配屏幕分辨率的。

然后会通过  sampleSize 来计算输出的宽高:

SkISize size = codec->getSampledDimensions(sampleSize);

//skia
SkISize SkSampledCodec::onGetSampledDimensions(int sampleSize) const {
 const SkISize size = this->accountForNativeScaling(&sampleSize);
 return SkISize::Make(get_scaled_dimension(size.width(), sampleSize),
  get_scaled_dimension(size.height(), sampleSize));
}

static inline int get_scaled_dimension(int srcDimension, int sampleSize) {
 if (sampleSize > srcDimension) {
  return 1;
 }
 return srcDimension / sampleSize;
}

这里宽高会变成:

初始宽高 / simpleSize

接着会使用上面提到是 scale 进行缩放:

if (scale != 1.0f) {
 willScale = true;
 scaledWidth = static_cast<int>(scaledWidth * scale + 0.5f);
 scaledHeight = static_cast<int>(scaledHeight * scale + 0.5f);
}

这里可以看到我们最后传给Java层去创建 Bitmap 的尺寸就是一系列计算得到的 scaleWidth * scaleHeight,即:

宽  = 原始宽度 * (targetDensity / density) / sampleSize + 0.5f


2Bitmap内存分配


在对应用的内存情况做进一步分析后,了解到了 Bitmap 的内存分配与回收在不同的 Android 版本中又不一样的机制。最近对这块也做了一些了解。根据 Android 系统版本,可以把分配方式分成几组:

• Android 3以前:图片数据分配在 native。这个已经是历史了,不关系
• Android8 以前:图片数据分配在java堆。这个虽然也挺旧了,但是应用基本还会支持很大一部分,
• Android8 及以后:图片数据分配在 native

所以我copy了 2 份源码来分析这部分,一份 Android6 的, 一份 Android 10 的。

创建过程

8.0以上

顺着 8.0 的 BitmapFactory#nativeDecodeStream 往下看,在 native 层代码里面,最终会调用 Bitmap 的构造方法去创建 Bitmap 的 java 层对象:

// now create the java bitmap
return bitmap::createBitmap(env, defaultAllocator.getStorageObjAndReset(),
    bitmapCreateFlags, ninePatchChunk, ninePatchInsets, -1);


// createBitmap
BitmapWrapper* bitmapWrapper = new BitmapWrapper(bitmap);
jobject obj = env->NewObject(gBitmap_class, gBitmap_constructorMethodID,
            reinterpret_cast<jlong>(bitmapWrapper), bitmap->width(), bitmap->height(), density,
            isPremultiplied, ninePatchChunk, ninePatchInsets, fromMalloc);

这里 BitmapWrapper 是对 native Bitmap 的一层包装。这里传递的是它的指针。这个对应了Java层的构造方法:

Bitmap(long nativeBitmap, int width, int height, int density,
            boolean requestPremultiplied, byte[] ninePatchChunk,
            NinePatch.InsetStruct ninePatchInsets, boolean fromMalloc)

到这里 Bitmap就创建完毕了 这里得到一个简单的指向关系:

接下来看详细的分配逻辑,在 native 层创建 Bitmap 的时候会有预分配的逻辑:

decodingBitmap.tryAllocPixels(decodeAllocator)

这里的 decodingBitmapSkBitmap,可以直接 google SkBitmap 对象的源码:

bool SkBitmap::tryAllocPixels(Allocator* allocator) {
    HeapAllocator stdalloc;

    if (nullptr == allocator) {
        allocator = &stdalloc;
    }
    return allocator->allocPixelRef(this);
}

//上面调用的 HeapAllocator#allocPixelRef
// Graphics.cpp
bool HeapAllocator::allocPixelRef(SkBitmap* bitmap) {
    mStorage = android::Bitmap::allocateHeapBitmap(bitmap);
    return !!mStorage;
}

allocateHeapBitmap里面是真正的分配逻辑:

sk_sp<Bitmap> Bitmap::allocateHeapBitmap(const SkImageInfo& info) {
    size_t size;
    if (!computeAllocationSize(info.minRowBytes(), info.height(), &size)) {
        LOG_ALWAYS_FATAL("trying to allocate too large bitmap");
        return nullptr;
    }
    return allocateHeapBitmap(size, info, info.minRowBytes());
}

sk_sp<Bitmap> Bitmap::allocateHeapBitmap(size_t size, const SkImageInfo& info, size_t rowBytes) {
    void* addr = calloc(size, 1);
    if (!addr) {
        return nullptr;
    }
    return sk_sp<Bitmap>(new Bitmap(addr, size, info, rowBytes));
}

使用 calloc函数分配需要的size。并且创建 Bitmap,把分配后的指针指向 addr。

8.0以下

8.0以下的 decode 里面最后会使用  JavaAllocator 分配图片像素:

// now create the java bitmap
return GraphicsJNI::createBitmap(env, javaAllocator.getStorageObjAndReset(),
 bitmapCreateFlags, ninePatchChunk, ninePatchInsets, -1);

分配的逻辑放在了 SkImageDecoder 里面:

SkImageDecoder* decoder = SkImageDecoder::Factory(stream);
// ...
decoder->decode(
    stream,
    &decodingBitmap,
    prefColorType, decodeMode) != SkImageDecoder::kSuccess
)

// skia
SkImageDecoder::Result SkImageDecoder::decode(SkStream* stream, SkBitmap* bm, SkColorType pref,
                                              Mode mode) {
    // we reset this to false before calling onDecode
    fShouldCancelDecode = false;
    // assign this, for use by getPrefColorType(), in case fUsePrefTable is false
    fDefaultPref = pref;

    // pass a temporary bitmap, so that if we return false, we are assured of
    // leaving the caller's bitmap untouched.
    SkBitmap tmp;
    const Result result = this->onDecode(stream, &tmp, mode);
    if (kFailure != result) {
        bm->swap(tmp);
    }
    return result;
}

这里调用 onDecode 函数,onDecode是一个模板方法,实际上调用子类 SkPNGImageDecoder onDecode:

// SkPNGImageDecoder
SkImageDecoder::Result SkPNGImageDecoder::onDecode(SkStream* sk_stream, SkBitmap* decodedBitmap, Mode mode) {
    //...

    if (!this->allocPixelRef(decodedBitmap,
  kIndex_8_SkColorType == colorType ? colorTable : NULL)) {
        return kFailure;
    }

    //...
}

这里使用的就是 JavaAllocator。和 10.0 的代码一样,我们先看 createBitmap 之后的逻辑。也会调用 Java Bitmap 的构造方法:

Bitmap(long nativeBitmap, byte[] buffer, int width, int height, int density,
            boolean isMutable, boolean requestPremultiplied,
            byte[] ninePatchChunk, NinePatch.InsetStruct ninePatchInsets)

和 Android 10 相比,这里多传入了一个 byte 数组叫buffer:

/**
* Backing buffer for the Bitmap.
*/

private byte[] mBuffer;

mBuffer = buffer;
mNativePtr = nativeBitmap;

这里的 mBuffer 就存储了 Bitmap 的像素内容,所以在 Android6 上对象间关系是这样:

接下来在 allocateJavaPixelRef里面看一下具体的内存分配流程:

android::Bitmap* GraphicsJNI::allocateJavaPixelRef(JNIEnv* env, SkBitmap* bitmap,
                                             SkColorTable* ctable) 
{
 // 省略...

    jbyteArray arrayObj = (jbyteArray) env->CallObjectMethod(gVMRuntime,
                                                             gVMRuntime_newNonMovableArray,
                                                             gByte_class, size);
    jbyte* addr = (jbyte*) env->CallLongMethod(gVMRuntime, gVMRuntime_addressOf, arrayObj);
    
 android::Bitmap* wrapper = new android::Bitmap(env, arrayObj, (void*) addr,
            info, rowBytes, ctable);
    wrapper->getSkBitmap(bitmap);
    bitmap->lockPixels();
    return wrapper;
}

这里 byte 数组是通过 VMRuntime newNonMovableArray分配的,然后通过 addressOf把地址传递给 android::Bitmap


3Bitmap内存释放


现在我们继续看一下 Bitmap 的内存释放机制。Bitmap 在 Java 层提供了 recycle方法来释放内存。我们同样也通过 Android 10 和 Android 6的源码进行分析。

8.0以上

Android 8以上的 recycle 方法逻辑如下:

public void recycle() {
    if (!mRecycled) {
        nativeRecycle(mNativePtr);
        mNinePatchChunk = null;
        mRecycled = true;
    }
}

这里直接调了 native 层的 nativeRecycle 方法,传入的是 mNativePtr,即 native 层 BitmapWrapper指针。nativeRecycle的代码如下:

static void Bitmap_recycle(JNIEnv* env, jobject, jlong bitmapHandle) {
    LocalScopedBitmap bitmap(bitmapHandle);
    bitmap->freePixels();
}

这里调了 LocalScopedBitmapfreePixelsLocalScopeBitmap则是代理了 BitmapWrapper这个类。

void freePixels() {
 mInfo = mBitmap->info();
 mHasHardwareMipMap = mBitmap->hasHardwareMipMap();
 mAllocationSize = mBitmap->getAllocationByteCount();
 mRowBytes = mBitmap->rowBytes();
 mGenerationId = mBitmap->getGenerationID();
 mIsHardware = mBitmap->isHardware();
 mBitmap.reset();
}

最后会调用 bitmap 指针的 reset, 那么最后会执行 Bitmap 的析构函数:

// hwui/Bitmap.cpp
Bitmap::~Bitmap() {
    switch (mPixelStorageType) {
        case PixelStorageType::Heap:
            free(mPixelStorage.heap.address);
         break;
        // 省略...
    }
}

这里释放了图片的内存数据。但是如果没有手动调用  recycle ,  Bitmap 会释放内存吗,其实也是会的。这里要从 Java 层的 Bitmap 说起。在 Bitmap 的构造方法里,有如下代码:

NativeAllocationRegistry registry;
registry = NativeAllocationRegistry.createMalloced(
 Bitmap.class.getClassLoader(), nativeGetNativeFinalizer(), allocationByteCount);
registry.registerNativeAllocation(this, nativeBitmap);

这样,当Bitmap被Android虚拟机回收的时候,会自动调用 nativeGetNativeFinalizer。关于 NativeAllocationRegistry的细节,我们不做深入讨论。

// nativeGetNativeFinalizer
static void Bitmap_destruct(BitmapWrapper* bitmap) {
    delete bitmap;
}

static jlong Bitmap_getNativeFinalizer(JNIEnv*, jobject) {
    return static_cast<jlong>(reinterpret_cast<uintptr_t>(&Bitmap_destruct));
}

这里会调用 bitmap 的 delete,自然也会调 Bitmap 的析构函数,清理图片的像素内存。我们把 8 以上的 Bitmap 内存回收整理一个结构图:

6.0

分析完 Android 10 的代码,我们继续了解下 8 以下是怎么回收 Bitmap 的。同样先看 recycle:

public void recycle() {
    if (!mRecycled && mFinalizer.mNativeBitmap != 0) {
        if (nativeRecycle(mFinalizer.mNativeBitmap)) {
            mBuffer = null;
            mNinePatchChunk = null;
        }
        mRecycled = true;
    }
}

nativeRecycle 里面调用 android/graphics/Bitmap.cppBitmap_recycle方法,这里的逻辑和 8 以上是一样的。只是这里传入的 bitmapHandle是:

mFinalizer.mNativeBitmap

这里也是在 Bitmap 创建的时候把 native 的 Bitmap 传给了 BitmapFinalizer对象。继续看 Bitmap#freePixels:

void Bitmap::freePixels() {
    AutoMutex _lock(mLock);
    if (mPinnedRefCount == 0) {
        doFreePixels();
        mPixelStorageType = PixelStorageType::Invalid;
    }
}

这里的 doFreePixels 也和 8 以上类似,不过走的是 PixelStorageType::Java 的分支:

// 省略其他代码...
case PixelStorageType::Java:
 JNIEnv* env = jniEnv();
 env->DeleteWeakGlobalRef(mPixelStorage.java.jweakRef);
 break;

这里会把 jweakRef 给回收。这个引用指向的的就是存储了图片像素数据的 Java byte 数组。在 8 以下没有 NativeAllocationRegistry的时候,会依赖 Java 对象的 finalize进行内存回收。

@Override
public void finalize() 
{
    try {
        super.finalize();
 } catch (Throwable t) {
  // Ignore
 } finally {
  setNativeAllocationByteCount(0);
  nativeDestructor(mNativeBitmap);
  mNativeBitmap = 0;
 }
}

这里会调用 nativeDestructor,即 Bitmap_destructor:

static void Bitmap_destructor(JNIEnv* env, jobject, jlong bitmapHandle) {
    LocalScopedBitmap bitmap(bitmapHandle);
    bitmap->detachFromJava();
}

void Bitmap::detachFromJava() {
    bool disposeSelf;
    {
        android::AutoMutex _lock(mLock);
        mAttachedToJava = false;
        disposeSelf = shouldDisposeSelfLocked();
    }
    if (disposeSelf) {
        delete this;
    }
}

这里最后会调用 delete this,即调用 Bitmap 的析构函数:

Bitmap::~Bitmap() {
    doFreePixels();
}

这里和 recycle一样,最后也会通过 doFreePixels 一样回收图片像素内存。整理流程如下:

4总结


阅读到这里,我们总结几个有用的结论:

• Android Bitmap 内存占用和图片的尺寸,质量强相关,日常治理大图的时候要对这些参数适当做降级方案。
• Android8以下图片分配在 Java 堆内,容易 OOM,可以通过一些 hook 方案把内存移到堆外。并且虽然 Bitmap 有自己兜底的内存释放机制,但是主动及时调用 recycle也不是坏事。
• Android8 以上虽然 Bitmap 内存分配在 native 部分,可以避免 Java 层的 OOM,但是虚拟内存不足的 OOM 还是可能会引发的,所以大图还是需要治理的。


最后推荐一下我做的网站,玩Android: wanandroid.com ,包含详尽的知识体系、好用的工具,还有本公众号文章合集,欢迎体验和收藏!


推荐阅读

如何秒开WebView?Android性能优化全攻略!
Kotlin委托的原理与使用,在Android中常用的几个场景
Android上下文Context,学有所得


扫一扫 关注我的公众号

如果你想要跟大家分享你的文章,欢迎投稿~


┏(^0^)┛明天见!

继续滑动看下一个
鸿洋
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存