QQ底部Tab栏高斯模糊效果源码解析

1,953 阅读10分钟
  • 前言

    前段时间QQ更新后发现下面的Tab栏添加了动态高斯模糊效果,众所周知,高斯模糊这玩意儿比较耗时,动态的模糊效果在安卓的APP中比较少见。在自己猜测了几种做法之后想知道QQ是怎么实现的,于是反编译了一下QQ的apk。

    鉴于我的逆向基础门都没入,属于只会用一个jadx查查16进制id这种,这里就不班门弄斧介绍了,感兴趣的可以自己去搜搜类似的文章看看。不过这里不得不说QQ的措施做得真好,它里面的所有控件id,资源id,layout的命名混淆后大部分是name,想通过查找id来寻找代码文件对我来说基本不可能,曾经反编译过网易云音乐的app,它就没有做这项措施,可以轻易的通过uiautomatorviewer工具查找到id后定位到具体的代码文件。下面可以看看最后的效果。

  • 效果

GIF.gif
gif图录制时会变糊成一团,下面再附一张图片
废话说完,下面就看看如何做到的吧

1.定位

首先可以知道,这个模糊的效果不是自定义view就是拿到具体视图模糊后给Tab当背景,这里可以使用uiautomatorviewer看看这个页面的层级和视图,如下图

图一
可以很清晰的看到在最下面的TabWidget下面还有个自定义View,但是id被混淆成了name,因而定位代码就基本不可能了(仅对我而说)。

2.反编译

既然知道这是个自定义View,那么我们就可以试着反编译APK去查找代码了,因为混淆后的自定义view的类名是不会变的。从上图可以看到该View的位于com.tencent.mobileqq下面,于是试着在这个包名下面寻找一下代码。 这里用最简单的jadx打开QQ的apk后就可以看到QQ混淆后的代码了。几十个包名这里就不上图演示了,鉴于对QQ团队的代码素养的信任,在mobileqq下面直接锁定的widget这个包,然后同样鉴于对QQ团队代码命名素养的信任,我着重寻找类似blur或者gauss这样的字眼,果然找到了两个文件QQBlurQQBlurView

图二
找到后就是苦力活了,因为代码是混淆的,需要把相关的代码倒腾出来再去分析。这里让我最蛋疼的就是这个QQBlur的代码了。

public  class QQBlur$1 implements Runnable{
private int a = -1;
/* renamed from: a */
final /* synthetic */ StackBlurManager f541a;
final /* synthetic */ azlc this$0;

public QQBlur$1(azlc azlc, StackBlurManager stackBlurManager) {
    this.this$0 = azlc;
    this.f541a = stackBlurManager;
}

public void run() {
    if (!this.this$0.f531b) {
        long elapsedRealtime = SystemClock.elapsedRealtime();
        if (!(this.a == -1 || this.a == azlc.a)) {
            this.this$0.a(this.a, azlc.a);
        }
        this.a = azlc.a;
        int i = azlc.a;
        Bitmap process = this.f541a.process(this.this$0.f531b);
        if (process != null) {
            this.this$0.f519a = process;
        } else {
            QLog.e("QQBlur", 1, "run: outBitmap is null. OOM ?");
        }
        long elapsedRealtime2 = SystemClock.elapsedRealtime();
        this.this$0.f531b;
        this.this$0.f = (elapsedRealtime2 - elapsedRealtime) + this.this$0.f;
        View a = this.this$0.f531b;
        if (a != null && this.this$0.f) {
            a.postInvalidate();
        }
    }
}

这里可以看到这个f531b(其实在混淆后这个命名是b,前面的531是jadx软件为了和其他b命名区分自己添加的)既可以是boolean也是process方法的int类型,下面还变成了View。 花了一个周末的时间对整个代码进行了逻辑分析和重新命名后,后面展示的代码就是根据我自己的理解重新命名的的类和变量名,如果有想看原混淆代码的可以自己去反编译,或者看我上传的一份。

3.代码分析

