Android中Bitmap的深刻探讨总结

因为最近公司对图像这一块作文章比较多,而本身对于Bitmap的认识确实也比较浅显,所以花些功夫研究一下Bitmap的使用以及原理,写下该篇文章记录一下学习过程。html

关于系统Graphics的研究须要搁置一段时间了,缘由是看了老罗的文章,发现本身的表达能力真是相差甚大,为了避免误人子弟,打算熟读老罗的分析后在进行概括总结。java

文章主要围绕着以下几个问题展开分析探讨:android

  1. Bitmap是什么?它跟JPG,PNG,WEBP等有什么区别?
  2. Andorid中的Bitmap使用方式?
  3. Android中Bitmap的内存占用?
  4. Android中Bitmap为何出现OOM的问题?Bitmap的内存管理?
  5. Android中Bitmap的尺寸压缩与质量压缩?

Bitmap的概念以及跟JPG,PNG,WEBP的区别

Bitmap是由像素(Pixel)组成的,像素是位图最小的信息单元,存储在图像栅格中。 每一个像素都具备特定的位置和颜色值。按从左到右、从上到下的顺序来记录图像中每个像素的信息,如:像素在屏幕上的位置、像素的颜色等。位图图像质量是由单位长度内像素的多少来决定的。单位长度内像素越多,分辨率越高,图像的效果越好。位图也称为“位图图像”“点阵图像”“数据图像”“数码图像”。一个像素点能够由1,4,16,24,32bit来表示,像素点的色彩越丰富,天然图像的效果就越好了。c++

上面的介绍引用自百度百科,位图文件(注意是位图文件)的后缀通常是**.bmp或者.dib**。位图概念来自于Windows,是Windows的标准图形文件,咱们在Windows中看到的默认背景图其实就是一张位图文件,有兴趣的朋友能够看看自家Windows电脑的背景图。一个位图存储文件的结构以下所示:git

具体的结构解析就不深刻了,毕竟术业有专攻,咱们只要知道概念便可,详细的能够查阅该篇文章程序员

位图文件不等于位图(Bitmap)github

接下来介绍两个概念:位深以及色深web

  • 色深:表示一个像素点能够有多少种色彩来描述,它的单位是bit,拿位图而言,其支持RGB各8bit,因此说位图的色深为24bit。
  • 位深:位深主要表示存储每一个像素所用的位数,主要用于实际图像文件的存储

下面贴个网上的例子理解一下这两个概念:算法

100像素x100像素的图片, 使用ARGB_8888,因此色深32位,保存时选择位深为24位,则在内存中所占大小为100 x100 x (32 / 8)Byte,而在文件所占大小为** 100 x100 x( 24/ 8 ) x 压缩效率 Byte**。编程

咱们能够写个代码验证看看是不是这样的,直接加载一张图片出来试下看看:

