Android—Bitmap解析与应用

1,500 阅读20分钟

项目地址

github.com/ListerChen/…

一、Bitmap基本介绍

Bitmap也称为位图,是图片在内存中的表现形式,任何图片(JPEG, PNG, WEBP...)加载到内存后都是一个Bitmap对象。Bitmap实际是像素点的集合,假设它的宽高为width和height,那么该Bitmap就包含width*height个像素,它在内存中占用的内存就是(width*height*单个像素内存)。

为了减小图片在磁盘上所占的空间,将Bitmap保存到磁盘上时会进行压缩,图片的文件格式实际代表的是不同的压缩方式与压缩率,而将磁盘上的文件加载到内存中时就要进行解压缩。

1.1 图片格式介绍

常见的静态图片格式为JPEG、PNG和WEBP,它们有着不同的压缩方式,保存到本地后所占用的空间大小也不一样。

  1. JPEG是一种有损压缩格式,以24位颜色压缩存储单个位图,但是不支持透明度。使用JPEG进行压缩时需要选择适当的压缩率,避免图片质量太差。
  2. PNG是一种无损压缩格式,支持所有的颜色,由于是无损压缩,PNG一般用于APP图标这类对线条或者清晰度有要求的图片。由于PNG所占空间较大,目前一般将PNG转为WEBP使用。
  3. WEBP支持无损压缩和有损压缩,并且他的无损压缩率优于PNG,有损压缩率优于JPEG,同时它支持所有颜色,并支持多帧动画,唯一的缺点是压缩效率不如JPEG和PNG。

1.2 Bitmap色深

Bitmap的本质就是像素点的集合,它通过描述每个像素的ARGB信息来形成整张图片,其中A表示透明度通道,RGB表示红绿蓝3种颜色通道,每个通道的值都在0-255之间,因此8bit可以完整地表示1个通道,那么4x8=32bit可以表示一个完整的像素。但如果每个Bitmap都使用32bit来表示一个像素的话,对内存来说是一个较大的负担,因此对于质量要求不高的Bitmap来说,可以采用较少的内存去表示一个像素。

色深指的是每一个像素点用多少bit来存储ARGB值,色深越大,图片的色彩越丰富,一般来说色深有8bit、16bit、32bit等,Bitmap.Config中的色深配置如下。

ALPHA_8: 这种方案只存储透明度通道,色深为8bit,使用场景特殊,比如设置遮盖效果等。 ARGB_8888: ARGB每个通道值采8bit来表示,色深为32bit,每个像素点需要4byte的内存来存储数据,图片质量高,所占内存大。 ARGB_4444: ARGB每个通道都是4位,色深为16bit,由于这种配置下的图片质量较差,Android官方已经将其标为弃用。 RGB_565: 色深为16bit,RGB通道值分别占5、6、5位,但是没有存储A通道值,所以不支持透明度,可用于对清晰度要求不高的照片。 RGBA_F16: 色深为64bit,该配置主要用于广色域与HDR内容。 HARDWARE: 这是一种特殊配置,该配置下,Bitmap会被存储在显存中,并且Bitmap是不可变的。这种配置仅适用于,当Bitmap唯一的操作就是将自己绘制在屏幕上时。

我们可以根据对图片质量的要求创建不同色深的Bitmap,如果对显示质量有较高的要求可以使用ARGB_8888,一般来说使用RGB_565即可,这也可以减少OOM的概率。

Bitmap b = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);

1.2 通过采样加载大图

ImageView是显示Bitmap的载体,一般情况下ImageView的宽高会小于Bitmap,如果将一个完整的Bitmap加载到偏小的ImageView中会浪费内存。关于这个问题,Android官方提供了一个优化方法。

