Android 说说Bitmap那些事

2023-01-08 18:16:44

前言

过了一个年,发现自己懈怠,没怎么去写博客了,项目中遇到的问题也很想把它写出来,但是都没有付诸行动,最近重构完项目的一些烂代码,闲下来时也是时候把项目中遇到的问题分享给大家。

好了,唠叨说完,今天主要说下图片压缩那些事,在Android开发中,我们无可避免地都会和图片打交道,其中图片压缩就是我们比较常见和棘手的问题,处理过程中需要注意失真和内存的问题:图片马赛克了,业务或测试就找上门了;Android大量位图(Bitmap)加载导致内存溢出。现在我们谈谈Bitmap一些基本概念。


Bitmap存储格式

Android加载图片的对象就是我们老生常谈的Bitmap了,Bitmap是位图,是由像素点组成的,那它是如何存储在内存中的呢?Bitmap像素点有几种存储方式,对应Bitmap.Config中的枚举值:

  • ALPHA_8:
    只保存透明度,不保存颜色。每个像素存储为单个半透明(alpha)通道,1个像素点占1个字节,不常用。创建此类型图后,无法在上面画颜色。
  • RGB_565:
    只存储色值,不存储透明度(不支持alpha通道),默认不透明,RGB分别占5、6、5位,一个像素点占用16位2个字节。当使用不需要高色彩保真度的不透明位图时,此配置比较适合。
  • ARGB_4444:
    ARGB各用4位存储,1个像素点16位占2个字节。此类型的图片配置导致画质质量较差,建议使用ARGB_8888。
  • ARGB_8888:
    ARGB各用8位存储,1个像素点32位占4个字节。每个通道(RGB和alpha为半透明)以8位精度(256个可能值)存储,配置灵活,图片也很清晰,应尽可能使用此种方式,缺点比较占内存。
  • RGBA_F16:
    每个通道(RGB和半透明的alpha)存储为半精度浮点值。每个像素存储在8个字节上。它非常适合用于广色域宽屏和HDR(高动态范围的图片),它所占用的内存是最高的,因此显示的效果也非常好(API26以上才能用)。
  • HARDWARE:
    硬件位图,其像素数据是存储在显存中,并对图片仅在屏幕上绘制的场景做了优化。简而言之,它把图片的内存只在GPU上存一份,而不需要应用本身的副本,这样的话,理论上通过Hardware方式加载一张图片,内存占用可以比原来少一半,因为像素数据是在显存中的,在有些场景下访问像素数据会发生异常,详见硬件位图

Bitmap内存计算方法

前面我们讲了Bitmap的几种方式及其像素点所占字节,那一张图片Bitmap在内存中的大小是多少呢?这里我们以像素为500*313的jpg格式图片为例:
在这里插入图片描述在这里插入图片描述
这里我们看到了文件的大小34.1KB,那么将这张图片加载到内存中大小是多少呢?Android的BitmapFactory给我们提供了几种加载图片的方式:

  • BitmapFactory.decodeResource():从资源文件中通过id加载bitmap
  • BitmapFactory.decodeFile():传入文件路径加载,比如加载sd卡中的文件
  • BitmapFactory.decodeStream():从输入流中加载图片
  • BitmapFactory.decodeByteArray():从byte数组中加载图片

它们有很多重载函数,具体可去看源码,Bitmap对象的创建过程就不说了,网上也有很多介绍,现在我们一般都会将图片资源放到drawable-xxhdpi目录下,然后调用decodeResource()加载Bitmap对象,根据网上相关资料,采用ARGB_8888(一个像素点32B,即4字节)格式存储的这张图片内存大小(非文件存储大小) = 原宽×原高×像素点所占字节数:

 500* 313* (32/8)B = 626000B = 0.6MB
        Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.test);
        Log.e(TAG, "原始大小: " + SampleUtils.getBitmapSize(bitmap));

        BitmapFactory.Options options1 = new BitmapFactory.Options();
        options1.inPreferredConfig = Bitmap.Config.RGB_565;