private void testCompress() {

        try {
            File file = new File(getInnerSDCardPath() + File.separator + "001LQK0Czy74OrXDiVLdd&690.jpeg");
            Log.e("compress", "文件大小=" + file.length());
            Bitmap bitmap = BitmapFactory.decodeFile(file.getAbsolutePath());
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

最后加载后的效果以下:

能够看到加载到内存后确实变成了32bit的图像,而加载以前就是24bit。

ok,接下来讲说Bitmap和JPG,PNG,WEBP区别。其实Bitmap通俗意义上讲就是一张图片在内存中表现的完整形式,里面包含的都是像素点,而bmp,jpg,png,webp则是Bitmap在硬盘存储的格式,能够理解成一个压缩包的概念,因此存储下来的文件相比于内存展示的会小不少。

  • JPG:JPG全名是JPEG,是图片的一种格式。JPEG图片以24位颜色存储单个位图。JPEG是与平台无关的格式,支持最高级别的压缩,不过,这种压缩是有损耗的。这里注意JPG不支持透明通道,因此是24位而不是32位
  • PNG:便携式网络图形是一种无损压缩的位图片形格式,其设计目的是试图替代GIF和TIFF文件格式,同时增长一些GIF文件格式所不具有的特性。PNG使用从LZ77派生的无损数据压缩算法,通常应用于JAVA程序、网页或S60程序中,缘由是它压缩比高,生成文件体积小。PNG以32位颜色存储单个位图。
  • WEBP:WebP格式,谷歌(google)开发的一种旨在加快图片加载速度的图片格式。图片压缩体积大约只有JPEG的2/3,并能节省大量的服务器宽带资源和数据空间。Facebook Ebay等知名网站已经开始测试并使用WebP格式。WebP既支持有损压缩也支持无损压缩。相较编码JPEG文件,编码一样质量的WebP文件须要占用更多的计算资源。详细资料能够看下腾讯Bugly团队写的文章:WebP原理和Android支持现状介绍

上面介绍中提到了有损以及无损,这两个的概念以下:

  • 有损压缩。指在压缩文件大小的过程当中,损失了一部分图片的信息,也即下降了图片的质量,而且这种损失是不可逆的,咱们不可能从有一个有损压缩过的图片中恢复出全来的图片。常见的有损压缩手段,是按照必定的算法将临近的像素点进行合并。
  • 无损压缩。只在压缩文件大小的过程当中,图片的质量没有任何损耗。咱们任什么时候候均可以从无损压缩过的图片中恢复出原来的信息。

Android中的Bitmap

在Android中解析获取Bitmap的方式存在于BitmapFactory.java工厂类当中,该类中提供了解析文件,解析流,解析Resource以及解析Asset中图片文件的方式,具体的使用方法以下:

这里对Options参数进行一个说明,Options对象可以支持对图片进行一些预处理的操做,其内部变量以下所示:

public static class Options {
        public Options() {
            inDither = false;
            inScaled = true; //默认容许缩放图像
            inPremultiplied = true;
        }

        public Bitmap inBitmap; //涉及重用Bitmap相关知识

    	//返回的Bitmap是否可变(可操做)
        public boolean inMutable;

		//只获取图片相关参数(如宽高)不加载图片
        public boolean inJustDecodeBounds;

 		//设置采样率
        public int inSampleSize;

   		//Bitmap.Config的四种枚举类型,默认使用Bitmap.Config.ARGB_8888
        public Bitmap.Config inPreferredConfig = Bitmap.Config.ARGB_8888;

       //若是被设置为true(默认值),在图片被显示出来以前各个颜色通道会被事先乘以它的alpha值,若是图片是由系统直接绘制或者是由Canvas绘制,这个值不该该被设置为false,不然会发生RuntimeException
        public boolean inPremultiplied;

      //处理图片抖动,若是设置为true,则若是图像存在抖动,就处理抖动,设置为false则无论抖动问题
        public boolean inDither;

		//原图像的像素密度,跟缩放inScale有关
        public int inDensity;

 		//目标图片像素密度,跟缩放inScale有关
        public int inTargetDensity;
        
		//屏幕像素密度
        public int inScreenDensity;
        
		//是否容许缩放图像
        public boolean inScaled;

      	// 5.0以上的版本标记过期了
        public boolean inPurgeable;

       //// 4.4.4以上版本忽略
        public boolean inInputShareable;


		 //是否支持Android自己处理优化图片,从而加载更高质量的图片
        public boolean inPreferQualityOverSpeed;

      //图片宽度
        public int outWidth;

       //图片高度
        public int outHeight;

        //返回图片mimetype,可能为null
        public String outMimeType;

        //图片解码的临时存储空间,默认值为16K
        public byte[] inTempStorage;
       ..
    }

Bitmap.Config

这里先须要介绍的是Bitmap.Config,有6个值:

ARGB指的是一种色彩模式,里面A表明Alpha,R表示red,G表示green,B表示blue

  • ALPHA_8:表明8位Alpha位图(没有存储任何的色彩信息,每个像素只须要1byte存储);
  • ARGB_4444:表明16位ARGB位图,质量太差,Android不建议使用,建议使用ARGB_8888;
  • ARGB_8888:表明32位ARGB位图,而且能够提供最好质量的图片显示,A,R,G.B各占8bit,。
  • RGB_565:表明16位RGB位图,不存储Alpha值,只用2bytes存储RGB信息,其中R为5bit,G为6bit,而B为5bit。
  • HARDWARE:该模式表示硬件位图,该模式的做用能够查看Glide对他的解释,这里不过多讨论。
  • RGBA_F16:该模式不太特别清楚,有待研究。

上面能够看出RGB_565相比于ARGB_8888来讲,内存占用会减小一半,可是其舍弃了透明度,同时三色值也有部分损失,虽然图片失真度很小。而ALPHA_8使用情景有限,ARGB_4444官方不推荐使用,因此本文研究的着重点就在ARGB_8888以及RGB_565上,当时具体使用策略按需而定,如图片库Glide就是使用RGB_565来减小Bitmap的内存占用。下面咱们从代码的角度验证一下正确性:

原图大小为:宽x高=690x975

File file = new File(getInnerSDCardPath() + File.separator + "001LQK0Czy74OrXDiVLdd&690.jpeg");
            Bitmap bitmap = BitmapFactory.decodeFile(file.getAbsolutePath());
            BitmapFactory.Options options = new BitmapFactory.Options();
            options.inPreferredConfig = Bitmap.Config.RGB_565;
            Bitmap bitmap2 = BitmapFactory.decodeFile(file.getAbsolutePath(), options);
			Log.e("compress", "bitmap1内存占用大小=" + bitmap1.getByteCount()+" bitmap2内存占用大小="+bitmap2.getByteCount());

打印Log的结果以下:

bitmap1内存占用大小=2691000 bitmap2内存占用大小=1345500

能够看到,对于同一张图片而言,RGB_565确实图片内存占用减小了一半,所以在对图片质量要求不是特别高的状况下,如信息流的小图,其实使用该模式是很是不错的。

inBitmap

接下来再看下inBitmap参数,在Android3.0版本后,该参数就在源码中加上了,该参数的意义在于复用当前Bitmap所申请的内存空间,以优化释放旧Bitmap内存以及从新申请Bitmap内存致使的性能损耗。这里讨论的版本为Android4.4.4之后,在该版本之后,使用该参数须要知足以下条件:

  • Bitmap自己可可变的(mutable)
  • 新的Bitmap的内存须要小于等于旧的Bitmap的内存
  • 新申请的bitmap与旧的bitmap必须有相同的解码格式,如:使用了ARGB_8888就不能再使用RGB_565的解码模式了。

知足了上面两个条件,就能够从新复用内存,而不须要额外申请了,具体的使用教程移步Andorid官方教程: Managing Bitmap Memory,这里就不深刻了。

decodeFile(...)的内存占用状况

关于decodeFile(...)方式加载出来的Bitmap本质上是调用decodeStream(...)进行的,上面代码再贴下:

File file = new File(getInnerSDCardPath() + File.separator + "001LQK0Czy74OrXDiVLdd&690.jpeg");
      Bitmap bitmap = BitmapFactory.decodeFile(file.getAbsolutePath());

解码模式用ARGB_8888,最后占用的内存大小是2691000,解析后得到的宽x高=690x975,即2691000=690x975x4,发现加载出来的图片确实是原图大小,那么若是加上Options参数的设置呢,上面分析Options对象的构成,咱们能够发现可能影响内存大小的参数会有inScaled,inScreenDensity,inDensity等等,那么怎么去验证呢?最简单的方法就是看Native源码,因此这里跟踪一下源码,而后在用代码确认一遍。decodeFile(...)最终会调用到以下方法:

private static Bitmap decodeStreamInternal(InputStream is, Rect outPadding, Options opts) {
        byte [] tempStorage = null;
        if (opts != null) tempStorage = opts.inTempStorage;//使用解码临时缓存区,默认为16K
        if (tempStorage == null) tempStorage = new byte[DECODE_BUFFER_SIZE];
        return nativeDecodeStream(is, tempStorage, outPadding, opts);
    }

Native调用在BitmapFactory.cpp中:

static jobject nativeDecodeStream(JNIEnv* env, jobject clazz, jobject is, jbyteArray storage,
        jobject padding, jobject options) {
    return nativeDecodeStreamScaled(env, clazz, is, storage, padding, options, false, 1.0f);
}

static jobject nativeDecodeStreamScaled(JNIEnv* env, jobject clazz, jobject is, jbyteArray storage,
        jobject padding, jobject options, jboolean applyScale, jfloat scale) {
    jobject bitmap = NULL;
	//建立SkStream流
    SkStream* stream = CreateJavaInputStreamAdaptor(env, is, storage, 0);
    if (stream) {
        // for now we don't allow purgeable with java inputstreams
        bitmap = doDecode(env, stream, padding, options, false, false, applyScale, scale);
        stream->unref();
    }
    return bitmap;
}

到这能够看见Skia的影子了,Skia 是 Google 一个底层的图形、图像、动画、 SVG 、文本等多方面的图形库,是 Android 中图形系统的引擎,主要支持Android的2D图像操做,3D天然就是Opengl es了。关于Skia自己我也了解的不是不少,可是这里并不须要用到相关知识,逻辑仍是可以理清,所以咱们继续跟踪doDecode(...)

//4.4w版本代码
static jobject doDecode(JNIEnv* env, SkStreamRewindable* stream, jobject padding,
        jobject options, bool allowPurgeable, bool forcePurgeable = false) {

    int sampleSize = 1;
    //1.解码的模式,主要有两个,一个是kDecodeBounds_Mode,该模式下只返回Bitmap的宽高以及一些Config参数;
    //另一个是kDecodePixels_Mode,返回完整的图片以及相关信息
    SkImageDecoder::Mode mode = SkImageDecoder::kDecodePixels_Mode;
    //Java层Config对应native层的Config,能够看到默认是使用ARGB_8888来处理图片
    SkBitmap::Config prefConfig = SkBitmap::kARGB_8888_Config;

    bool doDither = true;
    bool isMutable = false;
    float scale = 1.0f;
    ////isPurgeable=true
    bool isPurgeable = forcePurgeable || (allowPurgeable && optionsPurgeable(env, options));
    bool preferQualityOverSpeed = false;
    bool requireUnpremultiplied = false;

    jobject javaBitmap = NULL;

    if (options != NULL) {
        sampleSize = env->GetIntField(options, gOptions_sampleSizeFieldID);
        //能够看到这步,若是Java层设置了inJustDecodeBounds,那么使用kDecodeBounds_Mode模式,只获取宽高以及一些信息,而不是去加载图片
        if (optionsJustBounds(env, options)) {
            mode = SkImageDecoder::kDecodeBounds_Mode;
        }

        // initialize these, in case we fail later on
        env->SetIntField(options, gOptions_widthFieldID, -1);
        env->SetIntField(options, gOptions_heightFieldID, -1);
        env->SetObjectField(options, gOptions_mimeFieldID, 0);

        jobject jconfig = env->GetObjectField(options, gOptions_configFieldID);
        prefConfig = GraphicsJNI::getNativeBitmapConfig(env, jconfig);
        isMutable = env->GetBooleanField(options, gOptions_mutableFieldID);
        doDither = env->GetBooleanField(options, gOptions_ditherFieldID);
        preferQualityOverSpeed = env->GetBooleanField(options,
                gOptions_preferQualityOverSpeedFieldID);
        requireUnpremultiplied = !env->GetBooleanField(options, gOptions_premultipliedFieldID);
        //获取可重用的Bitmap,即当前Bitmap设置的inBitmap参数不为空状况下用到
        javaBitmap = env->GetObjectField(options, gOptions_bitmapFieldID);
        //能够设置scale
        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;
            }
        }
    }
    //这里情境下为false
    const bool willScale = scale != 1.0f;
    //这里从新设置了isPurgeable参数,若是不在缩放状况下,那么isPurgeable恒等于false,当前状况下=false
    isPurgeable &= !willScale;
	...
    SkBitmap* outputBitmap = NULL;
    unsigned int existingBufferSize = 0;
    if (javaBitmap != NULL) {
        outputBitmap = (SkBitmap*) env->GetIntField(javaBitmap, gBitmap_nativeBitmapFieldID);
        if (outputBitmap->isImmutable()) {
            ALOGW("Unable to reuse an immutable bitmap as an image decoder target.");
            javaBitmap = NULL;
            outputBitmap = NULL;
        } else {
            existingBufferSize = GraphicsJNI::getBitmapAllocationByteCount(env, javaBitmap);
        }
    }

    SkAutoTDelete<SkBitmap> adb(outputBitmap == NULL ? new SkBitmap : NULL);
    if (outputBitmap == NULL) outputBitmap = adb.get();

    NinePatchPeeker peeker(decoder);
    decoder->setPeeker(&peeker);

    SkImageDecoder::Mode decodeMode = isPurgeable ? SkImageDecoder::kDecodeBounds_Mode : mode;

    JavaPixelAllocator javaAllocator(env);
    RecyclingPixelAllocator recyclingAllocator(outputBitmap->pixelRef(), existingBufferSize);
    ScaleCheckingAllocator scaleCheckingAllocator(scale, existingBufferSize);
    SkBitmap::Allocator* outputAllocator = (javaBitmap != NULL) ?
            (SkBitmap::Allocator*)&recyclingAllocator : (SkBitmap::Allocator*)&javaAllocator;
    if (decodeMode != SkImageDecoder::kDecodeBounds_Mode) {
        if (!willScale) {
            // If the java allocator is being used to allocate the pixel memory, the decoder
            // need not write zeroes, since the memory is initialized to 0.
            decoder->setSkipWritingZeroes(outputAllocator == &javaAllocator);
            decoder->setAllocator(outputAllocator);
        } else if (javaBitmap != NULL) {
            // check for eventual scaled bounds at allocation time, so we don't decode the bitmap
            // only to find the scaled result too large to fit in the allocation
            decoder->setAllocator(&scaleCheckingAllocator);
        }
    }
	...
    if (options != NULL && env->GetBooleanField(options, gOptions_mCancelID)) {
        return nullObjectReturn("gOptions_mCancelID");
    }

    SkBitmap decodingBitmap;
    if (!decoder->decode(stream, &decodingBitmap, prefConfig, decodeMode)) {
        return nullObjectReturn("decoder->decode returned false");
    }
    //获取宽高
    int scaledWidth = decodingBitmap.width();
    int scaledHeight = decodingBitmap.height();

    if (willScale && mode != SkImageDecoder::kDecodeBounds_Mode) {
        scaledWidth = int(scaledWidth * scale + 0.5f);
        scaledHeight = int(scaledHeight * scale + 0.5f);
    }

    // update options (if any)
    if (options != NULL) {
        env->SetIntField(options, gOptions_widthFieldID, scaledWidth);
        env->SetIntField(options, gOptions_heightFieldID, scaledHeight);
        env->SetObjectField(options, gOptions_mimeFieldID,
                getMimeTypeString(env, decoder->getFormat()));
    }

    //inJustDecodeBounds=true则直接返回null,不对图片进行解析加载
    if (mode == SkImageDecoder::kDecodeBounds_Mode) {
        return NULL;
    }

    ...

    if (willScale) {
    //经过画布的方式缩放Bimap
        const float sx = scaledWidth / float(decodingBitmap.width());
        const float sy = scaledHeight / float(decodingBitmap.height());
        SkBitmap::Config config = configForScaledOutput(decodingBitmap.config());
        outputBitmap->setConfig(config, scaledWidth, scaledHeight, 0,
                                decodingBitmap.alphaType());
        if (!outputBitmap->allocPixels(outputAllocator, NULL)) {
            return nullObjectReturn("allocation failed for scaled bitmap");
        }

        // If outputBitmap's pixels are newly allocated by Java, there is no need
        // to erase to 0, since the pixels were initialized to 0.
        if (outputAllocator != &javaAllocator) {
            outputBitmap->eraseColor(0);
        }

        SkPaint paint;
        paint.setFilterLevel(SkPaint::kLow_FilterLevel);

        SkCanvas canvas(*outputBitmap);
        canvas.scale(sx, sy);
        canvas.drawBitmap(decodingBitmap, 0.0f, 0.0f, &paint);
    } else {
        outputBitmap->swap(decodingBitmap);
    }
    ...
    SkPixelRef* pr;
    if (isPurgeable) {
        pr = installPixelRef(outputBitmap, stream, sampleSize, doDither);
    } else {
        // if we get here, we're in kDecodePixels_Mode and will therefore
        // already have a pixelref installed.
        pr = outputBitmap->pixelRef();
    }
    if (pr == NULL) {
        return nullObjectReturn("Got null SkPixelRef");
    }

    if (!isMutable && javaBitmap == NULL) {
        // promise we will never change our pixels (great for sharing and pictures)
        pr->setImmutable();
    }

    // detach bitmap from its autodeleter, since we want to own it now
    adb.detach();
    //若是有重用的Bitmap,则返回
    if (javaBitmap != NULL) {
        bool isPremultiplied = !requireUnpremultiplied;
        GraphicsJNI::reinitBitmap(env, javaBitmap, outputBitmap, isPremultiplied);
        outputBitmap->notifyPixelsChanged();
        // If a java bitmap was passed in for reuse, pass it back
        return javaBitmap;
    }

    int bitmapCreateFlags = 0x0;
    if (isMutable) bitmapCreateFlags |= GraphicsJNI::kBitmapCreateFlag_Mutable;
    if (!requireUnpremultiplied) bitmapCreateFlags |= GraphicsJNI::kBitmapCreateFlag_Premultiplied;

    // 建立新Bitmap
    return GraphicsJNI::createBitmap(env, outputBitmap, javaAllocator.getStorageObj(),
            bitmapCreateFlags, ninePatchChunk, layoutBounds, -1);
}