该方法通过比较ImageView与Bitmap的大小并计算采样率,最终将采样后的小图加载到内存中。流程如下:在调用BitmapFactory.decodeXXX(res, resId, BitmapFactory.Options)解析图片时,先将BitmapFactory.OptionsinJustDecodeBounds 设为true,此时不会将图片加载到内存中,而是只得到Bitmap的宽高,随后通过图片宽高计算采样率。得到采样率后再将inJustDecodeBounds 设为false,再加载Bitmap时可以得到大图的采样图。

public static Bitmap decodeSampledBitmapFromResource(
            Resources res, int resId, int reqWidth, int reqHeight) {
    final BitmapFactory.Options options = new BitmapFactory.Options();
    // 该属性默认为false, 为true时不会将图片加载到内存中, 而是只计算宽高
    options.inJustDecodeBounds = true;
    BitmapFactory.decodeResource(res, resId, options);
    // 计算采样率
    options.inSampleSize = calculateInSampleSize(
            options, reqWidth, reqHeight);
    // 设置inJustDecodeBounds为false, 将图片加载到内存
    options.inJustDecodeBounds = false;
    return BitmapFactory.decodeResource(res, resId, options);
}

public static int calculateInSampleSize(
            BitmapFactory.Options options, int reqWidth, int reqHeight) {
    // 获得Bitmap的宽高
    final int height = options.outHeight;
    final int width = options.outWidth;
    // 计算采样率
    int inSampleSize = 1;
    if (height > reqHeight || width > reqWidth) {
        final int halfHeight = height / 2;
        final int halfWidth = width / 2;
        while ((halfHeight / inSampleSize) >= reqHeight
                && (halfWidth / inSampleSize) >= reqWidth) {
            inSampleSize *= 2;
        }
    }
    return inSampleSize;
}

如果使用Glide这类图片加载框架将Bitmap加载到ImageView中,框架会自动帮我们完成采样的工作。不过如果你需要获取某个Bitmap的缩略图,上述方法还是有用武之地的。

二、Bitmap相关应用

2.1 图片裁剪

图片裁剪是图片处理的基本功能,APP中的设置头像功能就需要用到图片裁剪,用户可以通过一个预览框在原图上裁剪出特定区域的图案。 系统本身提供了裁剪的功能,我们可以在A页面通过以下代码启动一个裁剪图片的Activity,裁剪结束在A页面的onActivityResult(...)中得到裁剪后的图片。

// uri为图片的地址
public void startCropPicture(Uri uri) {
    Intent intent = new Intent("com.android.camera.action.CROP");
    intent.setDataAndType(uri, "image/*");
    intent.putExtra("crop", "true");
    intent.putExtra("aspectX", 1); // 裁剪框比例
    intent.putExtra("aspectY", 1);
    intent.putExtra("outputX", 300); // 输出图片大小
    intent.putExtra("outputY", 300);
    intent.putExtra("scale", true);
    intent.putExtra("return-data", true);
    startActivityForResult(intent, REQUEST_CODE);
}

图片裁剪的原理很简单,通过Bitmap.createBitmap(originBitmap, left, top, width, height)即可在originBitmap上裁出指定区域的Bitmap,因此我们可以自己实现一个裁剪头像的功能。需要注意的是,Bitmap.createBitmap(...)方法参数中传入的坐标和宽高都是基于原始Bitmap的,如果传入的参数超出了原始Bitmap的宽高就会抛出异常。

本文Demo实现了裁剪图案的基础功能,拖动裁剪框右下角可以修改框大小,拖动其他区域可以移动裁剪框,可以通过本文开头的链接下载代码。

除了Bitmap.createBitmap(originBitmap, left, top, width, height)裁剪出指定的Bitmap,也可以在绘制时直接指定Bitmap的绘制区域,通过Canvas.drawBitmap(Bitmap bitmap, Rect src, RectF dst, Paint paint)即可实现,其中src表示对原图片的裁剪区域,dst表示对裁剪后的图片绘制到View上的区域。

2.2 图片拼接

图片拼接常见于分享时拼接所有图片,达到"一图看尽所有"的效果。代码示例如下,为了方便演示,示例中将4张一样的正方形图片splitBitmap拼接到了一起。

