Android中Bitmap的深入探讨总结

好久不见. 提交于 2019-12-04 16:15:31

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

关于系统Graphics的研究需要搁置一段时间了,原因是看了老罗的文章,发现自己的表达能力真是相差甚大,为了不误人子弟,打算熟读老罗的分析后在进行归纳总结。

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

  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来表示,像素点的色彩越丰富,自然图像的效果就越好了。

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

具体的结构解析就不深入了,毕竟术业有专攻,我们只要知道概念即可,详细的可以查阅该篇文章

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

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

  • 色深:表示一个像素点可以有多少种色彩来描述,它的单位是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则没有变化。


参考资料

标签
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!