上面代码中有两个很是重要的参数:willScale,isPurgeable,这两个参数直接或间接影响图片的内存占用以及管理,willScale表示图片是否须要缩放操做,而isPurgeable则表明图片的内存管理方式,不设置对应inDensity,inTargetDensity,inScreenDensity,willScale都是false,不涉及到缩放,因此加载出来的图片就是原图片大小,内存天然也是无变化。而isPurgeable的值在当前条件下则为false,若是为True的话那么会走到installPixelRef(...)方法中

static SkPixelRef* installPixelRef(SkBitmap* bitmap, SkStream* stream,
        int sampleSize, bool ditherImage) {
    SkImageRef* pr;
    // only use ashmem for large images, since mmaps come at a price
    if (bitmap->getSize() >= 32 * 1024) {
        pr = new SkImageRef_ashmem(stream, bitmap->config(), sampleSize);
    } else {
        pr = new SkImageRef_GlobalPool(stream, bitmap->config(), sampleSize);
    }
    pr->setDitherImage(ditherImage);
    bitmap->setPixelRef(pr)->unref();
    pr->isOpaque(bitmap);
    return pr;
}

经过查阅资料可知若是图片大小(占用内存)大于32×1024=32K,那么就使用Ashmem,不然就就放入一个引用池中。若是图片不大,直接放到native层内存中,读取方便且迅速。若是图片过大,放到native层内存也就不合理了,否则图片一多,native层内存很难管理。可是若是使用Ashmem匿名共享内存方式,写入到设备文件中,须要时再读取就能避免很大的内存消耗了。