private Bitmap getJointBitmap(Bitmap splitBitmap) {
    int width = splitBitmap.getWidth();
    int height = splitBitmap.getHeight();
    Bitmap bitmap = Bitmap.createBitmap(
                width * 2, height * 2, Bitmap.Config.RGB_565);
    Canvas canvas = new Canvas(bitmap);
    canvas.drawBitmap(splitBitmap, 0, 0, null);
    canvas.drawBitmap(splitBitmap, width, 0, null);
    canvas.drawBitmap(splitBitmap, 0, height, null);
    canvas.drawBitmap(splitBitmap, width, height, null);
    return bitmap;
}

拼接图片前先创建了一个宽高都为splitBitmap宽高2倍的Bitmap作为容器,然后通过Canvas在容器的四个位置绘制4张一样的图片,最终效果如下。

图像拼接.jpg

2.3 矩阵变换

Bitmap是像素点的集合,我们可以通过矩阵运算改变每个像素点的位置,达到图形变换的效果。Android中可以通过Matrix类来进行变换,Matrix本身是一个3x3的矩阵,可以通过Matrix m = new Matrix()新建一个单位矩阵,原始矩阵的值如下所示。

[1 0 0]
[0 1 0]
[0 0 1]

Matrix中各个位置的变换信息如下所示,scale表示缩放,skew表示错切,trans表示平移,persp等表示透视参数。Bitmap中的每个像素点可以使用一个3x1的矩阵表示,其中x表示当前像素点的横坐标,y表示纵坐标。用该矩阵左乘Bitmap中的所有像素后,就能得到变换后的图像。

[scaleX  skewX   transX]     [x]     [scaleX * x + skewX * y + transX]
[skewY   scaleY  transY]  x  [y]  =  [skewY * x + scaleY * y + transY]
[persp0  persp1  persp2]     [1]     [persp0 * x + persp1 * y + persp2]

简单来说,Matrix是一个容器,保存了用户期望的矩阵变换信息。在将Matrix应用于Bitmap之前,我们可以对其进行各种操作,将变换信息保存进去。矩阵运算可以实现平移、旋转、缩放、错切,因此Matrix也为提供了类似方法。

setTranslate(float dx,float dy): 控制 Matrix 进行位移。
setSkew(float kx,float ky): 控制 Matrix 进行倾斜,kx、ky为X、Y方向上的比例。
setSkew(float kx,float ky,float px,float py): 控制 Matrix 以 px, py 为轴心进行倾斜,kx、ky为X、Y方向上的倾斜比例
setRotate(float degrees): 控制 Matrix 进行 depress 角度的旋转,轴心为(0,0)
setRotate(float degrees,float px,float py): 控制 Matrix 进行 depress 角度的旋转,轴心为(px,py)
setScale(float sx,float sy): 设置 Matrix 进行缩放,sx, sy 为 X, Y方向上的缩放比例。
setScale(float sx,float sy,float px,float py): 设置 Matrix 以(px,py)为轴心进行缩放,sx、sy 为 X、Y方向上的缩放比例

很多时候矩阵变换并不是单一的平移、旋转或缩放,这些变换经常结合在一起使用,此时setXXX()方法无法满足要求。因此Matrix提供了preXXX()postXXX()方法来组合多个矩阵操作,多个矩阵操作之间通过乘法运算。由于矩阵的乘法是不满足交换律的,因此进行矩阵运算时需要注意乘法的顺序。

在使用preXXX()postXXX()时,我们可以将矩阵变换的所有计算看成一个乘法列表,调用preXXX()方法时就是向列表头部添加操作,调用postXXX()时就是向列表尾部添加操作。例如以下代码中矩阵乘法的执行顺序就是2->1->3->4。需要注意的是,setXXX()方法会重置Matrix的变换,如果对下方执行完4个运算的Matrix调用setTranslate()方法,那么该Matrix就只有平移效果了。