  • QQBlurView

先看看QQBlurView这个类的代码,下面代码为了方便查看和理解,是经过我自己的重新命名后的,想看混淆过的源码传送门这边走QQBlurView

@TargetApi(19)
public class QQBlurView extends View {
    //因为这个模糊效果只支持19以上,所以这个drawable是低于19的情况下显示的tab背景
    private Drawable mDefaultDrawable;

    private BlurPreDraw mBlurPreDraw = new BlurPreDraw(this);

    public QQBlurManager mManager = new QQBlurManager();

    private boolean mEnableBlur = true;
    ......

    protected void onDraw(Canvas canvas) {
        if (!isDrawCanvas()) {
            if (this.mEnableBlur) {
                setBackgroundDrawable(null);
                this.mManager.onDraw((View) this, canvas);
                super.onDraw(canvas);
                return;
            }
            setBackgroundDrawable(this.mDefaultDrawable);
            super.onDraw(canvas);
        }
    }

    protected void onAttachedToWindow() {
        super.onAttachedToWindow();
        if (this.mManager != null) {
            onAttached();
        }
    }

    protected void onDetachedFromWindow() {
        super.onDetachedFromWindow();
        if (this.mManager != null) {
            onDetached();
        }
    }

    public void onAttached() {
        Log.d("QQBlurView", "onResume() called");
        this.mManager.onResume();
    }

    public void onDetached() {
        Log.d("QQBlurView", "onPause() called");

        this.mManager.onPause();
    }

    public void onDestroy() {
        getViewTreeObserver().removeOnPreDrawListener(this.mBlurPreDraw);
        this.mManager.onDestroy();
    }

    public void setTargetView(View view) {
        this.mManager.setTargetView(view);
    }

    public void setBlurView(View view) {
        this.mManager.setBlurView(view);
    }

    public void onCreate() {
        getViewTreeObserver().addOnPreDrawListener(this.mBlurPreDraw);
        this.mManager.onCreate();
    }
    public boolean isDrawCanvas() {
        return this.mManager.isDrawCanvas();
    }
}

这里可以看到在onDraw中如果需要模糊效果的话就将模糊的具体操作都交给了QQBlurManager,下面还需要注意的点是外界需要传两个View进来,一个是本身这个QQBlurView,另一个暂且记做targetView,后面manager中会用到。

  • BlurPreDraw

在创建完成后需要注册OnPreDrawListener这个监听器,该方法主要作用为:将绘制视图树时执行的回调函数。这时所有的视图都测量完成并确定了框架。 客户端可以使用该方法来调整滚动边框,甚至可以在绘制之前请求新的布局。

public class BlurPreDraw implements ViewTreeObserver.OnPreDrawListener {
    final  QQBlurView blurView;
    public BlurPreDraw(QQBlurView qQBlurView) {
        this.blurView = qQBlurView;
    }

    public boolean onPreDraw() {
        if (this.blurView.mManager!=null) {
             return this.blurView.mManager.onPreDraw();
        }
        return true;
    }
}
  • BlurPreDraw

由于这个页面代码过多,这里就不全部展示了,从前面可以看到,视图注册了PreDraw监听器,而后调用了QQBlurManageronPreDraw方法