不过这是针对于5.0如下的版本使用,在5.0及以上的版本被标记为Deprecated,即便inPurgeable=true,也不会再使用Ashmem内存存放图片,而是直接放到了Java Heap中,简而言之就是inPurgeable属性被忽略了(下面在分析decodeResource(...)时候使用6.0版原本分析,能够看到isPurgeable参数已经消失了)。

在查阅相关资料发现Andorid O版本好像针对Bitmap的分配策略又不一样了,详细的能够参考这篇文章,这里我并无查看源码验证,所以仅供参考吧

由于Android系统从5.0开始对Java Heap内存管理作了大幅的优化。和以往不一样的是,对象再也不统一管理和回收,而是在Java Heap中单独开辟了一块区域用来存放大型对象,好比Bitmap这种,同时这块内存区域的垃圾回收机制也是和其它区域彻底分开的,这样就使得OOM的几率大幅下降,并且读取效率更高。因此,用Ashmem来存储图片就彻底没有必要了,况且Ashmem还会致使性能问题。这里咱们到时候看下再处理decodeResource(...)时候的逻辑。

对于经过decodeFile(...)加载Bitmap的流程分析完毕了,总结一下在使用decodeFile(...)的时候,不设置对应inDensity,inTargetDensity,inScreenDensity,系统是不会对Bitmap进行缩放操做,加载的是原图。若是设置了inDensity,inTargetDensity,inScreenDensity,而且知足缩放条件,则走的流程跟decodeResource(...)一致