Matrix matrix = new Matrix();
matrix.preScale(...); // 1
matrix.preTranslate(...); // 2
matrix.postTranslate(...); // 3
matrix.postRotate(...); // 4

2.4 颜色变换

颜色变换主要通过ColorFilter进行,通过Paint.setColorFilter(ColorFilter filter)可以设置颜色过滤器,该过滤器会对每一个像素的颜色进行过滤,得到最终的图像。ColorFilter有3个子类,这里主要介绍ColorMatrixColorFilter。

2.4.1 ColorMatrixColorFilter

该颜色过滤器通过矩阵进行色彩变换,先来介绍一下色彩矩阵,Android中的色彩是以ARGB的形式存储的,我们可以通过ColorMatrix修改颜色的值,ColorMatrix定义了一个4x5的float矩阵,矩阵的4行分别表示在RGBA上的向量,其范围值在0f-2f之间,如果为1就是原效果。每一行的第5列表示偏移量,就是指在当前通道上增大或减小多少。

ColorMatrix colorMatrix = new ColorMatrix(new float[]{
		1, 0, 0, 0, 0,
		0, 1, 0, 0, 0,
		0, 0, 1, 0, 0,
		0, 0, 0, 1, 0,
});

ColorMatrix与颜色之间的运算如下所示,其实就是矩阵运算,与上一节的Matrix类似。

[a, b, c, d, e]     [R]     [a*R + b*G + c*B + d*A + e]
[f, g, h, i, j]     [G]     [f*R + g*G + h*B + i*A + j]
[k, l, m, n, o]  x  [B]  =  [k*R + l*G + m*B + n*A + o]
[p, q, r, s, t]     [A]     [p*R + q*G + r*B + s*A + t]
                    [1]

有了ColorMatrix,就可以对Bitmap上所有的颜色进行修改,例如调整每个通道的值,将初始值1改为0.5,就可以将Bitmap变暗。不过我不太了解色彩,也没在项目中实际使用过,感兴趣的朋友可以看参考1。

2.5 图像混合

图像混合是指对两张原始图像(我们称为DST和SRC)的内容按某种规则合成,从而形成一张包含DST和SRC特点的新图像。例如DST为圆形图像,SRC为照片,可以将它们合成为圆形照片。

Android通过PorterDuffXfermode实现图像混合,它实际上是通过公式对两张图像在Canvas上的所有像素进行ARGB运算,最终在每个像素点得到新的ARGB值。需要注意的是,在onDraw(Canvas)方法中进行图像混合时,先绘制的图像为DST,后绘制的图像为SRC,因此需要注意图像的绘制顺序。

PorterDuffXfermode一共提供了18种混合模式,它们的计算公式如下,Sa表示SRC的ALPHA通道,Sc表示SRC的颜色;Da表示DST的ALPHA通道,Dc表示DST的颜色。以CLEAR为例,该模式会清除SRC区域的所有内容。

合成模式公式
CLEAR[0, 0]
SRC[Sa, Sc]
DST[Da, Dc]
SRC_OVER[Sa + (1 - Sa)*Da, Rc = Sc + (1 - Sa)*Dc]
DST_OVER[Sa + (1 - Sa)*Da, Rc = Dc + (1 - Da)*Sc]
SRC_IN[Sa * Da, Sc * Da]
DST_IN[Sa * Da, Sa * Dc]
SRC_OUT[Sa * (1 - Da), Sc * (1 - Da)]
DST_OUT[Da * (1 - Sa), Dc * (1 - Sa)]
SRC_ATOP[Da, Sc * Da + (1 - Sa) * Dc]
DST_ATOP[Sa, Sa * Dc + Sc * (1 - Da)]
XOR[Sa + Da - 2 * Sa * Da, Sc * (1 - Da) + (1 - Sa) * Dc]
DARKEN[Sa + Da - Sa * Da, Sc * (1 - Da) + Dc * (1 - Sa) + min(Sc, Dc)]
LIGHTEN[Sa + Da - Sa * Da, Sc * (1 - Da) + Dc * (1 - Sa) + max(Sc, Dc)]
MULTIPLY[Sa * Da, Sc * Dc]
SCREEN[Sa + Da - Sa * Da, Sc + Dc - Sc * Dc]
ADDSaturate(S + D)