    public boolean onPreDraw() {
        boolean isDirty = false;
        if (this.mTargetView != null) {
            isDirty = this.mTargetView.isDirty();
        }
        View view = this.mBlurView;
        if (!this.isDetachToWindow && isDirty && view != null && view.getVisibility() == View.VISIBLE) {
            preDrawCanvas();
            view.invalidate();
        }
        return true;
    }
    混淆后:
    public boolean m104a() {
        boolean z = false;
        if (this.f526a != null) {
            z = this.f526a.a();
        } else if (this.f525a != null) {
            z = this.f525a.isDirty();
        }
        View view = this.f533b;
        if (!this.f530a && z && view != null && view.getVisibility() == 0) {
            e();
            view.invalidate();
        }
        return true;
    }

这里说明下,有一些和混淆无关,与其他页面关联的方法这里就删除了,后面也是类似。 从代码中看到先判断从前面传进来的targetView有没有发生变化,isDirty就是判断targetView从上次绘制完成后有没有发生改变,对应效果就是,如果QQ中你没有滑动列表,就停止模糊方法,毕竟这是一个耗时耗资源的事,然后就调用模糊最关键的逻辑代码preDrawCanvas

private void preDrawCanvas() {
  long elapsedRealtime = SystemClock.elapsedRealtime();
        if (this.mTargetView != null && this.mBlurView != null && this.mBlurView.getWidth() > 0 && this.mBlurView.getHeight() > 0) {
          /**这里的mScale是因为这里用的模糊方式是java的StackBlur方法,
              先把要模糊的视图缩小模糊后再放大,这样能够降低耗时*/
            Bitmap createBitmap;
            int scaleWidth = QQBlurManager.ceil((float) this.mBlurView.getWidth(), this.mScale);
            int scaleHeight = QQBlurManager.ceil((float) this.mBlurView.getHeight(), this.mScale);
            int a3 = QQBlurManager.fixBy16(scaleWidth);
            int a4 = QQBlurManager.fixBy16(scaleHeight);
           //下面计算是为了获取16位取整后正确的缩放系数
            this.c = ((float) scaleHeight) / ((float) a4);
            this.b = ((float) scaleWidth) / ((float) a3);
            float f = this.mScale * this.b;
            float f2 = this.mScale * this.c;
           
            try {
                createBitmap = Bitmap.createBitmap(a3, a4, Config.ARGB_8888);
            } catch (Throwable e) {
                Log.e("QQBlur", "prepareBlurBitmap: ", e);
                createBitmap = null;
            }
            if (createBitmap != null) {
                this.mBlurBitmapWidth = (long) createBitmap.getWidth();
                this.mBlurBitmapHeight = (long) createBitmap.getHeight();
                if (VERSION.SDK_INT >= 19) {
                    mBlurBitmapByteCount = (long) createBitmap.getAllocationByteCount();
                } else {
                    mBlurBitmapByteCount = (long) createBitmap.getByteCount();
                }
                //设置bitmap的是否透明的值,原代码传过来的值为-1
                createBitmap.eraseColor(mBlurBitmapEraseColor);
                this.mCanvas.setBitmap(createBitmap);
                int[] iArr = new int[2];
                this.mBlurView.getLocationInWindow(iArr);
                int[] iArr2 = new int[2];
                this.mTargetView.getLocationInWindow(iArr2);
                this.mCanvas.save();
                //这里是形成动态模糊最关键的一行代码,这里将canvas平移后获得两个view交叉部分 
                //的坐标点
                this.mCanvas.translate(((float) (-(iArr[0] - iArr2[0]))) / f, ((float) (-(iArr[1] - iArr2[1]))) / f2);
                this.mCanvas.scale(1.0f / f, 1.0f / f2);
                //这个是模糊的具体操作代码,后面会说明
                StackBlurManager stackBlurManager = new StackBlurManager(createBitmap);
                stackBlurManager.setDbg(true);
                stackBlurManager.setExecutorThreads(stackBlurManager.getExecutorThreads());
                this.isDrawCanvas = true;
                if (VERSION.SDK_INT <= 27 || this.mBlurView.getContext().getApplicationInfo().targetSdkVersion <= 27) {
                //为什么这里要在27以下采用这种方法其实没太看懂,不过作用是为了裁剪出和需要模糊              
               //同等大小的区域,然后将目标视图呈现到我们给定的画布上
                    Rect clipBounds = this.mCanvas.getClipBounds();
                    clipBounds.inset(-createBitmap.getWidth(), -createBitmap.getHeight());
                    if (this.mCanvas.clipRect(clipBounds, Op.REPLACE)) {
                        this.mTargetView.draw(this.mCanvas);
                    } else {
                        Log.e("QQBlur", "prepareBlurBitmap: canvas clip rect empty. Cannot draw!!!");
                    }
                } else {
                    //将目标视图呈现到我们给定的画布上
                    this.mTargetView.draw(this.mCanvas);
                }
                this.mCanvas.restore();
                clearViewVisible();
                Log.i("高斯模糊", "创建bitmap" + createBitmap);
                this.isDrawCanvas = false;
                //将模糊的操作放到线程中进行
                this.mHandler.post(new QQBlur(this, stackBlurManager));
            } else {
                return;
            }
        }
        //这里的数值是用来调试用的,计算每次裁剪的耗时
        long elapsedRealtime2 = SystemClock.elapsedRealtime();
        this.mPreViewCount++;
        this.mPreViewTime = (elapsedRealtime2 - elapsedRealtime) + this.mPreViewTime;
    }
    private static int ceil(float f, float f2) {
        return (int) Math.ceil((double) (f / f2));
    }