这里记录一下工做期间遇到的一个问题,经过decodeFile(...) 加载出Bitmap后,再把Bitmap从新保存发现旧图片和新图片大小是不同的:

try {
            File file = new File(Environment.getExternalStorageDirectory() + File.separator + "11.jpeg");
            BitmapFactory.Options options = new BitmapFactory.Options();
            options.inPurgeable = true;
            Bitmap bitmap1 = BitmapFactory.decodeFile(file.getAbsolutePath());
            File comFile = new File(Environment.getExternalStorageDirectory() + File.separator + "11_new.jpeg");
            if (!comFile.exists()) {
                comFile.createNewFile();
            }
            OutputStream stream = new FileOutputStream(comFile);
            bitmap1.compress(Bitmap.CompressFormat.JPEG, 100
                    , stream);
            stream.flush();
            stream.close();
        } catch (Exception e) {

        }

能够发现新的图片莫名其妙增长了100多kb的大小,百思不得其解,这里猜想是不是Android将Bitmap转换成JPG的算法,因此再尝试经过11_new.jpg文件再从新生成一张图片,获得的结果以下:

发现图片大小又变大了??这是为啥,查阅了一下谷歌发现没有对应的答案,这里只能猜想是Android生成图片的算法的缘由吧,暂且作个笔记,往后弄明白了再作回答吧。若是有哪位同行知道的,但愿指点一下迷津蛤。

decodeResource(...)的内存占用状况

上面介绍了经过文件加载图片的状况,在Android中也能够直接加载drawable或者mipmap文件夹下的图片,而经过这种方式加载的图片大小可能不一致,最直观的就是放在drawable-hdpi和drawable-mdpi文件夹下的相同图片,加载出来是两张大小不同的图片。关于各个文件夹的含义这里就不解释了,若是对这些个概念比较模糊,能够查看一下这篇文章,这里就盗用一张图简单看下对应各个drawable文件夹所表明的屏幕密度:

而各个mipmap文件夹中官方意见是存放应用icon(进行内存优化),其余的图片资源仍然存放在drawable文件夹当中,因此在这里就不探讨mipmap文件夹了。

首先咱们能够经过以下代码得到手机屏幕的宽高密度:

float densityDpi = getResources().getDisplayMetrics().densityDpi;

获得的结果是屏幕密度=480,也就是说正常不进行缩放的图片应该放在xxhdpi文件夹下。下面测试一下同一张图片(72x72)放在ldpi,mdpi,hdpi,xhdpi,xxhdpi文件夹下面的内存占用状况:

ldpi中的图片 宽x高=288x288   内存大小=324.0kb
mdpi中的图片 宽x高=216x216   内存大小=182.25kb
hdpi中的图片 宽x高=144x144   内存大小=81.0kb
xhdpi中的图片 宽x高=108x108   内存大小=45.5625kb
xxhdpi中的图片 宽x高=72x72   内存大小=20.25kb

能够看到同一张图片放在不一样的drawable在编程Bitmap后宽高跟内存都变了,只有在xxhdpi中才显示原图,为何会这样呢?Android对于不一样drawable加载的逻辑是这样的:

首先先寻找手机密度匹配的drawable文件夹,这里个人手机匹配的是xxhdpi文件夹,若是没有则先向高密度的文件夹寻找,即xxxdpi,一直寻找到最高密度文件夹,若是依然没有则到drawable-nodpi文件夹找这张图,发现也没有,那么就会去更低密度 的文件夹下面找,依次是drawable-xhdpi -> drawable-hdpi -> drawable-mdpi -> drawable-ldpi的顺序。

若是在比当前屏幕密度高的文件夹中找到了,Android认为这是一张相对于屏幕密度所属文件更大的图,因此要进行缩小,同理若是在相比之下低密度的文件夹中找到了,则须要进行放大操做,缩放因子等于当前屏幕密度所在文件夹的密度除以图片所在文件夹的密度,拿xhdpi举例,缩放比例等于480/320=1.5,因此xhdpi中加载出来的宽高等于108x108。

若是以为讲述不清能够看下郭霖大神这篇博客,讲的很好。

上面是结论,那么天然要在源码中寻找一下立据才符合程序员的个性,这里以图片放在xhdpi文件夹下为前提,在调用decodeResource(...)在Java层会最终调用到以下方法中:

public static Bitmap decodeResourceStream(@Nullable Resources res, @Nullable TypedValue value,
            @Nullable InputStream is, @Nullable Rect pad, @Nullable Options opts) {
        validate(opts);
        if (opts == null) {
            opts = new Options();
        }

        if (opts.inDensity == 0 && value != null) {
            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) {
            opts.inTargetDensity = res.getDisplayMetrics().densityDpi;
        }
        
        return decodeStream(is, pad, opts);
    }

 public static Bitmap decodeStream(@Nullable InputStream is, @Nullable Rect outPadding,
            @Nullable Options opts) {
        ...
                bm = nativeDecodeAsset(asset, outPadding, opts);
           ...
        return bm;
    }

这里会首先肯定图像位于文件夹的密度,即设置opts.inDensity等于xhdpi的密度值,也就是说等于320,opts.inTargetDensity则为屏幕密度480。接着走到native方法中:

static jobject nativeDecodeAsset(JNIEnv* env, jobject clazz, jint native_asset,
        jobject padding, jobject options) {
    return nativeDecodeAssetScaled(env, clazz, native_asset, padding, options, false, 1.0f);
}

static jobject nativeDecodeAssetScaled(JNIEnv* env, jobject clazz, jint native_asset,
        jobject padding, jobject options, jboolean applyScale, jfloat scale) {
    SkStream* stream;
    Asset* asset = reinterpret_cast<Asset*>(native_asset);
	//false
    bool forcePurgeable = optionsPurgeable(env, options);
   ...
    SkAutoUnref aur(stream);
	//applyScale=false,scale=1.0f,forcePurgeable=false
    return doDecode(env, stream, padding, options, true, forcePurgeable, applyScale, scale);
}

这里仍是调用到了doDecode(...)方法中,这里咱们贴出6.0版本代码来查看吧,不然跟不上时代了(虽然已经跟不上了):

