浅析Android平台图像压缩方案

一、前言

在 Android 中进行图片压缩是非常常见的开发场景,主要的压缩方法有两种:其一是质量压缩,其二是尺寸压缩。

前者是在不改变图片尺寸的情况下,改变图片的存储体积,而后者则是降低图像尺寸,达到相同目的。

在介绍Android平台的压缩方案之前,先了解一下Bitmap的几个主要概念。

像素密度  像素密度指的是每英寸像素数目,在Bitmap里用mDensity/mTargetDensity,mDensity默认是设备屏幕的像素密度,mTargetDensity是图片的目标像素密度,在加载图片时就是 drawable 目录的像素密度。

色彩模式  色彩模式是数字世界中表示颜色的一种算法,在Bitmap里用Config来表示。

  • ARGB_8888:每个像素占四个字节,A、R、G、B 分量各占8位,是 Android 的默认设置;

  • RGB_565:每个像素占两个字节,R分量占5位,G分量占6位,B分量占5位;

  • ARGB_4444:每个像素占两个字节,A、R、G、B分量各占4位,成像效果比较差;

  • Alpha_8: 只保存透明度,共8位,1字节.

Bitmap的计算方式: memory = scaledWidth * scaledHeight * 每个像素所占字节数

其中
scaledWidth : width * targetDensity / density + 0.5
scaledHeight: height * targetDensity / density + 0.5

  • scaledWidth表示水平方向的像素值,

  • width表示屏幕宽度,
    -targetDensity表示手机的像素密度,这个值一般跟手机相关,

  • density表示decodingBitmap 的 density,这个值一般跟图片放置的目录有关(hdpi/xxhdpi)

scaledHeight同理

每个像素所占字节数:这个值跟色彩模式相关,默认 ARGB_8888 则是4个字节,

在Bitmap种有两个获取内存占用大小的方法

  • getByteCount():API12 加入,代表存储 Bitmap 的像素需要的最少内存。

  • getAllocationByteCount():API19 加入,代表在内存中为 Bitmap 分配的内存大小,代替了 getByteCount() 方法。

两者的区别: 在不复用 Bitmap 时,getByteCount() 和 getAllocationByteCount 返回的结果是一样的。在通过复用 Bitmap 来解码图片时,那么 getByteCount() 表示新解码图片占用内存的大小,getAllocationByteCount() 表示被复用 Bitmap真实占用的内存大小(即 mBuffer 的长度)。

二、图片格式

Android目前常用的图片格式有png,jpeg和webp,
png: 无损压缩图片格式,支持Alpha通道,Android切图素材多采用此格式;
jpeg:有损压缩图片格式,不支持背景透明,适用于照片等色彩丰富的大图压缩,不适合logo;
webp:是一种同时提供了有损压缩和无损压缩的图片格式,派生自视频编码格式VP8,从谷歌官网来看,无损webp平均比png小26%,有损的webp平均比jpeg小25%~34%,无损webp支持Alpha通道,有损webp在一定的条件下同样支持,有损webp在Android4.0(API 14)之后支持,无损和透明在Android4.3(API18)之后支持;
采用webp能够在保持图片清晰度的情况下,可以有效减小图片所占有的磁盘空间大小。

三、图片压缩方式

质量压缩

质量压缩的关键在于Bitmap.compress()函数,该函数不会改变图像的大小,但是可以降低图像的质量,从而降低存储大小,进而达到压缩的目的。

这里提到的图像的质量主要指的是图片的色彩空间

一般图像的色彩空间为RGB,主要通过RGB三原色通道来描述图片,其中又有ARGB格式,比起RGB多了一个透明度的通道。

Android下的质量压缩主要通过下面这个函数来实现的。

bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream);

三个参数

  • CompressFormat: 压缩格式,它有JPEG、PNG、WEBP三种选择,JPEG是有损压缩,PNG是无损压缩,WEBP是Google推出的图像格式.

  • int quality:0~100可选,数值越大,质量越高,图像越大。

  • OutputStream stream:压缩后图像的输出流。

其中PNG是无损格式的,压缩效果不太理想,而WEBP会存在兼容性的问题。出于兼容性和效果来看,一般会选择JPEG作为压缩格式。

实例代码

// R.drawable.thumb 为 png 图片
Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.thumb);
try {
    //保存压缩图片到本地
    File file = new File(Environment.getExternalStorageDirectory(), "aaa.jpg");
    if (!file.exists()) {
        file.createNewFile();
    }
    FileOutputStream fs = new FileOutputStream(file);
    bitmap.compress(Bitmap.CompressFormat.JPEG, 50, fs);
    Log.i(TAG, "onCreate: file.length " + file.length());
    fs.flush();
    fs.close();
} catch (FileNotFoundException e) {
    e.printStackTrace();
} catch (IOException e) {
    e.printStackTrace();
}
//查看压缩之后的 Bitmap 大小
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
bitmap.compress(Bitmap.CompressFormat.JPEG, 50, outputStream);
byte[] bytes = outputStream.toByteArray();
Bitmap compress = BitmapFactory.decodeByteArray(bytes, 0, bytes.length);
Log.i(TAG, "onCreate: bitmap.size = " + bitmap.getByteCount() + "   compress.size = " + compress.getByteCount());