//                options1.inDensity = 160;
//                options1.inScaled = false;
        Bitmap bitmap1 = BitmapFactory.decodeResource(getResources(), R.drawable.test, options1);
        Log.e(TAG, "RGB_565: " + SampleUtils.getBitmapSize(bitmap1)
                + "  inTargetDensity=" + bitmap1.getDensity()
                + "  width=" + bitmap1.getWidth()
                + "  height=" + bitmap1.getHeight()
                + "  totalSize=" + bitmap1.getWidth() * bitmap1.getHeight() * 2);

        BitmapFactory.Options options2 = new BitmapFactory.Options();
        options2.inPreferredConfig = Bitmap.Config.ARGB_8888;
//                options2.inDensity = 160;
//                options2.inScaled = false;
        Bitmap bitmap2 = BitmapFactory.decodeResource(getResources(), R.drawable.test, options2);
        Log.e(TAG, "ARGB_8888: " + SampleUtils.getBitmapSize(bitmap2)
                + "  inTargetDensity=" + bitmap2.getDensity()
                + "  width=" + bitmap2.getWidth() +
                "    height=" + bitmap2.getHeight()
                + "  totalSize=" + bitmap2.getWidth() * bitmap2.getHeight() * 4);

获取Bitmap大小:


    /**
     * 得到bitmap的大小
     */
    public static int getBitmapSize(Bitmap bitmap) {
        //API 19
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
            return bitmap.getAllocationByteCount();
        }
        //API 12
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB_MR1) {
            return bitmap.getByteCount();
        }
        //在低版本中用一行的字节x高度
        return bitmap.getRowBytes() * bitmap.getHeight();
    }

上面打印出来的log日志如下:
在这里插入图片描述
从日志中推断出

  • BitmapFactory.Options.inPreferredConfig默认参数应该是Bitmap.Config.ARGB_8888,一般情况下,这个参数就是我们设置的需要存储像素的格式,所以是可以通过设置它的参数来减少内存,但是也会有不符合配置的情况,详见Android inpreferredconfig参数分析
  • 一般情况下,图片内存大小 = 原宽×原高×像素所占字节数

本着严谨的态度,我们再做一个测试,将图片放到drawable目录下,打印日志结果:
在这里插入图片描述
将上面两幅图整理比较,在dpi为480的设备下,加载分辨率为500*313的jpg格式图片:

存储格式 drawable 目录 width(px) height(px) 像素点字节数(B) 内存大小
RGB_565 drawable 1500 939 2 2817000(2.68MB)
RGB_565 drawable-xxhdpi 500 313 2 313000(0.30MB)
ARGB_8888 drawable 1500 939 4 5634000(5.37MB)
ARGB_8888 drawable-xxhdpi 500 313 4 626000(0.60)

看到这里,在同种存储格式,不同的drawable目录下,图片的分辨率(宽*高)不一样,drawable目录下的图片加载到内存中宽和高都变为原来的3倍,分辨率变成原来的9倍,所以内存也就变成原来的9倍,由此我们可以猜测图片内存大小与图片存放drawable目录有关,通过查看 decodeResource() 源码,如果没有Options参数传入会生成默认的,最终调用 decodeResourceStream() 方法:

    /**
     * Decode a new Bitmap from an InputStream. This InputStream was obtained from
     * resources, which we pass to be able to scale the bitmap accordingly.
     */
    public static Bitmap decodeResourceStream(Resources res, TypedValue value,
            InputStream is, Rect pad, Options opts) {
 
        if (opts == null) {
            opts = new Options();
        }
 
        if (opts.inDensity == 0 && value != null) {
        	//可理解为图片放置在drawable对应的dpi
            final int density = value.density;
            if (density == TypedValue.DENSITY_DEFAULT) {
                opts.inDensity = DisplayMetrics.DENSITY_DEFAULT;
            } else if (density != TypedValue.DENSITY_NONE) {
                opts.inDensity = density;
            }
        }
        
        if (opts.inTargetDensity == 0 && res != null) {
        	//手机的dpi
            opts.inTargetDensity = res.getDisplayMetrics().densityDpi;
        }
        
        return decodeStream(is, pad, opts);
    }

这里说明 decodeResourceStream() 内部做了对Bitmap的密度适配,然后再调用 decodeStream(),Bitmap的decode过程实际上是在native层完成的,跟踪到BitmapFactory.cpp#nativeDecodeStream(),相关缩放代码如下:

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);
}

一目了然了,缩放值scale的由来:

scale = (float) targetDensity / density;

这里列出不同drawable目录所对应的设备dpi值:

不同目录 drawable drawable-ldpi drawable-mdpi drawable-hdpi drawable-xhdpi drawable-xxhdpi
对应设备的dpi 160 120 160 240 320 480

结合上面两个表格和源码分析,初步得出Android在加载res目录下的资源图片时,会根据不同drawable目录下的图片做一次分辨率转换,转换规则:

  • 新图的高度 = 原图高度 * (设备的 dpi / 目录对应的 dpi )
  • 新图的宽度 = 原图宽度 * (设备的 dpi / 目录对应的 dpi )

按照我们初步的理论,将表格中存储格式为RGB_565两行数据代入:

  • drawable对应160dpi,新图高度:1500 = 500 * (480/160) ;新图宽度: 939 = 313 * (480/160)。
  • drawable-xxhdpi对应480dpi,新图高度:500 = 500 * (480/480) ;新图宽度: 313 = 313 * (480/480)。

如果想要进一步验证该结论,可采用dpi为240的测试机进行控制变量的实验,这里就不做比较了,所以我们说前面图片:

内存大小 = 原宽×原高×像素点所占字节数

是不太对的,它还与设备的 dpi 和不同的资源目录有关,具体体现在位于res不同资源目录中的图片,当加载进内存时,会先经过一次分辨率的转换,然后再计算大小。 最终计算方式:

Bitmap内存占用 ≈ 原宽 × 原高× (设备dpi/资源目录对应dpi)^2 × 像素点所占字节数

既然分析了res中图片加载到内存的计算方法,那其它资源图片加载到内存中是不是同样的计算方法呢?现在我们不妨来分析下 decodeFile() 方法,其源码如下:

    public static Bitmap decodeFile(String pathName, Options opts) {
        validate(opts);
        Bitmap bm = null;
        InputStream stream = null;
        try {
            stream = new FileInputStream(pathName);
            bm = decodeStream(stream, null, opts);
        } catch (Exception e) {
            Log.e("BitmapFactory", "Unable to decode stream: " + e);
        } finally {
            if (stream != null) {
                try {
                    stream.close();
                } catch (IOException e) {
                }
            }
        }
        return bm;

    }

内部根据文件路径创建FileInputStream,最终和 decodeResource() 方法一样调用 decodeStream() 方法进解码图片,不同之处在于并没有进行分辨率的转换,所以图片内存大小的计算方法应该为我们最初的公式:

内存大小 = 原宽×原高×像素点所占字节数

下面测试一下(先申请存储权限):

        File file = new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), "/TestPath/test.jpg");
        BitmapFactory.Options options3 = new BitmapFactory.Options();
        options3.inPreferredConfig = Bitmap.Config.ARGB_8888;
        Log.e(TAG, "ARGB_8888: "
                + SampleUtils.getBitmapSize(bitmap3)
                + "  inTargetDensity=" + bitmap3.getDensity()
                + "  width=" + bitmap3.getWidth() +
                "    height=" + bitmap3.getHeight()
                + "  totalSize=" + bitmap3.getWidth() * bitmap3.getHeight() * 4);

结果如下:
在这里插入图片描述这里只做了一个实验,你也可以用另一张图片重复此操作,结果都是一样的:

内存大小 = 原宽×原高×像素点所占字节数

除此之外,其它方式加载到内存的计算方法也是一样的,如网路资源(本质上也是下载到手机储存)、assert目录、SD卡资源等。所以这里我们得出结论:

只有res内的图片资源会进行分辨率的转换,采用新的分辨率去计算内存大小,而其它资源用的是原图分辨率去计算内存大小

前面长篇大论主要是为了总结这几点:

  • 对于非res目录下的图片资源,如本地文件图片,网络图片等,Bitmap内存占用 ≈ 原宽 × 原高× × 像素点所占字节数
  • 对于res目录下的不同drawable目录下图片资源,Bitmap内存占用 ≈ 原宽 × 原高× (设备dpi/资源目录对应dpi)^2 × 像素点所占字节数

这里,不得不说下BitmapFactory.Options类的一些参数及其意义:

类型 参数 意义
boolean inJustDecodeBounds 如果为true,解码后不会返回Bitmap对象,但Bitmap宽高将返回到options.outWidth与options.outHeight中;反之返回。主要用于只需获取解码后的Bitmap的大小而不会将bitmap载入内存,浪费内存空间。
boolean inMutable 为true,代表返回可变属性的Bitmap,反之不可变
boolean inPreferredConfig 根据指定的Config来进行解码,如:Bitmap.Config.RGB_565等
boolean inSampleSize 如果值大于1,在解码过程中将按比例返回占更小内存的Bitmap。例如值为2,则对宽高进行缩放一半。这个值很有用,大多数图片压缩都有用到。
boolean inScaled 如果为true,且inDesity与inTargetDensity都不为0,那么在加载过程中将会根据inTargetDensityl来缩放,在drawn中不依靠于图片自身的缩放属性。
boolean inDensity Bitmap自身的密度 ,默认为图片所在drawable目录对应的dpi
boolean inTargetDensity Bitmap drawn过程中使用的密度,默认采用当前设备的dpi,同inScreenDensity

图片文件存储格式

上面我们讲了图片加载到内存大小的计算方法,现在我们来看看图片文件存储大小,常见的图片文件几种格式:JPEG(JPG)、PNG、WEBP。

我们可以将它们理解为图片的容器,它们是经过相对应的压缩算法将原图每个像素点信息转换用另一种数据格式表示,以此达到压缩目的,从而减少图片文件大小。

总而言之,这几种格式就是不同的压缩算法,对应Bitmap.CompressFormat:生成的图片使用指定的图片存储格式

  • Bitmap.CompressFormat.JPEG:
    采用JPEG压缩算法,是一种有损压缩格式,会在压缩过程中改变图像原本质量,画质越差,对原来的图片质量损伤越大,但是得到的文件比较小,而且JPEG不支持透明度,当遇到透明度像素时,会以黑色背景填充。
  • Bitmap.CompressFormat.PNG:
    采用PNG算法,是一种支持透明度的无损压缩格式,拥有丰富的颜色显示效果,即使在压缩情况下也能做到不降低图像质量。
  • Bitmap.CompressFormat.WEBP:
    WEBP是一种同时提供了有损压缩和无损压缩的图片文件格式,在14<=api<=17时,WEBP是一种有损压缩格式,而且不支持透明度,在api18以后WEBP是一种无损压缩格式,而且支持透明度,有损压缩时,在质量相同的情况下,WEBP格式的图片体积比JPEG小40%,但是编码时间比JPEG长8倍。在无损压缩时,无损的WEBP图片比PNG压缩小26%,但是WEBP的压缩时间是PNG格式压缩时间的5倍。

更多详细可参考 Bitmap.CompressFormat


图片压缩方法

前面我们讲了如何计算bitmap在内存的大小,例如我们要从网络上下载一张1920*1080分辨率的图片采用ARGB_8888模式显示,那这张图片所占内存大小:

1920*1080*4B = 7.91MB

一张图片竟然要占用这么大的内存,手机内存是有限的,如果我们不加以控制,那加载几十张又会是什么样场景,最后的结果肯定是OOM闪退了,这显然是无法接受的,所以我们更加要小心翼翼地处理这些图片。

根据前面所说的及相关计算方式,Bitmap的内存优化方法主要是图片存放在合适的drawable目录下、采取合适的存储格式去降低像素点所占字节数、降低图片的分辨率以及Bitmap的复用和缓存,即:

  • 将图片存放在合适的drawable目录下
  • 减少每个像素点大小
  • 降低分辨率
  • 复用和缓存

依据中间两点,就有了下面这几种压缩方法

RGB_565压缩

这是通过设置像素点占用的内存大小来达到压缩的效果,一般不建议使用ARGB_4444,因为画质实在是敢看,如果对透明度没有要求,建议可以改成RGB_565,相比ARGB_8888将节省一半的内存开销。

    /**
     * RGB565压缩
     *
     * @param context 上下文
     * @param id      图片资源id
     * @return 压缩后的图片Bitmap
     */
    public static Bitmap compressByRGB565(Context context, int id) {
        BitmapFactory.Options options = new BitmapFactory.Options();
        options.inPreferredConfig = Bitmap.Config.RGB_565;
        return BitmapFactory.decodeResource(context.getResources(), id, options);
    }

