Android平台毛玻璃UI效果实现原理初探

96
JimmieZhou
0.3 2016.12.15 20:56* 字数 2173

自从苹果开始在iOS系统里采用毛玻璃这种UI效果后,很多手机应用的界面设计都开始引入毛玻璃效果了。其实对于移动端开发人员而言,实现一个毛玻璃效果并不难,Android端有RenderScript、StackBlur等,网上很多经验丰富的工程师也给出了各种解决方案(Android 5.0 下毛玻璃(磨砂)效果如何实现?),所以今天这篇博客不会着重介绍怎样又快又好地实现毛玻璃效果,只会介绍一下模糊效果的大概原理。急着实现效果的同学可以移步Github,哈哈。

首先,简单说几个概念,我们平时实现的毛玻璃效果,基本都是通过对一幅图片进行预处理、高斯模糊实现的。也正如我们所看到的毛玻璃效果那样,实现毛玻璃效果的关键就是让图片变模糊

朴素的模糊思想

相信大部分人都知道一个图片在Android端是怎样表示的,就是Drawable。其中Drawable作为一个抽象基类下面又派生出很多具体的图片类,如ColorDrawable、BitmapDrawable、LayerDrawable、ShapeDrawable等等,其中BitmapDrawable是根据一个像素矩阵显示对应的图像的,也是今天进行模糊处理的主角。

Drawable.png

每个BitmapDrawable内部都持有一个Bitmap引用,这个Bitmap类盛放着像素矩阵。其中每个像素有自己的颜色,而这个像素点的具体颜色的表示形式又取决于Bitmap的Config。

Bitmap的Config有以下几种:

  • ARGB_8888
    大部分图像的像素点的存储形式都是ARGB_8888。这种格式的图像中每个像素点有4个颜色通道,其中A表示alpha,也就是透明通道,另外3个通道RGB则分别表示三原色:红、绿和蓝。这4个通道每个各占8bit,也就是一个字节。4个通道一共占4个字节,也就是一个int类型的大小。所以对于ARGB型的Bitmap,我们可以用一个int型的变量来表示其中的一个像素点。
  • ARGB_4444
    每个通道占4bit,一个像素点占2个字节。已经被Google标注为Deprecated,理由是图像质量太差。
  • RGB_565
    透明通道被去掉,3个通道共占2个字节。这个格式还是会用到的,因为大部分图片是不会用到透明通道的,我们可以通过使用这种格式来降低图片尺寸,进一步降低内存占用率。
  • ALPHA_8
    没有红绿蓝的通道,一般用于表示掩码。

以上是Bitmap.Config的枚举值,实际在图像处理中我们还会经常用到另一种图像,就是灰度图。对于一副灰度图,每个像素点只有一个灰度通道,灰度值的取值范围从0到255,从一副RGB图转化到灰度图很简单,就是每个像素点的灰度取R、G、B的平均值。比如一个绿色的像素点0xFF00FF00,对应的灰度值就是

(0 + 255 + 0) / 3 = 85

以ARGB_8888为例,下图说明一个像素点各通道是怎样存储的。

pixel_matrix.png

从上图可以看到,一个像素周围有8个邻接像素。那么,想要使图片变得模糊,一个很简单的想法就是:

让这个像素的颜色值取周围9个像素的平均值。

那么对于原图片P到模糊之后的图片Q,对于灰度图,我们可以有以下公式:

q(i,j)表示第i行,第j列被模糊化处理之后的像素。p(k,l)表示原图中的像素点。

说白了就是对上图中红框框里的像素点(也把红框框叫”盒子“)取平均值,得到的值就是3x3的小方块中心那个像素点的新的灰度值。刚才说的是对灰度图的处理,对于ARGB图像的处理也相似,可以分别对每个通道求平均值,然后合在一起。