我们再来看看quality参数被设置为50前后,两张图片的对比.
压缩前的图片 

640   压缩后的图片 640

在质量压缩前后图片转成 Bitmap 之后在内存中的大小也并没有变化,这是在保持像素的前提下,改变图片的位深及透明度等:

//压缩之后图片占用的存储体积
compress.length = 7814
//在内存中压缩前后图片占用的大小
bitmap.size = 350000   compress.size = 350000

对比二者,保存前的图片存储体积是 106k,质量设为 50 并且保存为 JPEG 格式之后,图片存储大小就只有 8k 了,并且质量设的越低,保存成文件之后,文件的体积也就越小。

这里可能有人就会有疑惑,为什么压缩过后,两张图片的大小还会是一样的呢?

       因为质量压缩不会改变图片的分辨率,而图片在内存中的大小是根据width*height*一个像素的所占用的字节数计算的,宽高没变,在内存中占用的大小自然不会变,质量压缩的原理是通过改变图片的位深和透明度来减小图片占用的磁盘空间大小,所以不适合作为缩略图,可以用于想保持图片质量的同时减小图片所占用的磁盘空间大小。

bitmap占用的大小不变,那为什么图片质量下降了呢?这是因为图片被压缩过了啊!

       首先要知道JPEG格式是有损压缩的,JPEG格式的图片是不支持透明色彩的,这也是JPEG的大小会比PNG小很多,图片质量会比PNG差的原因。在经过了bitmap.compress()这个流程时,JPEG会舍去透明属性.这样存放到磁盘时的文件大小就减小了.然后这个时候再通过BitmapFactory.decodeByteArray()把图片加载回来时,加载的是舍去了透明通道的图片,按理说应该采用 RGB_565或者RGB_888这样的色彩空间加载,但是你没有另外设置这个参数的话,加载的色彩格式会是默认ARGB_8888.图片都没有透明的色彩空间了,你再给它分配内存就只是浪费内存而已。

这也是为什么压缩前后,bitmap所占的大小相同,图片质量却有所差距的原因。

一般图片上传压缩处理的方式:

public static void compressBmpToFile(Bitmap bmp, File file){
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        int options = 80;//设置开始的compress系数
        bmp.compress(Bitmap.CompressFormat.JPEG, options, baos);
        while (baos.toByteArray().length / 1024 > 100) { // 大于100k继续压缩 
            baos.reset();
            options -= 10;
            bmp.compress(Bitmap.CompressFormat.JPEG, options, baos);
        }
        try {
            FileOutputStream fos = new FileOutputStream(file);
            fos.write(baos.toByteArray());
            fos.flush();
            fos.close();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

尺寸压缩

针对图片尺寸的修改其实就是一个图像重新采样的过程,放大图像称为上采样,缩小图像称为下采样,这里我们重点讨论下采样。

在 Android 中图片重采样提供了两种方法,一种叫做邻近采样,另一种叫做双线性采样。

邻近采样

邻近采样,是 Android 中常用的压缩方法之一,邻近采样的方式是最快的,因为它直接选择其中一个像素作为生成像素,但是生成的图片可能会相对比较失真,产生比较明显的锯齿,最具有代表性的就是处理文字比较多的图片在展示效果上的差别, 我们先来看看在 Android 中使用邻近采样的示例代码:

/**
     * @param filePath   要加载的图片路径
     * @param destWidth  显示图片的控件宽度
     * @param destHeight 显示图片的控件的高度
     * @return
     */
    public static Bitmap getBitmap(String filePath, int destWidth, int destHeight) {
        //第一次采样
        BitmapFactory.Options options = new BitmapFactory.Options();
        //该属性设置为true只会加载图片的边框进来,并不会加载图片具体的像素点
        options.inJustDecodeBounds = true;
        //第一次加载图片,这时只会加载图片的边框进来,并不会加载图片中的像素点
        BitmapFactory.decodeFile(filePath, options);
        //获得原图的宽和高
        int outWidth = options.outWidth;
        int outHeight = options.outHeight;
        //定义缩放比例
        int sampleSize = 1;
        while (outHeight / sampleSize > destHeight || outWidth / sampleSize > destWidth) {
            //如果宽高的任意一方的缩放比例没有达到要求,都继续增大缩放比例
            //sampleSize应该为2的n次幂,如果给sampleSize设置的数字不是2的n次幂,那么系统会就近取值
            sampleSize *= 2;
        }
        /********************************************************************************************/
        //至此,第一次采样已经结束,我们已经成功的计算出了sampleSize的大小
        /********************************************************************************************/
        //二次采样开始
        //二次采样时我需要将图片加载出来显示,不能只加载图片的框架,因此inJustDecodeBounds属性要设置为false
        options.inJustDecodeBounds = false;
        //设置缩放比例
        options.inSampleSize = sampleSize;
        options.inPreferredConfig = Bitmap.Config.ARGB_8888;
        //加载图片并返回
        return BitmapFactory.decodeFile(filePath, options);
    }
}

双线性采样

双线性采样(Bilinear Resampling)通过减少图片的像素来降低图片的磁盘空间大小和内存大小,可以用于缓存缩略图, 在 Android 中的使用方式一般有两种:

Bitmap bitmap = BitmapFactory.decodeFile("/sdcard/test.png");
Bitmap compress = Bitmap.createScaledBitmap(bitmap, bitmap.getWidth()/2, bitmap.getHeight()/2, true);
或者直接使用 matrix 进行缩放

Bitmap bitmap = BitmapFactory.decodeFile("/sdcard/test.png");
Matrix matrix = new Matrix();
matrix.setScale(0.5f, 0.5f);
bm = Bitmap.createBitmap(bitmap, 0, 0, bit.getWidth(), bit.getHeight(), matrix, true);

看源码可以知道 createScaledBitmap 函数最终也是使用第二种方式的 matrix 进行缩放,我们来看看双线性采样的表现:

                       

 

可以看到处理之后的图片不是像邻近采样一样纯粹的一种颜色,而是两种颜色的混合。双线性采样使用的是双线性內插值算法,这个算法不像邻近点插值算法一样,直接粗暴的选择一个像素,而是参考了源像素相应位置周围 2x2 个点的值,根据相对位置取对应的权重,经过计算之后得到目标图像。

双线性内插值算法在图像的缩放处理中具有抗锯齿功能, 是最简单和常见的图像缩放算法,当对相邻 2x2 个像素点采用双线性內插值算法时,所得表面在邻域处是吻合的,但斜率不吻合,并且双线性内插值算法的平滑作用可能使得图像的细节产生退化,这种现象在上采样时尤其明显。

 

Re-using Bitmaps(重复使用bitmaps)

我们知道bitmap会占用大量的内存空间,这节会讲解什么是inBitmap属性,如何利用这个属性来提升bitmap的循环效率。前面我们介绍过使用对象池的技术来解决对象频繁创建再回收的效率问题,使用这种方法,bitmap占用的内存空间会差不多是恒定的数值,每次新创建出来的bitmap都会需要占用一块单独的内存区域,如下图所示:

为了解决上图所示的效率问题,Android在解码图片的时候引进了inBitmap属性,使用这个属性可以得到下图所示的效果:

使用inBitmap属性可以告知Bitmap解码器去尝试使用已经存在的内存区域,新解码的bitmap会尝试去使用之前那张bitmap在heap中所占据的pixel data内存区域,而不是去问内存重新申请一块区域来存放bitmap。利用这种特性,即使是上千张的图片,也只会仅仅只需要占用屏幕所能够显示的图片数量的内存大小。下面是如何使用inBitmap的代码示例:

使用inBitmap需要注意几个限制条件:

  • 在SDK 11 -> 18之间,重用的bitmap大小必须是一致的,例如给inBitmap赋值的图片大小为100-100,那么新申请的bitmap必须也为100-100才能够被重用。从SDK 19开始,新申请的bitmap大小必须小于或者等于已经赋值过的bitmap大小。
  • 新申请的bitmap与旧的bitmap必须有相同的解码格式,例如大家都是8888的,如果前面的bitmap是8888,那么就不能支持4444与565格式的bitmap了,不同的编码格式占用的内存是不同的,有时候也可以根据需求指定编码格式。

我们可以创建一个包含多种典型可重用bitmap的对象池,这样后续的bitmap创建都能够找到合适的“模板”去进行重用。如下图所示:

如何加载高清图


如果有需求,要求我们既不能压缩图片,又不能发生oom怎么办,这种情况我们需要加载图片的一部分区域来显示,下面我们来了解一下BitmapRegionDecoder这个类,加载图片的一部分区域,他的用法很简单

//支持传入图片的路径,流和图片修饰符等
   BitmapRegionDecoder mDecoder = BitmapRegionDecoder.newInstance(path, false);
//需要显示的区域就有由rect控制,options来控制图片的属性
    Bitmap bitmap = mDecoder.decodeRegion(mRect, options);
由于要显示一部分区域,所以要有手势的控制,方便上下的滑动,需要自定义控件,而自定义控件的思路也很简单
1 提供图片的入口
2 重写onTouchEvent, 根据手势的移动更新显示区域的参数
3 更新区域参数后,刷新控件重新绘制

下面是完整代码

public class BigImageView extends View {

    private BitmapRegionDecoder mDecoder;
    private int mImageWidth;
    private int mImageHeight;
    //图片绘制的区域
    private Rect mRect = new Rect();
    private static final BitmapFactory.Options options = new BitmapFactory.Options();

    static {
        options.inPreferredConfig = Bitmap.Config.RGB_565;
    }

    public BigImageView(Context context) {
        super(context);
        init();
    }

    public BigImageView(Context context, AttributeSet attrs) {
        super(context, attrs);
        init();
    }

    public BigImageView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }

    private void init() {

    }

    /**
     * 自定义view的入口,设置图片流
     * @param path 图片路径
     */
    public void setFilePath(String path) {
        try {
            //初始化BitmapRegionDecoder
            mDecoder = BitmapRegionDecoder.newInstance(path, false);
            BitmapFactory.Options options = new BitmapFactory.Options();
            //便是只加载图片属性,不加载bitmap进入内存
            options.inJustDecodeBounds = true;
            BitmapFactory.decodeFile(path, options);
            //图片的宽高
            mImageWidth = options.outWidth;
            mImageHeight = options.outHeight;
            Log.d("mmm", "图片宽=" + mImageWidth + "图片高=" + mImageHeight);

            requestLayout();
            invalidate();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);

        //获取本view的宽高
        int measuredHeight = getMeasuredHeight();
        int measuredWidth = getMeasuredWidth();


        //默认显示图片左上方
        mRect.left = 0;
        mRect.top = 0;
        mRect.right = mRect.left + measuredWidth;
        mRect.bottom = mRect.top + measuredHeight;
    }

    //第一次按下的位置
    private float mDownX;
    private float mDownY;

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                mDownX = event.getX();
                mDownY = event.getY();
                break;
            case MotionEvent.ACTION_MOVE:
                float moveX = event.getX();
                float moveY = event.getY();
                //移动的距离
                int xDistance = (int) (moveX - mDownX);
                int yDistance = (int) (moveY - mDownY);
                Log.d("mmm", "mDownX=" + mDownX + "mDownY=" + mDownY);
                Log.d("mmm", "movex=" + moveX + "movey=" + moveY);
                Log.d("mmm", "xDistance=" + xDistance + "yDistance=" + yDistance);
                Log.d("mmm", "mImageWidth=" + mImageWidth + "mImageHeight=" + mImageHeight);
                Log.d("mmm", "getWidth=" + getWidth() + "getHeight=" + getHeight());
                if (mImageWidth > getWidth()) {
                    mRect.offset(-xDistance, 0);
                    checkWidth();
                    //刷新页面
                    invalidate();
                    Log.d("mmm", "刷新宽度");
                }
                if (mImageHeight > getHeight()) {
                    mRect.offset(0, -yDistance);
                    checkHeight();
                    invalidate();
                    Log.d("mmm", "刷新高度");
                }
                break;
            case MotionEvent.ACTION_UP:
                break;
            default:
        }
        return true;
    }


    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        Bitmap bitmap = mDecoder.decodeRegion(mRect, options);
        canvas.drawBitmap(bitmap, 0, 0, null);
    }

    /**
     * 确保图不划出屏幕
     */
    private void checkWidth() {


        Rect rect = mRect;
        int imageWidth = mImageWidth;
        int imageHeight = mImageHeight;

        if (rect.right > imageWidth) {
            rect.right = imageWidth;
            rect.left = imageWidth - getWidth();
        }

        if (rect.left < 0) {
            rect.left = 0;
            rect.right = getWidth();
        }
    }

    /**
     * 确保图不划出屏幕
     */
    private void checkHeight() {

        Rect rect = mRect;
        int imageWidth = mImageWidth;
        int imageHeight = mImageHeight;

        if (rect.bottom > imageHeight) {
            rect.bottom = imageHeight;
            rect.top = imageHeight - getHeight();
        }

        if (rect.top < 0) {
            rect.top = 0;
            rect.bottom = getHeight();
        }
    }
}

 

总结


1、使用webp格式的图片可以在保持清晰度的情况下减小图片的磁盘大小,是一种比较优秀的,google推荐的图片格式;
2、质量压缩可以减小图片占用的磁盘空间,不会减小在内存中的大小;
3、邻近采样压缩可以通过改变分辨率来减小图片所占用的磁盘空间和内存空间大小,但是采样率只能设置2的n次方,可能图片的最优比例在中间;
4、双线性采样压缩同样也是通过改变分辨率来减小图片所占用的磁盘空间和内存空间大小,缩放的尺寸没有什么限制;
5、jni调用jpeg库来弥补安卓系统skia框架的不足,也是比较优秀的解决方式。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值