如果你使用过PorterDuffXfermode,你可能见过下面这张图,Android官方的样例就是这个效果,不过官方只提供了16种混合模式的样例,我在Demo中把ADD和OVERLAY也添加了进去。

图像合成1.jpg

当然你也可能见过这张图。

图像合成2.jpg

乍一看,这两张图中的DST和SRC原始图像都是一样的,但是为什么使用了同样的混合模式后,显示的结果不同呢?

关键就在于DST和SRC的大小,第一张图中的DST和SRC都是Bitmap,它们的大小与Canvas相等,只是在Bitmap的某个区域绘制了圆和矩形。Demo代码如下,可以看到makeDst()makeSrc()中创建的Bitmap与整个View(或者说Canvas)是相等的。这也解释了为什么第一张图的CLEAR模式下的结果是空白的,因为SRC的大小是整个View的大小,CLEAR模式表示清除SRC区域的内容,最终将整个View的内容清除了。

public class XFerModeView extends View {

    private Paint mPaint;
    private PorterDuffXfermode mPorterDuffXfermode;
    private int mWidth;
    private int mHeight;

    // 省略构造方法......

    private void init() {
        setLayerType(LAYER_TYPE_SOFTWARE, null);
        mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mPorterDuffXfermode = new PorterDuffXfermode(PorterDuff.Mode.CLEAR);
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        if (mWidth != w || mHeight != h) {
            mWidth = w;
            mHeight = h;
            invalidate();
        }
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        drawBackground(canvas);
        int sc = canvas.saveLayer(0, 0, mWidth, mHeight, null);
        drawCompositionInFullSize(canvas);
        canvas.restoreToCount(sc);
    }

    private void drawBackground(Canvas canvas) {
        mPaint.setStyle(Paint.Style.STROKE);
        mPaint.setStrokeWidth(5);
        mPaint.setColor(Color.BLACK);
        canvas.drawRect(0, 0, mWidth, mHeight, mPaint);
    }

    private void drawCompositionInFullSize(Canvas canvas) {
        mPaint.setStyle(Paint.Style.FILL);
        Bitmap dst = makeDst();
        Bitmap src = makeSrc();
        // 绘制DST
        canvas.drawBitmap(dst, 0, 0, mPaint);
        // 设置图像混合模式
        mPaint.setXfermode(mPorterDuffXfermode);
        // 绘制SRC
        canvas.drawBitmap(src, 0, 0, mPaint);
        // 清除图像混合模式
        mPaint.setXfermode(null);
    }

    private Bitmap makeDst() {
        Bitmap bm = Bitmap.createBitmap(mWidth, mHeight, Bitmap.Config.ARGB_8888);
        Canvas c = new Canvas(bm);
        mPaint.setColor(0xFFFFCC44);
        c.drawOval(10, 10, mWidth * 3f / 4, mHeight * 3f / 4, mPaint);
        return bm;
    }

    private Bitmap makeSrc() {
        Bitmap bm = Bitmap.createBitmap(mWidth, mHeight, Bitmap.Config.ARGB_8888);
        Canvas c = new Canvas(bm);
        mPaint.setColor(0xFF66AAFF);
        c.drawRect(mWidth * 1f / 3, mHeight * 1f / 3,
                mWidth * 19f / 20, mHeight * 19f / 20, mPaint);
        return bm;
    }

在第二张图中,绘制SRC和DST时创建的图像大小就是圆或矩形的大小,最终的结果也与第一张图有所不同,修改后的代码如下。还是以CLEAR模式为例,此时清除的就只是矩形区域SRC的图像,可以看到DST中与SRC相交的部分被清除了。

public class XFerModeView extends View {

