最近一个需求要实现类似微信状态的模糊效果,还要求不能引入库,增加包的大小。网上搜了一圈,只有 Flutter 的实现。没办法只能自己开撸,实现效果如下,上面的图是我的实现效果,下面的是微信的实现效果。

Android仿微信的图片编辑 仿真微信图片_微信

Android仿微信的图片编辑 仿真微信图片_java_02

实现原理

首先,我们观察一下下面的微信状态的实现效果。可以看出上部分是截取了头发部分进行了高斯模糊;而下面部分则是对围裙进行高斯模糊。

Android仿微信的图片编辑 仿真微信图片_Android仿微信的图片编辑_03

拿原图进行对比,我们可以发现,渐变高斯模糊的部分遮住了原图片,同时还有渐变的效果。最后,图片好像加了一层灰色的遮罩,整体偏灰。

接下来,我们要做的事情就清楚了。

第一步:选取原图片的上下两部分分别进行高斯模糊 第二步:自定义 OnDraw 方法,让高斯模糊的部分覆盖原图片的上下两部分 第三步:让高斯模糊的图片实现渐变效果

选取原图片的上下两部分分别进行高斯模糊

在开始高斯模糊前,我们需要先确定上下两部分的高度。需要注意的是,我们不能直接使用图片的高度,因为图片的宽不一定等于屏幕的宽度。因此,我们需要按照比例计算出图片缩放后的高度。代码如下:

//最后要求显示的图片宽度为屏幕宽度
int requireWidth = UIUtils.getScreenWidth(context);
int screenHeight = UIUtils.getScreenHeight(context);
//按照比例,计算出要求显示的图片高度
int requireHeight = requireWidth * source.getHeight() / source.getWidth();
int topOrBottomBlurImageHeight = (int) ((screenHeight - requireHeight) / 2 + requireHeight * 0.25f);

如下图所示,最后一步 (screenHeight - requireHeight) / 2 获取到缩放后的图片居中时的上下两部分的高度。但是,渐变高斯模糊的部分还需要增加 padding 来遮住原图片的部分内容,这里的 padding 取的是 requireHeight * 0.25f

Android仿微信的图片编辑 仿真微信图片_微信_04

计算出高度后,我们还不能对图片直接进行高斯模糊,要先要对图片进行缩放。为什么要先进行压缩呢?有两点原因:

  1. 使用 RenderScript 进行高斯模糊,最大模糊半径是 25,模糊效果不理想
  2. 高斯模糊的半径超过 10 之后就有性能问题

为了解决上面的问题,我们需要先对图片进行缩放,再进行高斯模糊。核心代码如下,为了后面使用协程,这里是用 kotlin 实现的。

private val filter = PorterDuffColorFilter(Color.argb(140, 0, 0, 0), PorterDuff.Mode.SRC_ATOP)

private fun blurBitmap(
    source: Bitmap,
    radius: Int,
    top: Boolean,
    topOrBottomBlurImageHeight: Int,
    screenHeight: Int,
    context: Context?
    ): Bitmap? {

        //第1部分
        val cutImageHeight = topOrBottomBlurImageHeight * source.height / screenHeight
        val sampling = 30

        //第2部分
        val outBitmap = Bitmap.createBitmap(source.width / sampling,
        cutImageHeight / sampling, Bitmap.Config.ARGB_8888)
        val canvas = Canvas(outBitmap)
        canvas.scale(1 / sampling.toFloat(), 1 / sampling.toFloat())
        val paint = Paint()
        paint.flags = Paint.FILTER_BITMAP_FLAG or Paint.ANTI_ALIAS_FLAG
        //过滤颜色值
        paint.colorFilter = filter
        val dstRect = Rect(0, 0, source.width, cutImageHeight)
        val srcRect: Rect = if (top) {//截取顶部
            Rect(0, 0, source.width, cutImageHeight)
        } else {//截取底部
            Rect(0, source.height - cutImageHeight, source.width, source.height)
        }
        canvas.drawBitmap(source, srcRect, dstRect, paint)

        //高斯模糊
        val result = realBlur(context, outBitmap, radius)

        //创建指定大小的新 Bitmap,内部会对传入的原 Bitmap 进行拉伸
        val scaled = Bitmap.createScaledBitmap(
            result,
            (source.width),
            (cutImageHeight),
            true)
            return scaled
        }

代码看不懂?没关系,下面会一一来讲解:

第1部分,这里定义了两个本地变量 cutImageHeightsamplingcutImageHeight 是要裁剪图片的高度,sampling 是缩放的比例。你可能会奇怪 cutImageHeight 的计算方式。如下图所示,cutImageHeight 是用 topOrBottomBlurImageHeight 占屏幕高度的比例计算的,目的是让不同的图片裁剪的高度不同,这也是微信状态模糊的效果。如果你想固定裁剪比例,完全可以修改 cutImageHeight 的计算方式。

Android仿微信的图片编辑 仿真微信图片_开发语言_05

第2部分,这里就做了一件事,就是截取原图的部分并压缩。这里比较难理解的就是为什么创建 Bitmap 时,它的宽高已经缩小了,但是还需要调用 canvas.scale。其实,canvas.scale 只会作用于 canvas.drawBitmap 里的原 Bitmap