质量压缩

保持像素的前提下改变图片的位深及透明度,通过抹除某点附件相近像素,达到降低质量、压缩文件的目的。加载Bitmap的内存并不会减少,文件会变小,用用于服务器上传图片的压缩或保存本地图片文件。

    /**
     * 质量压缩
     *
     * @param bmp     图片位图
     * @param quality 质量参数0-100,100为不压缩,PNG为无损压缩,此参数对Bitmap.CompressFormat.PNG无效
     * @param file    保存压缩后的图片文件
     */
    public static void qualityCompress(Bitmap bmp, int quality, File file) {
        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        // 把压缩后的数据存放到bos中
        bmp.compress(Bitmap.CompressFormat.JPEG, quality, bos);
        try {
            FileOutputStream fos = new FileOutputStream(file);
            fos.write(bos.toByteArray());
            fos.flush();
            fos.close();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

尺寸压缩(缩放压缩)

改变图片的尺寸,即压缩图片宽度和高度的像素点,从上面的计算公式我们可以得知,这样会降低图片bitmap内存的占用,从而一定程度上减少OOM的概率。但这里我们需要注意,如果压缩比太大,也会由于像素点降低导致图片失真严重,最后图片由高清变成了马赛克。


    /**
     * 缩放压缩
     *
     * @param bmp   图片位图
     * @param radio 缩放比例,值越大,图片尺寸越小
     * @param file  保存压缩后的图片文件
     */
    public static void scaleCompress(Bitmap bmp, int radio, File file) {
        //设置缩放比
        Bitmap result = Bitmap.createBitmap(bmp.getWidth() / radio, bmp.getHeight() / radio, Bitmap.Config.ARGB_8888);
        Canvas canvas = new Canvas(result);
        RectF rectF = new RectF(0, 0, bmp.getWidth() * 1.0f / radio, bmp.getHeight() * 1.0f / radio);
        //将原图画在缩放之后的矩形上
        canvas.drawBitmap(bmp, null, rectF, null);
        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        // 把压缩后的数据存放到bos中
        bmp.compress(Bitmap.CompressFormat.JPEG, 100, bos);
        try {
            FileOutputStream fos = new FileOutputStream(file);
            fos.write(bos.toByteArray());
            fos.flush();
            fos.close();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

采样率压缩

其实采样率压缩和尺寸压缩原理是一样的,都是通过减少图片尺寸,压缩图片宽度和高度的像素点,这里充分利用了Options类里的参数设置(可参考上面表格):

  • inSampleSize:采样率,为整数,且为2的n次幂,n可以为0,即采样率为1,处理后的图片尺寸与原图一致。当采样率为2时,即宽、高均为原来的1/2,像素则为原来的1/4,其占有内存也为原来的1/4。当设置的采样率小于1时,其效果与1一样。当设置的inSampleSize大于1,不为2的指数时,系统会向下取一个最接近2的指数的值。
  • inJustDecodeBounds:当设置为true时,BitmapFactory只会解析图片的原始宽/高信息,不会真正的去加载图片,这一设置堪称绝了。
    /**
     * 采样率压缩
     *
     * @param context 上下文
     * @param id      图片资源id
     * @param destW   目标宽大小
     * @param destH   目标高大小
     * @return	压缩后的图片Bitmap
     */
    public static Bitmap sampleSizeCompress(Context context, int id, int destW, int destH) {
        Bitmap bm = null;
        int inSampleSize = 1;
        //第一次采样
        BitmapFactory.Options options = new BitmapFactory.Options();
        //该属性设置为true只会加载图片的宽高、类型信息,并不会加载图片具体的像素点
        options.inJustDecodeBounds = true;
        bm = BitmapFactory.decodeResource(context.getResources(), id, options);
        Log.e(TAG, "sampleSizeCompress--压缩之前图片的宽:" + options.outWidth +
                "--压缩之前图片的高:" + options.outHeight +
                "--压缩之前图片大小:" + options.outWidth * options.outHeight * 4 / 1024 + "kb");
        int iWidth = options.outWidth





							
  • 作者:黄小梁
  • 原文链接:https://blog.csdn.net/HHHceo/article/details/123335085
    更新时间:2023-01-08 18:16:44