图片加载和Bitmap的内存优化

图片加载

在客户端开发中,图片加载和显示,是非常常见的功能了。常见的图片获取途径有网络传输,本地文件获取和资源加载。Android中用来显示图片的控件,除了一般的可设置背景的组件外,主要就是ImageView。
通过查看ImageView的源代码,可以大致了解图片加载的过程

public void setImageBitmap(Bitmap bm) {
    // Hacky fix to force setImageDrawable to do a full setImageDrawable
    // instead of doing an object reference comparison
    mDrawable = null;
    if (mRecycleableBitmapDrawable == null) {
        mRecycleableBitmapDrawable = new BitmapDrawable(mContext.getResources(), bm);
    } else {
        mRecycleableBitmapDrawable.setBitmap(bm);
    }
    setImageDrawable(mRecycleableBitmapDrawable);
}
public void setImageDrawable(@Nullable Drawable drawable) {
        ......
        updateDrawable(drawable);

        if (oldWidth != mDrawableWidth || oldHeight != mDrawableHeight) {
            requestLayout();
        }
        invalidate();
    }
}
public void setImageURI(@Nullable Uri uri) {
        ......
        resolveUri();
        if (oldWidth != mDrawableWidth || oldHeight != mDrawableHeight) {
            requestLayout();
        }
        invalidate();
    }
}
public void setImageResource(@DrawableRes int resId) {
    .....

    resolveUri();

    if (oldWidth != mDrawableWidth || oldHeight != mDrawableHeight) {
        requestLayout();
    }
    invalidate();
}
private void resolveUri() {
    ......
    Drawable d = null;
    if (mResource != 0) {
        try {
            d = mContext.getDrawable(mResource);
        } catch (Exception e) {
            Log.w(LOG_TAG, "Unable to find resource: " + mResource, e);
            // Don't try again.
            mResource = 0;
        }
    } else if (mUri != null) {
        d = getDrawableFromUri(mUri);

        if (d == null) {
            Log.w(LOG_TAG, "resolveUri failed on bad bitmap uri: " + mUri);
            // Don't try again.
            mUri = null;
        }
    } else {
        return;
    }
    updateDrawable(d);
}
private void updateDrawable(Drawable d) {
       .......
        mDrawable = d;

        if (d != null) {
            d.setCallback(this);
            d.setLayoutDirection(getLayoutDirection());
            if (d.isStateful()) {
                d.setState(getDrawableState());
            }
            if (!sameDrawable || sCompatDrawableVisibilityDispatch) {
                final boolean visible = sCompatDrawableVisibilityDispatch
                        ? getVisibility() == VISIBLE
                        : isAttachedToWindow() && getWindowVisibility() == VISIBLE && isShown();
                d.setVisible(visible, true);
            }
            d.setLevel(mLevel);
            mDrawableWidth = d.getIntrinsicWidth();
            mDrawableHeight = d.getIntrinsicHeight();
            applyImageTint();
            applyColorMod();

            configureBounds();
        } else {
            mDrawableWidth = mDrawableHeight = -1;
        }
    }

可以看到IamgeView的setImage相关的方法加载图片的过程大致是这样
1.根据图片路径(资源目录或者文件路径)或者Bitmap对象,生成一个Drawable对象
2.然后调用updateDrawable()方法,设置Drawable对象的宽高
3.执行requestLayout()方法重新布局View
4.执行invalidate()重新绘制ImageView

这里值一提的是,setImageUri()方法加载网络图片,只能用来加载本地图片文件。加载网络图片,应该先下载图片,将其转换成bitmap,再用setImageBitmap显示。

类似的,其他控件设置背景图片的加载过程也大致是这样。

BItmap的内存占用分析

上面提到了加载网络图片,需要先下载图片,转换成Bitmap对象。在实际开发中,因为本地文件和资源目录的图片都不能灵活的应对各种变化,加载显示网络图片的场景,越来越多。而Bitmap的缓存和内存优化就是图片加载优化过程中的一个关键点。先看来来Bitmap内存占用的计算方式。
Bitmap作为位图,需要读入图片在每个像素点上的数据,其主要占据内存的地方,也就是这些像素数据。一张图片像素数据的总大小为,图片的像素大小 * 每个像素点的字节大小,通常你就可以把这个值理解为Bitmap对象所占内存的大小。而图片的像素大小为横向像素值 * 纵向像素值。所以就有了下面这个公式:

Bitmap内存 ≈ 像素数据总大小 = 横向像素值 * 纵向像素值 * 每个像素的内存

单个像素的字节大小