    public static int fixBy16(int i) {
        return i % 16 == 0 ? i : (i - (i % 16)) + 16;
    }

上面做了简单的注释,这边做一下总结:

  • 创建一个和模糊区域同等大小的bitmap,将其放在初始化就创建好的canvas中、
  • 获得缩放后取整的宽高和缩放后有些细微变化的比例值
  • 获取targetViewblurView的坐标值,通过相减计算出他们交叉区域的坐标点
  • 获得交叉区域后,将targetView的这一部分内容绘制到canvas上,也就是会知道了前面创建好的bitmap上,然后模糊这一bitmap在绘制到blurView上就实现了对交叉这一区域的模糊。 下面用简单的图展示一下。

在QQ中,上面的聊天列表就是这个targetView,最下面有一层blurView,上面是透明的tab,实现动态模糊的逻辑就是不断去获取targetViewblurView交叉这一部分区域的视图,将这部分视图模糊后绘制在blurView上,就形成了一种动态模糊的效果。在上面展示的例子中就是这么调用的

        QQBlurView qqBlurView = findViewById(R.id.blur);
        List<String> list = new ArrayList<>();
        for (int i = 0; i < 50; i++) {
            list.add("" + i);
        }
        ListView listView = findViewById(R.id.listView);
        listView.setAdapter(new MainAdapter(this, list));
        qqBlurView.setEnableBlur(true);
        qqBlurView.setBlurView(qqBlurView);
        qqBlurView.setTargetView(listView);
        qqBlurView.setBlurRadius(1);
        qqBlurView.setEraseColor(-1);
        qqBlurView.onCreate();
        qqBlurView.onAttached();
  • QQBlur

最后一个就是模糊调用的线程方法,这里面的还原我其实没有100%的把握,原因前面也说了,这里的命名类型不一样,但是命名却是一模一样的,所以尽我所能去理解并还原了

public class QQBlur implements Runnable {
    private int a = -1;
    final StackBlurManager mStackBlurManager;
    final QQBlurManager mQQblurManager;

    public QQBlur(QQBlurManager QQblurManager, StackBlurManager stackBlurManager) {
        this.mQQblurManager = QQblurManager;
        this.mStackBlurManager = stackBlurManager;
    }

    public void run() {
        if (!this.mQQblurManager.isDrawCanvas()) {
            long elapsedRealtime = SystemClock.elapsedRealtime();
            if (!(this.a == -1 || this.a == QQBlurManager.mBlurType)) {
                this.mQQblurManager.onPolicyChange(this.a, QQBlurManager.mBlurType);
            }
            this.a = QQBlurManager.mBlurType;
            int i = QQBlurManager.mBlurType;
            Bitmap process = this.mStackBlurManager.process(this.mQQblurManager.mRadius);
            if (process != null) {
                this.mQQblurManager.mBitmap = process;
            } else {
                Log.e("QQBlur", "run: outBitmap is null. OOM ?");
            }
            long elapsedRealtime2 = SystemClock.elapsedRealtime();
            this.mQQblurManager.mBlurThreadCount++;
            this.mQQblurManager.mBlurThreadTime = (elapsedRealtime2 - elapsedRealtime) + this.mQQblurManager.mBlurThreadTime;
            View blurView = this.mQQblurManager.mBlurView;
            if (blurView != null && this.mQQblurManager.isDrawing) {
                blurView.postInvalidate();
            }
        }
    }
}
//该方法不是这个类里面的,这里展示下QQ中总共包含了这四种模糊方式,这里才用的是最后一终
 private CharSequence selectBlurType(int i) {
        switch (i) {
            case 1:
                return "StackBlur.Native";
            case 2:
                return "StackBlur.RS";
            case 3:
                return "GaussBlur.RS";
            default:
                return "StackBlur.Java";
        }
    }

这段代码最主要的是Bitmap process = this.mStackBlurManager.process(this.mQQblurManager.mRadius);这一行,调用模糊方法,前面主要是判断模糊方式有没有变化,后面mBlurThreadCount,mBlurThreadTime也是调试参数用的,最后模糊完成后刷新界面,这里的StackBlurManager是一个第三方库,有兴趣的小伙伴可以自己去看一下,QQ做了一些细微的调整,增加了一两个方法。