高斯模糊这里可以采取你项目里之前使用的方式就行,如果之前没做过高斯模糊,可以看Android图像处理 - 高斯模糊的原理及实现。这里使用的是 Google 原生的方式,代码如下:

@Throws(RSRuntimeException::class)
private fun realBlur(context: Context?, bitmap: Bitmap, radius: Int): Bitmap {
    var rs: RenderScript? = null
    var input: Allocation? = null
    var output: Allocation? = null
    var blur: ScriptIntrinsicBlur? = null
    try {
        rs = RenderScript.create(context)
        rs.messageHandler = RenderScript.RSMessageHandler()
        input = Allocation.createFromBitmap(
            rs, bitmap, Allocation.MipmapControl.MIPMAP_NONE,
            Allocation.USAGE_SCRIPT
        )
        output = Allocation.createTyped(rs, input.type)
        blur = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs))
        blur.setInput(input)
        blur.setRadius(radius.toFloat())
        blur.forEach(output)
        output.copyTo(bitmap)
    } finally {
        rs?.destroy()
        input?.destroy()
        output?.destroy()
        blur?.destroy()
    }
    return bitmap
}

还有一点细节,由于我们给高斯模糊的图片加了 filter ,为了保持一致性。我们也需要给原 Bitmap 进行过滤。代码如下:

private fun blurSrc(bitmap: Bitmap): Bitmap? {
    if (bitmap.isRecycled) {
        return null
    }
    val outBitmap = Bitmap.createBitmap(bitmap.width, bitmap.height, Bitmap.Config.ARGB_8888)
    val canvas = Canvas(outBitmap)
    val paint = Paint()
    paint.flags = Paint.FILTER_BITMAP_FLAG or Paint.ANTI_ALIAS_FLAG
    paint.colorFilter = filter
    canvas.drawBitmap(bitmap, 0f, 0f, paint)
    return outBitmap
}

最后,我们可以使用协程来获取处理后的 Bitmap ,代码如下

fun wxBlurBitmap(source: Bitmap, topOrBottomBlurImageHeight: Int, screenHeight: Int, context: Context?, imageView: BlurImageView) {
    if(source.isRecycled) {
        return
    }
    GlobalScope.launch(Dispatchers.Default) {
        val time = measureTimeMillis {
            val filterBitmap = async {
                blurSrc(source)
            }
            val topBitmap = async {
                blurBitmap(source, 10, true, topOrBottomBlurImageHeight, screenHeight, context)
            }
            val bottomBitmap = async {
                blurBitmap(source, 10, false, topOrBottomBlurImageHeight, screenHeight, context)
            }
            val src = filterBitmap.await()
            val top = topBitmap.await()
            val bottom = bottomBitmap.await()
            launch(Dispatchers.Main) {
                if(top == null || bottom == null) {
                    imageView.setImageBitmap(source)
                } else {
                    imageView.setBlurBitmap(src, top, bottom, topOrBottomBlurImageHeight)
                    
                }

            }
        }
    }
}

自定义 ImageView

上面的操作,我们获得了3个 Bitmap,要把它们正确的摆放就需要我们自定义一个 ImageView。如果对自定义 View 不了解的话,可以看看扔物线大佬的 Hencoder 的自定义View系列 教程。代码如下:

public class BlurImageView extends androidx.appcompat.widget.AppCompatImageView {

    private Bitmap mSrcBitmap;
    private Bitmap mTopBlurBitmap;
    private Bitmap mBottomBlurBitmap;
    private Matrix mDrawMatrix;
    private Paint mPaint;
    private Shader mTopShader;
    private Shader mBottomShader;
    private PorterDuffXfermode mSrcPorterDuffXfermode;
    private PorterDuffXfermode mBlurPorterDuffXfermode;
    private int mTopOrBottomBlurImageHeight;