    // 省略重复代码......

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        drawBackground(canvas);
        int sc = canvas.saveLayer(0, 0, mWidth, mHeight, null);
        drawCompositionInSelfSize(canvas);
        canvas.restoreToCount(sc);
    }

    /**
     * 混合图像的大小只有可见区域大小
     */
    private void drawCompositionInSelfSize(Canvas canvas) {
        mPaint.setStyle(Paint.Style.FILL);
        mPaint.setColor(0xFFFFCC44);
        canvas.drawOval(10, 10, mWidth * 3f / 4, mHeight * 3f / 4, mPaint);
        mPaint.setXfermode(mPorterDuffXfermode);
        mPaint.setColor(0xFF66AAFF);
        canvas.drawRect(mWidth * 1f / 3, mHeight * 1f / 3,
                mWidth * 19f / 20, mHeight * 19f / 20, mPaint);
        mPaint.setXfermode(null);
    }
}

使用图像混合时还有一个要注意的地方:上述代码在onDraw(Canvas)方法中绘制混合图像时会先调用int sc = canvas.saveLayer(...)生成一个新的图层(Layer),sc表示图层的编号,随后在新Layer上绘制DST和SRC,绘制完后将该Layer添加到Canvas上。那么这里为什么需要新的Layer来绘制DST和SRC,而不是直接在Canvas上绘制呢?

Layer可以理解为画布Canvas的一个层级,默认情况下Canvas只有一个Layer,所有的绘制都在同一图层上。当需要绘制多层图像时,可以通过canvas.saveLayer(...)生成新的Layer,在新Layer上绘制的内容是独立的,不会影响到其他Layer的内容,调用canvas. restoreToCount(int sc)时将该Layer覆盖到Canvas现有的图像上。Canvas通过栈的形式管理Layer,示意图如下。

Layer.png

之前提到进行图像混合时,先绘制的内容是DST,后绘制的是SRC。如果不新建Layer的话,在绘制SRC时,Canvas上的所有内容都会被当作DST,所以背景等内容也会参与图像混合,很容易得到错误的效果。

以上就是图像混合的基本介绍,图像混合的应用场景比较广泛,这里介绍几种常见的场景。

2.5.1 图像切割

图像切割用于将图像切割成特定的形状。可以是常见形状如圆形或圆角矩形,也可以切割为五角星这样的非常规形状,使用这一类非常规形状时需要该形状的底图。

将图像裁剪为圆角矩形时比较简单,在onDraw(Canvas)中新建图层,绘制圆角矩形作为DST,再绘制原图作为SRC即可,此时图像混合模式应设置为SRC_IN,代码如下,decodeSampledBitmapFromResource(...)就是1.2节的大图采样。

public class RoundCornerView extends View {

    private Paint mPaint;
    private PorterDuffXfermode mFerMode;
    private Bitmap mBitmap;
    private Rect mBitmapRect;
    private int mWidth;
    private int mHeight;

    // 省略构造函数...

    private void init() {
        mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mFerMode = new PorterDuffXfermode(PorterDuff.Mode.SRC_IN);
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        if (mWidth != w || mHeight != h) {
            mWidth = w;
            mHeight = h;
            mBitmap = BitmapUtils.decodeSampledBitmapFromResource(
                    getContext().getResources(), R.drawable.compress_test, mWidth, mHeight);
            mBitmapRect = new Rect(0, 0, mWidth, mHeight);
            invalidate();
        }
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        int sc = canvas.saveLayer(0, 0, mWidth, mHeight, null);
        canvas.drawRoundRect(0, 0, mWidth, mHeight, 50, 50, mPaint);
        mPaint.setXfermode(mFerMode);
        canvas.drawBitmap(mBitmap, null, mBitmapRect, mPaint);
        mPaint.setXfermode(null);
        canvas.restoreToCount(sc);
    }