它取决于Bitmap类表示图片质量的参数Config值。Bitmap.Config是一个枚举类,它定义了Bitmap支持的图片色彩质量的类型:

Config占用内存(byte)说明
ALPHA_81单透明通道
RGB_5652简易RGB色调
ARGB_44444已废弃
ARGB_8888424位真彩色
RGBA_F168Android8.0新增(更丰富的色彩表现HDR)
HARDWARESpecialAndroid 8.0 新增 (Bitmap直接存储在graphic memory)

通常,BitmapFactory解析图片生成的Bitmap对象,默认的配置是ARGB_8888。

以分辨率为1280 * 960,大小约4.9M的图片为例,分析下Bitmap对象的内存占用情况。
图片在res/drawable目录下,将它加载到320dp * 240dp的ImageView。

Bitmap bitmapDecode = BitmapFactory.decodeResource(getResources(), resId);
imageView.setImageBitmap(bitmapDecode);

执行程序后,打印出了Bitmap对象的宽高、内存大小以及色彩类型:

《图片加载和Bitmap的内存优化》 image.png

首先,从数据上可以验证:44236800 = 3840 * 2880 * 4。
然后,来解释为什么width=3840,height=2880。
带着这个问题,我们需要来看看BitmapFactory的decode过程

private static native Bitmap nativeDecodeStream(InputStream is, byte[] storage,
        Rect padding, Options opts);
private static native Bitmap nativeDecodeFileDescriptor(FileDescriptor fd,
        Rect padding, Options opts);
private static native Bitmap nativeDecodeAsset(long nativeAsset, Rect padding, Options opts);
private static native Bitmap nativeDecodeByteArray(byte[] data, int offset,
        int length, Options opts);
private static native boolean nativeIsSeekable(FileDescriptor fd);

查看相关源代码,不难发现,真正解析生成Bitmap对象,是在native方法中完成的。为此,我们需要追踪到BitmapFactory.cpp#nativeDecodeXXX方法,我们只看相关的部分:

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;
    }
}
...
int scaledWidth = decoded->width();
int scaledHeight = decoded->height();

if (willScale && mode != SkImageDecoder::kDecodeBounds_Mode) {
    scaledWidth = int(scaledWidth * scale + 0.5f);
    scaledHeight = int(scaledHeight * scale + 0.5f);
}
...
if (willScale) {
    const float sx = scaledWidth / float(decoded->width());
    const float sy = scaledHeight / float(decoded->height());
    bitmap->setConfig(decoded->getConfig(), scaledWidth, scaledHeight);
    bitmap->allocPixels(&javaAllocator, NULL);
    bitmap->eraseColor(0);
    SkPaint paint;
    paint.setFilterBitmap(true);
    SkCanvas canvas(*bitmap);
    canvas.scale(sx, sy);
    canvas.drawBitmap(*decoded, 0.0f, 0.0f, &paint);
}

从代码中,我们可以看到,Bitmap最终是通过canvas绘制出来。但是绘制之前会有一个缩放(scale)过程。

scale = (float) targetDensity / density;

这一行代码说明,缩放的倍率由targetDensity和density决定。

  • targetDensity,一般对应于设备屏幕的像素密度
  • density,一般对应于bitmap对象的像素密度。如果图片放在资源目录下,density就是该资源目录对应的像素密度。比如文件在drawable-mdpi目录下,对应的density为1。文件在drawable-hdpi目录下,对应的density为1.5。 同一张图片放置在不同目录下density会有不同的值:

    《图片加载和Bitmap的内存优化》 image.png

    具体来讲,Bitmap对象的内存大小是和图片所在资源目录的density成正比,和设备屏幕的targetDensity成正比。回到上面那个例子,图片放在res/drawable目录下,它是默认的drawable目录,对应的像素密度(density)为1,也就是density是1。设备屏幕的像素密度(targetDensity)是3。所以scale等于3。这就是1280 * 960的图片,经过decode以后,width为3840,height为2880的原因。

targetDensity和density这两个参数都是从options中获取到的。而这个options就对应于BitmapFactory的Options配置。