static jobject doDecode(JNIEnv* env, SkStreamRewindable* stream, jobject padding, jobject options) {

    int sampleSize = 1;
	//1.解码的模式,主要有两个,一个是kDecodeBounds_Mode,该模式下只返回Bitmap的宽高以及一些Config参数;
	//另一个是kDecodePixels_Mode,返回完整的图片以及相关信息
    SkImageDecoder::Mode decodeMode = SkImageDecoder::kDecodePixels_Mode;
    SkColorType prefColorType = kN32_SkColorType;

    bool doDither = true;
    bool isMutable = false;
    float scale = 1.0f;
    bool preferQualityOverSpeed = false;
    bool requireUnpremultiplied = false;

    jobject javaBitmap = NULL;

    if (options != NULL) {
    	//获取采样率
        sampleSize = env->GetIntField(options, gOptions_sampleSizeFieldID);
        //能够看到这步,若是Java层设置了inJustDecodeBounds,那么使用kDecodeBounds_Mode模式,只获取宽高以及一些信息,而不是去加载图片
        if (optionsJustBounds(env, options)) {
            decodeMode = SkImageDecoder::kDecodeBounds_Mode;
        }

        // initialize these, in case we fail later on
        env->SetIntField(options, gOptions_widthFieldID, -1);
        env->SetIntField(options, gOptions_heightFieldID, -1);
        env->SetObjectField(options, gOptions_mimeFieldID, 0);

        jobject jconfig = env->GetObjectField(options, gOptions_configFieldID);
        prefColorType = GraphicsJNI::getNativeBitmapColorType(env, jconfig);
        isMutable = env->GetBooleanField(options, gOptions_mutableFieldID);
        doDither = env->GetBooleanField(options, gOptions_ditherFieldID);
        preferQualityOverSpeed = env->GetBooleanField(options,
                gOptions_preferQualityOverSpeedFieldID);
        requireUnpremultiplied = !env->GetBooleanField(options, gOptions_premultipliedFieldID);
        //若是设置了inBitmap,则读取对应Bitmap
        javaBitmap = env->GetObjectField(options, gOptions_bitmapFieldID);
        //获取Java层inScaled是否支持缩放
        if (env->GetBooleanField(options, gOptions_scaledFieldID)) {
        	//获取Java层的三个密度
            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) {
            	//1. 计算缩放比率
                scale = (float) targetDensity / density;
            }
        }
    }
	//true
    const bool willScale = scale != 1.0f;

    SkImageDecoder* decoder = SkImageDecoder::Factory(stream);
    if (decoder == NULL) {
        return nullObjectReturn("SkImageDecoder::Factory returned null");
    }

    decoder->setSampleSize(sampleSize);
    decoder->setDitherImage(doDither);
    decoder->setPreferQualityOverSpeed(preferQualityOverSpeed);
    decoder->setRequireUnpremultipliedColors(requireUnpremultiplied);

    android::Bitmap* reuseBitmap = nullptr;
    unsigned int existingBufferSize = 0;
    if (javaBitmap != NULL) {
        reuseBitmap = GraphicsJNI::getBitmap(env, javaBitmap);
        if (reuseBitmap->peekAtPixelRef()->isImmutable()) {
            ALOGW("Unable to reuse an immutable bitmap as an image decoder target.");
            javaBitmap = NULL;
            reuseBitmap = nullptr;
        } else {
            existingBufferSize = GraphicsJNI::getBitmapAllocationByteCount(env, javaBitmap);
        }
    }

    NinePatchPeeker peeker(decoder);
    decoder->setPeeker(&peeker);

    JavaPixelAllocator javaAllocator(env);
    RecyclingPixelAllocator recyclingAllocator(reuseBitmap, existingBufferSize);
    ScaleCheckingAllocator scaleCheckingAllocator(scale, existingBufferSize);
    SkBitmap::Allocator* outputAllocator = (javaBitmap != NULL) ?
            (SkBitmap::Allocator*)&recyclingAllocator : (SkBitmap::Allocator*)&javaAllocator;
    if (decodeMode != SkImageDecoder::kDecodeBounds_Mode) {
        if (!willScale) {
            // If the java allocator is being used to allocate the pixel memory, the decoder
            // need not write zeroes, since the memory is initialized to 0.
            decoder->setSkipWritingZeroes(outputAllocator == &javaAllocator);
            decoder->setAllocator(outputAllocator);
        } else if (javaBitmap != NULL) {
            // check for eventual scaled bounds at allocation time, so we don't decode the bitmap
            // only to find the scaled result too large to fit in the allocation
            decoder->setAllocator(&scaleCheckingAllocator);
        }
    }

    // Only setup the decoder to be deleted after its stack-based, refcounted
    // components (allocators, peekers, etc) are declared. This prevents RefCnt
    // asserts from firing due to the order objects are deleted from the stack.
    SkAutoTDelete<SkImageDecoder> add(decoder);

    AutoDecoderCancel adc(options, decoder);

    // To fix the race condition in case "requestCancelDecode"
    // happens earlier than AutoDecoderCancel object is added
    // to the gAutoDecoderCancelMutex linked list.
    if (options != NULL && env->GetBooleanField(options, gOptions_mCancelID)) {
        return nullObjectReturn("gOptions_mCancelID");
    }

    SkBitmap decodingBitmap;
    //解析Bitmap
    if (decoder->decode(stream, &decodingBitmap, prefColorType, decodeMode)
                != SkImageDecoder::kSuccess) {
        return nullObjectReturn("decoder->decode returned false");
    }
    //获取宽高
    int scaledWidth = decodingBitmap.width();
    int scaledHeight = decodingBitmap.height();
    //这里加0.5应该是四舍五入的意思
    if (willScale && decodeMode != SkImageDecoder::kDecodeBounds_Mode) {
        scaledWidth = int(scaledWidth * scale + 0.5f);
        scaledHeight = int(scaledHeight * scale + 0.5f);
    }

    // 设置Options的值
    if (options != NULL) {
        jstring mimeType = getMimeTypeString(env, decoder->getFormat());
        if (env->ExceptionCheck()) {
            return nullObjectReturn("OOM in getMimeTypeString()");
        }
        env->SetIntField(options, gOptions_widthFieldID, scaledWidth);
        env->SetIntField(options, gOptions_heightFieldID, scaledHeight);
        env->SetObjectField(options, gOptions_mimeFieldID, mimeType);
    }

    //justBounds模式下直接返回空便可
    if (decodeMode == SkImageDecoder::kDecodeBounds_Mode) {
        return NULL;
    }

	...

    SkBitmap outputBitmap;
    if (willScale) {
        // This is weird so let me explain: we could use the scale parameter
        // directly, but for historical reasons this is how the corresponding
        // Dalvik code has always behaved. We simply recreate the behavior here.
        // The result is slightly different from simply using scale because of
        // the 0.5f rounding bias applied when computing the target image size
        const float sx = scaledWidth / float(decodingBitmap.width());
        const float sy = scaledHeight / float(decodingBitmap.height());

        // TODO: avoid copying when scaled size equals decodingBitmap size
        SkColorType colorType = colorTypeForScaledOutput(decodingBitmap.colorType());
        // FIXME: If the alphaType is kUnpremul and the image has alpha, the
        // colors may not be correct, since Skia does not yet support drawing
        // to/from unpremultiplied bitmaps.
        outputBitmap.setInfo(SkImageInfo::Make(scaledWidth, scaledHeight,
                colorType, decodingBitmap.alphaType()));
        if (!outputBitmap.tryAllocPixels(outputAllocator, NULL)) {
            return nullObjectReturn("allocation failed for scaled bitmap");
        }

        // If outputBitmap's pixels are newly allocated by Java, there is no need
        // to erase to 0, since the pixels were initialized to 0.
        if (outputAllocator != &javaAllocator) {
            outputBitmap.eraseColor(0);
        }

        SkPaint paint;
        paint.setFilterQuality(kLow_SkFilterQuality);
        //使用画布的方式进行缩放
        SkCanvas canvas(outputBitmap);
        canvas.scale(sx, sy);
        canvas.drawARGB(0x00, 0x00, 0x00, 0x00);
        canvas.drawBitmap(decodingBitmap, 0.0f, 0.0f, &paint);
    } else {
        outputBitmap.swap(decodingBitmap);
    }

    if (padding) {
        if (peeker.mPatch != NULL) {
            GraphicsJNI::set_jrect(env, padding,
                    peeker.mPatch->paddingLeft, peeker.mPatch->paddingTop,
                    peeker.mPatch->paddingRight, peeker.mPatch->paddingBottom);
        } else {
            GraphicsJNI::set_jrect(env, padding, -1, -1, -1, -1);
        }
    }

    // if we get here, we're in kDecodePixels_Mode and will therefore
    // already have a pixelref installed.
    if (outputBitmap.pixelRef() == NULL) {
        return nullObjectReturn("Got null SkPixelRef");
    }

    if (!isMutable && javaBitmap == NULL) {
        // promise we will never change our pixels (great for sharing and pictures)
        outputBitmap.setImmutable();
    }
    //若是进行重用,则更新旧Bitmap
    if (javaBitmap != NULL) {
        bool isPremultiplied = !requireUnpremultiplied;
        GraphicsJNI::reinitBitmap(env, javaBitmap, outputBitmap.info(), isPremultiplied);
        outputBitmap.notifyPixelsChanged();
        // If a java bitmap was passed in for reuse, pass it back
        return javaBitmap;
    }

    int bitmapCreateFlags = 0x0;
    if (isMutable) bitmapCreateFlags |= GraphicsJNI::kBitmapCreateFlag_Mutable;
    if (!requireUnpremultiplied) bitmapCreateFlags |= GraphicsJNI::kBitmapCreateFlag_Premultiplied;

    //建立Bitmap而且返回
    return GraphicsJNI::createBitmap(env, javaAllocator.getStorageObjAndReset(),
            bitmapCreateFlags, ninePatchChunk, ninePatchInsets, -1);
}