还有一个问题,对于一个Bitmap非边界处的像素点,可以找出3x3的小方块并求出平均值,但是对于边界处,3x3的小方块必然会越界,可以有多种处理方案,一是收缩小方块,比如对于q11,把方块缩小到2x2。也可以在越界处复制一些"虚拟像素",将越界的像素赋值为方块中心的像素的颜色值。

按照以上的想法,可以在Android平台写出以下代码来对一个ImageView所包含的BitmapDrawable进行模糊化处理:

    private class MeanFilterTask extends AsyncTask<Void, Double, Void>{
        private Bitmap bitmap;//源Bitmap
        private Bitmap target;//模糊后的Bitmap

        public MeanFilterTask(ImageView imageViewTarget){
            super();
            Drawable drawable = imageViewTarget.getDrawable();
            bitmap = ((BitmapDrawable) drawable).getBitmap();
            bitmap = Bitmap.createScaledBitmap(bitmap, bitmap.getWidth() / 10, bitmap.getHeight() / 10, false);//缩小图片,减少运算量,改善模糊效果
        }

        @Override
        protected void onPreExecute() {
            super.onPreExecute();
            target = Bitmap.createBitmap(bitmap.getWidth(), bitmap.getHeight(), bitmap.getConfig());
        }

        @Override
        protected Void doInBackground(Void... params) {
            meanBlur(bitmap, target);//均值模糊
            return null;
        }

        @Override
        protected void onProgressUpdate(Double... values) {
            super.onProgressUpdate(values);
        }

        @Override
        protected void onPostExecute(Void aVoid) {
            super.onPostExecute(aVoid);
            imageTarget.setImageDrawable(new BitmapDrawable(getResources(), target));
        }
    }

    /**
     * 均衡模糊
     * @param src 源Bitmap
     * @param target 目标Bitmap
     */
    private void meanBlur(Bitmap src, Bitmap target) {
        if (src.getConfig() != Bitmap.Config.ARGB_8888) {//简化考虑,只支持ARGB8888
            return;
        }

        int boxWidth = 3;// 定义一个3x3的盒子,对盒子内的像素点取平均
        int boxHeight = 3;
        for (int i = 0; i < src.getHeight(); i ++){
            for (int j = 0; j < src.getWidth(); j ++){
                int meanPixel = filter(boxWidth, boxHeight, i, j, src, new MeanFilter(boxHeight, boxWidth));// 求平均值
                target.setPixel(j, i, meanPixel);// 写入像素点
            }
        }
    }

    /**
     * 根据滤波模板进行滤波
     * @param boxWidth  盒子宽度(此处为3)
     * @param boxHeight 盒子高度(此处为3)
     * @param rowIndex targetBitmap的目标像素点在第xx行
     * @param colIndex targetBitmap的目标像素点在第xx列
     * @param src 源Bitmap
     * @param filter 滤波模板
     * @return
     */
    private int filter(int boxWidth, int boxHeight, int rowIndex, int colIndex, Bitmap src, Filter filter){
        if ( boxWidth % 2 == 0 || boxHeight % 2 == 0)
            return 0;

        int targetPixel = 0xff000000;//计算的结果
        int redSum = 0;
        int greenSum = 0;
        int blueSum = 0;
        int temp;

        for (int i = rowIndex - boxHeight / 2, boxRow = 0; i <= rowIndex + boxHeight / 2; i ++, boxRow ++){
            for (int j = colIndex - boxWidth / 2, boxCol = 0; j <= colIndex + boxWidth / 2; j ++, boxCol ++){
                if (i < 0 || i >= src.getHeight() || j < 0 || j >= src.getWidth()) //越界
                    temp = src.getPixel(colIndex, rowIndex);
                else
                    temp = src.getPixel(j, i);//依次取出盒子内的像素点
                redSum += ((temp & 0x00ff0000) >> 16) * filter.weight(boxRow, boxCol);//求均值,先计算sum,对于 均值模糊, 这里的weight为1
                greenSum += ((temp & 0x0000ff00) >> 8) * filter.weight(boxRow, boxCol);//求均值,先计算sum,对于 均值模糊, 这里的weight为1
                blueSum += (temp & 0x000000ff) * filter.weight(boxRow, boxCol);//求均值,先计算sum,对于 均值模糊, 这里的weight为1
            }
        }

        int meanRed = ((int) (redSum * 1.0 / filter.total() )) << 16;//ARGB red通道需要左移16bit归位,对于 均值模糊, 这里的total为9
        int meanGreen = ((int) (greenSum * 1.0 / filter.total() )) << 8;//ARGB green通道需要左移8bit归位,对于 均值模糊, 这里的total为9
        int meanBlue = ((int) (blueSum * 1.0 / filter.total() ));//,对于 均值模糊, 这里的total为9
        targetPixel = (targetPixel | meanRed | meanGreen | meanBlue);//或运算 将3个求均值的结果合一
        return targetPixel;
    }

