图片加载和Bitmap的内存优化

作者: zackyG | 来源:发表于2019-02-23 11:38 被阅读0次

    图片加载

    在客户端开发中,图片加载和显示,是非常常见的功能了。常见的图片获取途径有网络传输,本地文件获取和资源加载。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_8 1 单透明通道
    RGB_565 2 简易RGB色调
    ARGB_4444 4 已废弃
    ARGB_8888 4 24位真彩色
    RGBA_F16 8 Android8.0新增(更丰富的色彩表现HDR)
    HARDWARE Special Android 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对象的宽高、内存大小以及色彩类型:


    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会有不同的值:


      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对象参数:


      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

    相关文章

      网友评论

        本文标题:图片加载和Bitmap的内存优化

        本文链接:https://www.haomeiwen.com/subject/kmcjyqtx.html