SkColorType其实是代替4.4w中SkBitmap::kARGB_8888_Config的一个封装枚举类:

enum SkColorType {
    kUnknown_SkColorType,
    kAlpha_8_SkColorType,
    kRGB_565_SkColorType,
    kARGB_4444_SkColorType,
    kRGBA_8888_SkColorType,
    kBGRA_8888_SkColorType,
    kIndex_8_SkColorType,
    kGray_8_SkColorType,

    kLastEnum_SkColorType = kGray_8_SkColorType,

#if SK_PMCOLOR_BYTE_ORDER(B,G,R,A)
    kN32_SkColorType = kBGRA_8888_SkColorType,
#elif SK_PMCOLOR_BYTE_ORDER(R,G,B,A)
    kN32_SkColorType = kRGBA_8888_SkColorType,
#else
    #error "SK_*32_SHFIT values must correspond to BGRA or RGBA byte order"
#endif
};

上面源码能够看见确实少掉了isPurgeable的影子,缩放的核心在于 scale = (float) targetDensity / density;这句话,经过计算目标密度/原图密度获得一个缩放比率,而后分别用原Bitmap的宽高乘以对应比率获得最终Bitmap的宽高,在xhdpi状况下就是108x108的宽高啦,这也符合咱们实验后的结果,而内存在用则是原Bitmap的内存x(缩放比率)x(缩放比率)

在设置了inDensity以及inTargetDensity的状况下,同时进行设置sampleSize,源码中会首先根据sampleSize计算出Bitmap的压缩宽高,而后在根据inDensity以及inTargetDensity进行缩放.


Android中Bitmap的OOM

在Android中处理Bitmap免不了遇到OOM的问题,在上面小节中讲述了Bitmap的概念以及在Andorid的表现方式和内存管理,这里就对OOM作个总结。推荐查看官方文档:manage-memory