    public BlurImageView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        init();
    }

    /**
     * 设置图片
     * @param src 原图片的 Bitmap
     * @param top 原图片top部分的 Bitmap
     * @param bottom 原图片bottom部分的 Bitmap
     * @param topOrBottomBlurImageHeight 模糊图片要求的高度
     */
    public void setBlurBitmap(Bitmap src, Bitmap top, Bitmap bottom, int topOrBottomBlurImageHeight) {
        this.mSrcBitmap = src;
        this.mTopBlurBitmap = top;
        this.mBottomBlurBitmap = bottom;
        this.mTopOrBottomBlurImageHeight = topOrBottomBlurImageHeight;
        invalidate();
    }

    private void init() {
        mPaint = new Paint();
        mDrawMatrix = new Matrix();
        mPaint.setAntiAlias(true);
        mSrcPorterDuffXfermode = new PorterDuffXfermode(PorterDuff.Mode.DST_ATOP);
        mBlurPorterDuffXfermode = new PorterDuffXfermode(PorterDuff.Mode.DST_ATOP);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        if(mSrcBitmap == null || mTopBlurBitmap == null || mBottomBlurBitmap == null) {
            super.onDraw(canvas);
            return;
        }
        if(mSrcBitmap.isRecycled() || mTopBlurBitmap.isRecycled() || mBottomBlurBitmap.isRecycled()) {
            mSrcBitmap = null;
            mTopBlurBitmap = null;
            mBottomBlurBitmap = null;
            return;
        }

        int save = canvas.saveLayer(null, null, Canvas.ALL_SAVE_FLAG);

        //第1部分
        final int srcWidth = mSrcBitmap.getWidth();
        final int srcHeight = mSrcBitmap.getHeight();
        final int topWidth = mTopBlurBitmap.getWidth();
        final int topHeight = mTopBlurBitmap.getHeight();
        final int contentWidth = getWidth() - getPaddingLeft() - getPaddingRight();
        final int contentHeight = getHeight() - getPaddingTop() - getPaddingBottom();
        float scrBitmapScale =  (float) contentWidth / (float) srcWidth;
        float srcTopOrBottomPadding = (contentHeight - srcHeight * scrBitmapScale) * 0.5f;
        int requireBlurHeight = mTopOrBottomBlurImageHeight;
        float overSrcPadding = requireBlurHeight - srcTopOrBottomPadding;//要求的模糊图片的高度
        float dx = 0;//缩放后的模糊图片的x方向的偏移
        float dy = 0;//缩放后的模糊图片的y方向的偏移
        float blurScale = 0;//高斯模糊图片的缩放比例
        if(requireBlurHeight * topWidth >= topHeight * contentWidth) {
            //按照高缩放
            blurScale = (float) requireBlurHeight / (float) topHeight;
            dx = (contentWidth - topWidth * blurScale) * 0.5f;
        } else {
            //按照宽缩放,因为按照高缩放时,当前Bitmap无法铺满
            blurScale = (float) contentWidth / (float) topWidth;
            dy = (requireBlurHeight - topHeight * blurScale) * 0.5f;
        }

        //第2部分
        //绘制上面模糊处理后的图片
        if(mTopShader == null) {
            mTopShader = new LinearGradient((float) contentWidth / 2, requireBlurHeight, (float) contentWidth / 2, srcTopOrBottomPadding, new int[]{
                    0x00FFFFFF,
                    0xFFFFFFFF
            }, null, Shader.TileMode.CLAMP);
        }
        mPaint.setShader(mTopShader);
        mDrawMatrix.setScale(blurScale, blurScale);
        mDrawMatrix.postTranslate(Math.round(dx), Math.round(dy));
        canvas.drawBitmap(mTopBlurBitmap, mDrawMatrix, null);
        mPaint.setXfermode(mBlurPorterDuffXfermode);
        canvas.drawRect(0, srcTopOrBottomPadding, contentWidth, requireBlurHeight, mPaint);
        //绘制下面模糊处理后的图片
        float padding = contentHeight - requireBlurHeight;
        mDrawMatrix.setScale(blurScale, blurScale);
        mDrawMatrix.postTranslate(Math.round(dx), Math.round(padding + dy));
        canvas.drawBitmap(mBottomBlurBitmap, mDrawMatrix, null);
        if(mBottomShader == null) {
            mBottomShader = new LinearGradient((float) contentWidth/2, padding + overSrcPadding, (float) contentWidth/2, padding, new int[]{
                    0xFFFFFFFF,
                    0x00FFFFFF
            }, null, Shader.TileMode.CLAMP);
        }
        mPaint.setShader(null);
        mPaint.setShader(mBottomShader);
        canvas.drawRect(0, padding + overSrcPadding, contentWidth, padding, mPaint);

        //绘制中间的原图
        mPaint.setShader(null);
        mPaint.setXfermode(mSrcPorterDuffXfermode);
        float srcScale =  (float) contentWidth / (float) srcWidth;
        mDrawMatrix.setScale(srcScale, srcScale);
        mDrawMatrix.postTranslate(0, Math.round(srcTopOrBottomPadding));
        canvas.drawBitmap(mSrcBitmap, mDrawMatrix, mPaint);
        canvas.restoreToCount(save);
    }
}

BlurImageView 得核心代码在 onDraw 里面。我们按照上面注释的顺序,一个一个来分析:

第1部分,我们声明了几个变量,用来辅助计算。为了方便理解,我画了如下示意图:

Android仿微信的图片编辑 仿真微信图片_缩放_06

srcTopOrBottomPadding: 是原图按照比例缩放、居中摆放时空白的高度 overSrcPadding: 是模糊图片遮罩原图片的高度,也就是渐变模糊图片的高度 dx: 按照高度缩放时,缩放后的模糊图片的x方向的偏移 dy: 按照宽缩放时,缩放后的模糊图片的y方向的偏移 blurScale: 图上没有标出,是高斯模糊图片的缩放比例。确保高斯模糊的图片能够铺满

第2部分,这里的作用是绘制上下两部分的模糊图片,并对图片的部分进行渐变处理。以上面部分的图片为例,第一步先绘制已经处理好的 mTopBlurBitmap,这里设置了 Matrix ,在绘制过程中会对图片进行缩放和移动,让图片的位置摆放正确。第二步就是对部分图片进行渐变处理,这里合成模式选择了 DST_ATOP