用过BitmapFactory类的,肯定都对这个Options配置不会陌生。它包含几个常用的属性:

  • inDensity,The pixel density to use for the bitmap. bitmap对象自身的像素密度
  • inTargetDensity,The pixel density of the destination this bitmap will be drawn to.图片绘制的目标区域的像素密度,一般可以理解为设备屏幕的像素密度。
  • inScreenDensity,The pixel density of the actual screen that is being used。设备屏幕的像素密度。
  • inSampleSize,可以理解为采样率。它的值表示decode操作时,width和height缩小的倍数。默认是1,它的值只能是2的N次方,并且大于1。
  • inJustDecodeBounds,这个属性如果为true,表示当前的这次decode操作,不会生成Bitmap对象,而是仅仅读取图片的尺寸和类型信息。
    inDensity和图片存放的资源目录有关。inTargetDensity和inScreenDensity一般来说,很少手动去赋值。默认情况下,这俩都是和设备屏幕的像素密度保持一致。
    以下是在同一台设备上,图片放在不同资源文件目录(mdpi、hdpi、xhdpi、xxhdpi)下加载的Bitmap对象参数:

    《图片加载和Bitmap的内存优化》 image.png

    通过以上的执行结果,可以得出这样几个结论:

  • 在同一台设备上,图片所在资源目录的dpi越大,生成的bitmap尺寸越小
  • 设备屏幕的像素密度越大,生成的bitmap尺寸越大
  • res/drawable目录对应的density值和res/drawable-mdpi目录一样,等于1,dpi值为160。
  • 资源目录的像素密度与设备相同的图片,生成的bitmap不会缩放,尺寸是原始大小。
    因此,之前的bitmap内存的计算公式可以演化成:

bitmap内存 ≈ 像素数据总大小 = 图片的像素宽 * 图片的像素高 * (设备屏幕的像素密度/bitmap的像素密度)^2 * 每个像素的内存

以举例的图片来说就是 44236800 = 1280 * 960 *(480/160) ^2 * 4

Bitmap的内存优化
从上面的公式,不难看出,Bitmap的内存优化,主要有三种方式:

  • 加载Bitmap时,选择低色彩的质量参数(Bitmap.Config),如RGB_5665,这样相比默认的ARGB_8888,占用内存缩小一半。
  • 将图片放在合理的资源目录下,尽可能保持和屏幕密度一致。但也不要全都放在最高密度的资源目录下,资源目录的像素密度高于屏幕密度,加载的Bitmap尺寸会小于原始尺寸,甚至小于显示区域的尺寸,就会导致图片被拉伸,这也不能满足有些需求。
  • 根据目标控件的尺寸,在加载图片时,对bitmap的尺寸进行缩放。比如在像素密度为480dpi的屏幕上,width为300dp,height为200dp的ImageView,能显示的无缩放的图片分辨率为900*600,如果图片分辨率大于这个尺寸,解析时就要考虑按比例缩小。

第一种方式,BitmapFactory.Options配置默认的色彩质量参数是ARGB_8888,每个像素占4个字节。而RGB_565每个像素占2个字节。适用于对色彩多样性要求比较低的场景。
第二种方式,在实际开发当中,将图片放置在合理的资源目录下。不能简单的放在res/drawable目录下,也最好不要以为地放在最高密度的drawable-xxxhdpi目录下。需要结合app的实际使用场景,比如通过统计得出,装机量占比中,以480dpi的屏幕密度为主的话,可考虑将原始图片放在drawable-xxhdpi的资源目录下,其他资源目录下放置的图片,根据density比例缩放。如drawable-xhdpi目录放置原始宽高2/3的图片。这样,图片在各个分辨率的屏幕上显示的尺寸和内存占用的情况,基本一致。
第三种方式,主要涉及到BitmapFactory解析Bitmap的优化处理。简单来说就是灵活使用inJustDecodeBounds和inSampleSize属性。下面介绍下其具体步骤:

  1. 将BitmapFactory.Options的inJustDecodeBounds属性设为true,加载图片。
  2. 从BitmapFactory.Options中取出图片的尺寸信息,对应于outWidth和outHeight属性。
  3. 根据采样率的取值规则(2的N次方),结合目标控件的尺寸大小,算出采样率inSampleSize的值。
  4. 将BitmapFactory.Options的inJustDecodeBounds属性设为false,重新加载图片,获取到bitmap对象。

值得注意的是,这种方式在解析FIleInputStream的缩放时存在问题,原因是FileInputStream是一种有序的文件流,两次decodeStream调用会影响文件流的位置属性,导致第二次调用decodeStream得到的是null。解决这个问题的方法就是,可以通过FIleInputStream得到对应FileDescriptor,然后调用BitmapFactory.decodeFileDescriptor方法来加载缩放后的图片。

本文参考:

https://blog.csdn.net/qq1263292336/article/details/78867461

https://blog.csdn.net/hoyouly/article/details/52839015

https://my.oschina.net/rengwuxian/blog/182885

https://www.jianshu.com/p/3f6f6e4f1c88

    原文作者:zackyG
    原文地址: https://www.jianshu.com/p/756817f12fd5
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