以上代码写了注释,相信不难看懂。这里面我又引入了一个滤波模板的概念,也就是Filter接口。相关代码如下:

/**
 * Created by zjl on 2016/12/13.
 * 滤波模板
 */
public interface Filter {
    int weight(int rowIndex, int colIndex);//盒子元素与滤波模板元素做乘积最后求平均,在均值模糊中,这里的weight为1

    int total();//前面求出sum然后除以total求出最后的平均颜色值
}

/**
 * Created by zjl on 2016/12/13.
 * 平均模糊的滤波模板
 */
class MeanFilter implements Filter {

    private int width;//盒子的宽度,文章假设为3x3的盒子
    private int height;

    public MeanFilter(int boxHeight, int boxWidth) {
        this.height = boxHeight;
        this.width = boxWidth;
    }

    @Override
    public int weight(int rowIndex, int colIndex) {
        return 1;
    }

    @Override
    public int total() {
        return width * height;
    }
}

等会再说滤波模板干啥用的,先看一下这种朴素的模糊方法的效果:

模糊对比

改良模糊效果

可以看到,模糊确实是有效果了,但是感觉还没有到毛玻璃的那种效果。实际上,这种模糊对盒子里的所有像素点一视同仁,这种模糊方法有些”粗暴“,为了得到更自然的平滑效果,我们可以适当加大盒子中心点的权重,降低盒子边界处像素点的权重。换言之,本来我们对盒子里的所有像素点取平均值,现在我们对每个像素点区别对待,给每个像素点乘上一个权重,靠近盒子中心的权重更大,远离中心的权重更小,最后再除以权重的总和来求出一个平均。

上面的公式看起来比较抽象,这里以一个3x3的模板举个例子:

这里给出的滤波模板的第一行是1 2 1,第二行是 2 4 2, 第三行是 1 2 1。其实这就是最常见的3x3的高斯模糊的模板。为什么叫高斯模糊呢,因为这个模板其实是二维高斯分布(也叫二维正态分布)在离散域的近似。二维高斯分布的公式长这个样子:

对应函数图像:

二维高斯分布

本来如果要按照这个公式实现高斯模糊的话,运算量就太大了,又有除法运算又有乘方运算的,但我们可以用二项分布去近似逼近高斯分布(正态分布)。现在返回去观察那个3x3的高斯滤波模板,其中第一行的1 2 1就是一个二项式展开。而整个3x3的模板的由来,其实就是一个二项式展开式的各个部分分别乘以这个展开式本身,我们把这个过程叫做一维滤波模板的卷积

卷积

有了这个近似逼近的方法就方便编程实现了。刚才在上面定义了一个滤波器接口,接下来需要写一个高斯滤波器类来实现那个接口。对于weight()方法,可以通过对二项展开式的卷积得到,对于total()方法,可以通过二项式的定理得到是一个二次幂:

以下是高斯模糊的代码实现:


private class GaussFilterTask extends AsyncTask<Void, Double, Void>{
        private Bitmap bitmap;
        private Bitmap target;

        public GaussFilterTask(){
            super();
            Drawable drawable = imageTarget.getDrawable();
            bitmap = ((BitmapDrawable) drawable).getBitmap();
            bitmap = Bitmap.createScaledBitmap(bitmap, bitmap.getWidth()/20, bitmap.getHeight()/20,false);
        }

        @Override
        protected void onPreExecute() {
            super.onPreExecute();
            target = Bitmap.createBitmap(bitmap.getWidth(), bitmap.getHeight(), bitmap.getConfig());
        }

        @Override
        protected Void doInBackground(Void... params) {
            gaussBlur(bitmap, target);//调用高斯模糊方法
            return null;
        }

        @Override
        protected void onProgressUpdate(Double... values) {
            super.onProgressUpdate(values);
        }

        @Override
        protected void onPostExecute(Void aVoid) {
            super.onPostExecute(aVoid);
            imageTarget.setImageDrawable(new BitmapDrawable(getResources(), target));
        }
    }

    private void gaussBlur(Bitmap src, Bitmap target) {
        if (src.getConfig() != Bitmap.Config.ARGB_8888) {//简化考虑,只支持ARGB8888格式的Bitmap,即 透明、红、绿、蓝四个通道各占一个字节
            return;
        }
        
        int boxWidth = 7;//盒子大小为7x7
        int boxHeight = 7;
        GaussFilter filter = new GaussFilter(boxHeight, boxWidth);//实例化GaussFilter,并传递给filter()方法

        for (int i = 0; i < src.getHeight(); i ++){
            for (int j = 0; j < src.getWidth(); j ++){
                int meanPixel = filter(boxWidth, boxHeight, i, j, src, filter);

                target.setPixel(j, i, meanPixel);

            }

        }

    }

/**
 * Created by zjl on 2016/12/13.
 * 使用二项式分布逼近的高斯滤波器
 */
class GaussFilter implements Filter {

    private int width;
    private int height;

    public GaussFilter(int boxHeight, int boxWidth) {
        this.height = boxHeight;
        this.width = boxWidth;

    }

    @Override
    public int weight(int rowIndex, int colIndex) {
        int me = C(width - 1, colIndex);
        return me * C(height - 1, rowIndex);
    }

    @Override
    public int total() {
        int result = (int) Math.pow(2, width + height - 2);
        return result;
    }

    private int C(int n, int k){ //n次二项展开式,第k项
        if (k <= 0)
            return 1;
        if (k > n / 2)
            return C(n, n - k);

        int result = 1;
        for (int i = 1; i <= k; i++)
            result *= (n - i + 1);
        for (int i = 1; i <= k; i++)
            result /= i;

        return result;
    }
}

以上代码中,把模糊操作放到AsyncTask中是因为这是个耗时任务,图像的size一大很容易ANR。

在盒子大小为3x3时,缩放10倍时的效果:

3x3 缩放10倍

在盒子大小为3x3时,缩放20倍时的效果:

3x3 缩放20倍

在盒子大小为7x7时,缩放20倍时的效果:

7x7 缩放20倍

可以看到:

  • 对图像进行一定程度的缩放可以很大程度改善高斯模糊的效果。同时降低运算量。
  • 盒子大小则表示局部模糊的范围,盒子越小,对布局细节的保留相对越多,盒子越大,细节丢失地越严重。
  • 高斯模糊比起最初的均值模糊,模糊效果过渡得更”平滑“,不那么突兀。我们平时用得毛玻璃的效果的底层实现,基本上是高斯模糊或优化后的高斯模糊算法。

说明

最后再说明一下,这篇文章主要是为了介绍了模糊算法的一些基本原理,以上代码没有经过优化,不仅运算速度慢,而且容易出现OOM,完全没法用在工程中,只是演示一下效果罢了。

Android