  • StackBlurManager

public class StackBlurManager {
    static int EXECUTOR_THREADS = Runtime.getRuntime().availableProcessors();
    static final ExecutorService EXECUTOR = Executors.newFixedThreadPool(EXECUTOR_THREADS);
    private static final String TAG = "StackBlurManager";
    private static volatile boolean hasRS = true;
    private final BlurProcess _blurProcess;
    private final Bitmap _image;
    private Bitmap _result;
    private boolean mDbg = true;

    public StackBlurManager(Bitmap image) {
        this._image = image;
        this._blurProcess = new JavaBlurProcess();
    }

    public Bitmap process(int radius) {
        long start = SystemClock.uptimeMillis();
        this._result = this._blurProcess.blur(this._image, 8);
          Log.i(TAG, "process: " + this._blurProcess + "=" + (SystemClock.uptimeMillis() - start) + " ms");
        return this._result;
    }

这里默认的模糊方式是JavaBlurProcess,该方法后面还有很多其他模糊方式的方法,应该是在其他情况下供别的地方调用的,感兴趣的朋友可以自己看一下StackBlurManager这个类,这里的是QQ上的,上面那个是第三方框架中的。

  • ThreadManager和MqqHandler

其实这两个是意外发现的,这是QQ中自己做的线程调度和自定义的handler用法,而且这写方法并没有混淆,有兴趣的话可以观摩学习下代码,我下面贴几行QQ里面这些类的用法。

ThreadManager.getSubThreadHandler().post(runnable);
ThreadManager.getSubThreadHandler().removeCallbacks(runnable);
   ThreadManager.getSubThreadHandler().post(new Runnable() {
            public void run() {
                aron aron = (aron) MainFragment.this.a.getManager(319);
                ((aivy) MainFragment.this.a.a(2)).w();
            }
        });
 ThreadManager.getSubThreadHandler().postDelayed(anonymousClass1, 2000);
 ThreadManager.getUIHandler().postDelayed(new Runnable() {
                public void run() {
                    MainFragment.this.a;
                    MainFragment.this.C();
                }
            }, 2000);
ThreadManager.post(new Runnable() {
            public void run() {
                if (BaseApplicationImpl.getContext() != null) {
                    ayiv.a(BaseApplicationImpl.getContext(), "");
                }
            }
        }, 2, null, true);


       if (this.f29a == null) {
            this.f29a = new MqqHandler();
        }
        this.f29a.postDelayed(new Runnable() {
            public void run() {
                String a = MainFragment.this.a;
                if ("消息".equals(a) || "联系人".equals(a)) {
                    String str = "消息".equals(a) ? "Msg_tab" : "Contacts_tab";
                    auzd.b(MainFragment.this.a, "CliOper", "", "", str, str, 0, 0, "", "", "", "");
                }
                if (MainFragment.this.a != null && MainFragment.this.a.getBoolean(ThemeUtil.THEME_VOICE_SETTING + MainFragment.this.a.getCurrentAccountUin(), true)) {
                    MainFragment.this.a.b(i + 1);
                }
                if (a != null && AppSetting.c) {
                    MainFragment.this.a("消息", a);
                    MainFragment.this.a("联系人", a);
                    MainFragment.this.a("动态", a);
                    MainFragment.this.a("NOW", a);
                }
            }
        }, 100);

4.结尾

以上就是这次查找代码的全部收获,全部的代码包括我自己命名的,混淆的原代码,ThreadManager类和StackBlurManager类我都上传到了这里,本意是让大家学习大厂的代码逻辑和思想,如有侵权,第一时间联系我删除,谢谢了。