首先介绍一下OOM的概念,也就是Out-Of-Memory,俗称内存溢出,咱们的app在运行时使用的内存若是超出了单个进程容许最大的值,那么这个进程就会报OOM。OOM发生的状况通常由内存泄漏或者一次性加载过大的内存数据致使(最有可能的就是Bitmap的加载)。那么如何去避免加载过大的Bitmap致使的OOM呢?在谷歌官方文档中介绍了如何有效的去加载一张大的Bitmap,再综合前辈们的方案获得了大概以下几个方式去避免加载Bitmap时候OOM的发生:

  • 增大系统给咱们的内存大小,也就是在Manifest中设置`android:largeHeap="true"。
  • 对图片进行合适的压缩处理,使用RGB_565代替RGBA_8888模式加载Bitmap。
  • 若是条件容许下,使用inBitmap进行内存重用。

在Manifest中设置android:largeHeap="true"这种方式须要须要谨慎使用,缘由引用胡凯大神的博客解释

在一些特殊的情景下,你能够经过在manifest的application标签下添加largeHeap=true的属性来为应用声明一个更大的heap空间。而后,你能够经过getLargeMemoryClass()来获取到这个更大的heap size阈值。然而,声明获得更大Heap阈值的本意是为了一小部分会消耗大量RAM的应用(例如一个大图片的编辑应用)。不要轻易的由于你须要使用更多的内存而去请求一个大的Heap Size。只有当你清楚的知道哪里会使用大量的内存而且知道为何这些内存必须被保留时才去使用large heap。所以请谨慎使用large heap属性。使用额外的内存空间会影响系统总体的用户体验,而且会使得每次gc的运行时间更长。在任务切换时,系统的性能会大打折扣。另外, large heap并不必定可以获取到更大的heap。在某些有严格限制的机器上,large heap的大小和一般的heap size是同样的。所以即便你申请了large heap,你仍是应该经过执行getMemoryClass()来检查实际获取到的heap大小。

对图片压缩的方式主要有尺寸压缩,采样率压缩以及质量压缩三种方式,质量压缩不改变内存占用,所以这里说的压缩主要指使用尺寸压缩和采样率压缩的方式,从代码上看,采样率压缩是尺寸压缩的的子集,Native中实现的方式都是经过scale参数决定最后生成Bitmap的宽高。这里介绍一下采样率压缩,这种方式在谷歌官方文档中体现,也是各大图片库使用的一种减小内存占用的方式(Glide,Picasso.etc),当咱们加载一张实际为1080x1920的图到一个300x200的ImageView的时候做为缩略图展现时候,没有必要全加载一张那么大的图片,咱们能够经过inSampleSize参数配合inJustDecodeBounds 对图片进行压缩,谷歌提供的一个关于采样率的计算方法:

fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int {
    // Raw height and width of image
    val (height: Int, width: Int) = options.run { outHeight to outWidth }
    var inSampleSize = 1

    if (height > reqHeight || width > reqWidth) {

        val halfHeight: Int = height / 2
        val halfWidth: Int = width / 2

        // Calculate the largest inSampleSize value that is a power of 2 and keeps both
        // height and width larger than the requested height and width.
        while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) {
            inSampleSize *= 2
        }
    }

    return inSampleSize
}

更多的内容查看官方文档吧,这里不叙述了。

inBitmap参数的使用能够查看官方Demo

上述说明的OOM是针对以一张图片而言,多图片下的策略基于单图片,额外添加了缓存的操做,最多见的就是LruCache和DiskLruCache策略,官方文档献上,若是想要学习对于多图片加载使用的,我觉深刻一个图片库是一个很是不错的选择,如Glide。


Bitmap的压缩

上面分析中其实或多或少涉猎了Bitmap的压缩的相关知识,Android咱们能接触真正意义上的Bitmap压缩其实只有两种(自主编译libjpg的不算):尺寸压缩和质量压缩。

  • 尺寸压缩:改变Bitmap的大小,宽高以及占用内存随着改变。
  • 质量压缩:改变Bitmap的质量,它是在保持像素的前提下改变图片的位深及透明度等,因此宽高以及占用内存不会改变。

尺寸压缩的方式能够经过采样率或者自主设置inDensity和inTargetDensity以及inScreenDensity的方式进行,这里就不举例了;质量压缩方法使用以下:

public static byte[] compressImageToByteArray(Bitmap src, Bitmap.CompressFormat format, int size) {

        try {
            byte[] byteArray;
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            src.compress(format, size, baos);
            byteArray = baos.toByteArray();
            baos.close();
            return byteArray;
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }

Bitmap的compress(...)中的CompressFormat参数有三种:

JPEG    (0),
 PNG     (1),
 WEBP    (2);

须要注意的是可以进行压缩的只有JPEG以及WEBP格式的,因为PNG为无损压缩格式,因此进行质量压缩并不会有多大的效果,测试代码以下:

private void testCompress() {

        try {
            File jpgFile = new File(getInnerSDCardPath() + File.separator + "11.jpeg");
            Bitmap bitmap = BitmapFactory.decodeFile(jpgFile.getAbsolutePath());
            Log.e("test", "初始jpg大小=" + bitmap.getByteCount());

            byte[] array = BitmapUtils.compressImageToByteArray(bitmap, Bitmap.CompressFormat.JPEG, 50);
            Log.e("test", "质量压缩到50%后的jpg大小=" + array.length);

            byte[] pngArray = BitmapUtils.compressImageToByteArray(bitmap, Bitmap.CompressFormat.PNG, 50);
            Log.e("test", "质量压缩到50%后的png大小=" + pngArray.length);

            byte[] pngArray1 = BitmapUtils.compressImageToByteArray(bitmap, Bitmap.CompressFormat.PNG, 80);
            Log.e("test", "质量压缩到80%后的png大小=" + pngArray1.length);

            byte[] pngArray2 = BitmapUtils.compressImageToByteArray(bitmap, Bitmap.CompressFormat.PNG, 80);
            Log.e("test", "质量压缩到100%后的png大小=" + pngArray2.length);


            byte[] webArray = BitmapUtils.compressImageToByteArray(bitmap, Bitmap.CompressFormat.WEBP, 50);
            Log.e("test", "质量压缩到50%后的webp大小=" + webArray.length);


        } catch (Exception e) {
            e.printStackTrace();
        }


    }

---
test: 初始jpg大小=2691000
test: 质量压缩到50%后的jpg大小=142720
test: 质量压缩到50%后的png大小=660135
test: 质量压缩到80%后的png大小=660135
test: 质量压缩到100%后的png大小=660135
test: 质量压缩到50%后的webp大小=112188

能够看到JPG和WEBP都进行了压缩,而对应PNG则没有变化。


参考资料