    ......
}

最终效果如下,同理可以将图像切割为圆形等基础形状。

切割为圆角矩形.jpg

如果要将图像切割为五角星这样的图案,就需要使用一张五角星的底图,需要注意的是,底图上五角星以外的部分应该是透明的,否则切割出来还是原来的形状。其代码与切割为圆角矩形大同小异,只需要将绘制圆角矩形的部分换成绘制五角星即可。

public class StarPicView extends View {

    private Paint mPaint;
    private PorterDuffXfermode mMode;
    private Bitmap mBgBitmap;
    private Bitmap mBitmap;
    private int mWidth, mHeight;
    private Rect mDrawRect;

    // 省略构造函数......

    private void init() {
        mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mMode = new PorterDuffXfermode(PorterDuff.Mode.SRC_IN);
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        if (mWidth != w || mHeight != h) {
            mWidth = w;
            mHeight = h;
            mBgBitmap = BitmapUtils.decodeSampledBitmapFromResource(
                    getContext().getResources(), R.drawable.star4, mWidth, mHeight);
            mBitmap = BitmapUtils.decodeSampledBitmapFromResource(
                    getContext().getResources(), R.drawable.icon3, mWidth, mHeight);
            mDrawRect = new Rect(0, 0, mWidth, mHeight);
            invalidate();
        }
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        int sc = canvas.saveLayer(0, 0, mWidth, mHeight, null);
        canvas.drawBitmap(mBgBitmap, null, mDrawRect, mPaint);
        mPaint.setXfermode(mMode);
        canvas.drawBitmap(mBitmap, null, mDrawRect, mPaint);
        mPaint.setXfermode(null);
        canvas.restoreToCount(sc);
    }
}

最终的效果如下。

切割为五角星.jpg

2.5.2 色彩合成

色彩合成可以为图片添加新的效果,当使用纯色与照片混合时,可以改变图片整体的色调。例如黄色可以让图片具有泛黄的怀旧效果,红色可以让图片更温暖。以下代码通过SCREEN混合模式将半透明的红色与图片混合。

public class ColorComposeView extends View {

    ......

    private void init() {
        setLayerType(LAYER_TYPE_SOFTWARE, null);
        mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mMode = new PorterDuffXfermode(PorterDuff.Mode.SCREEN);
    }

    ......

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        canvas.drawColor(Color.WHITE);
        int sc = canvas.saveLayer(0, 0, mWidth, mHeight, null);
        canvas.drawColor(0x44FF0000);
        mPaint.setXfermode(mMode);
        canvas.drawBitmap(mBitmap, null, mRect, mPaint);
        mPaint.setXfermode(null);
        canvas.restoreToCount(sc);
    }
}

怎么样,是不是觉得小姐姐看上去都温柔了一些?

色彩混合.jpeg

出了纯色混合,也可以将两张图片进行合成,例如通过一张毛玻璃的底图,可以为照片添加一定的模糊效果,底图如下所示。

毛玻璃底图.jpeg

绘制时将底图作为DST,将照片作为SRC,混合模式使用OVERLAY,代码与绘制切割五角星的大同小异,不再赘述。最终得到如下的效果。

图像合成之模糊效果.jpg

2.5.3 图像alpha渐变

之前遇到一个很有意思的UI需求,给定一张底图,要求绘制时图像的透明度从上到下是渐变的1-0,效果如下所示。因为background是白色,所以透明部分透出来的是白色。

图像渐变.jpg

这个其实也很简单,在本地新建一个渐变的drawable,然后通过XOR模式进行混合,具体代码可参考文章开头代码。

三、图片压缩

3.1 质量压缩

质量压缩减小的是图片在磁盘上的体积大小,通过Bitmap.compress(CompressFormat, quality, outputStream)将Bitmap保存到本地时,可以选择对应的文件格式以及质量标准,文件格式包含JPEG、PNG和WEBP三种,质量的取值为0-100,0代表最差质量,100代表最高质量。WEBP格式将会在API30被弃用,取而代之的是WEBP_LOSSLESS和WEBP_LOSSY,用于更清晰地描述是无损压缩还是有损压缩。

将Bitmap保存为60质量标准的jpeg的代码如下。

private void qualityCompressJPG() {
    OutputStream os = getOutputStreamByName("jpgFile60.jpeg");
    if (os != null) {
        mOriginBitmap.compress(Bitmap.CompressFormat.JPEG, 60, os);
    }
}

private OutputStream getOutputStreamByName(String fileName) {
    BufferedOutputStream bos = null;
    File dir = new File(FILE_DIR);
    boolean dirExist = true;
    if (!dir.exists()) {
        dirExist = dir.mkdirs();
    }
    if (dirExist) {
        File file = new File(dir, fileName);
        if (file.exists()) {
            file.delete();
        }
        try {
            boolean fileExist;
            fileExist = file.createNewFile();
            if (fileExist) {
                bos = new BufferedOutputStream(new FileOutputStream(file));
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
    return bos;
}

三种图片格式中,PNG为无损压缩,因此当文件格式选择PNG时Bitmap.compress(CompressFormat, quality, outputStream)方法会无视quality参数。下图为保存同一张图片时,选择不同的格式与不同质量的对比。可以发现WEBP格式所占用的空间是比较理想的,如果APP的体积比较大,可以考虑把资源文件转化为WEBP来节省空间。

质量压缩.jpeg

3.2 尺寸压缩

尺寸压缩就是指压缩原始Bitmap的宽高,通过减小像素个数来减小Bitmap占用的空间,这种压缩方式下,不管Bitmap所占的内存,还是图片保存到磁盘上所占的空间都会有显著的减小。

尺寸压缩可以通过Bitmap.createScaledBitmap(Bitmap src, int dstWidth, int dstHeight, boolean filter)来创建压缩后的Bitmap,filter参数可以简单地理解为,如果为true的话就会消耗更长的时间获得更好的图片质量,false则相反。

private void sizeCompress1(int scale) {
    int width = mOriginBitmap.getWidth() / scale;
    int height = mOriginBitmap.getHeight() / scale;
    Bitmap b = Bitmap.createScaledBitmap(
                    mOriginBitmap, width, height, false);
    OutputStream os = getOutputStreamByName("sizeCompress1.webp");
    if (os != null) {
        b.compress(Bitmap.CompressFormat.WEBP, 100, os);
    }
}

通过Bitmap b = Bitmap.createScaledBitmap(src, w, h, filter)来创建Bitmap时,它的Config是基于原始Bitmap的,如果原始Bitmap的Config是ARGB_8888而压缩后又不需要这么高的清晰度,那么可以选择新建RGB_565的Bitmap,并通过Canvas将压缩后的图片绘制上去。

private void sizeCompress2(int scale) {
    int width = mOriginBitmap.getWidth() / scale;
    int height = mOriginBitmap.getHeight() / scale;
    Bitmap b = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565);
    Canvas canvas = new Canvas(b);
    Rect rect = new Rect(0, 0, width, height);
    canvas.drawBitmap(mOriginBitmap, null, rect, null);
    OutputStream os = getOutputStreamByName("sizeCompress2_565.webp");
    if (os != null) {
        b.compress(Bitmap.CompressFormat.WEBP, 100, os);
    }
}

如果第2种方式选择ARGB_8888,创建出来的Bitmap其所占的内存(通过Bitmap.getByteCount()计算)以及保存到磁盘上的大小都与第1种方式相同。如果选择RGB_565,相比于第1种方式,Bitmap所占的内存会缩小一半,保存到磁盘上后的体积也会适当减小。

参考&推荐阅读

  1. Android自定义控件其实很简单
  2. Android动态模糊实现的研究