-
前言
前段时间QQ更新后发现下面的Tab栏添加了动态高斯模糊效果,众所周知,高斯模糊这玩意儿比较耗时,动态的模糊效果在安卓的APP中比较少见。在自己猜测了几种做法之后想知道QQ是怎么实现的,于是反编译了一下QQ的apk。
鉴于我的逆向基础门都没入,属于只会用一个jadx查查16进制id这种,这里就不班门弄斧介绍了,感兴趣的可以自己去搜搜类似的文章看看。不过这里不得不说QQ的措施做得真好,它里面的所有控件id,资源id,layout的命名混淆后大部分是name,想通过查找id来寻找代码文件对我来说基本不可能,曾经反编译过网易云音乐的app,它就没有做这项措施,可以轻易的通过uiautomatorviewer
工具查找到id后定位到具体的代码文件。下面可以看看最后的效果。 -
效果
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
这样的字眼,果然找到了两个文件QQBlur
和QQBlurView
找到后就是苦力活了,因为代码是混淆的,需要把相关的代码倒腾出来再去分析。这里让我最蛋疼的就是这个
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
监听器,而后调用了QQBlurManager
的onPreDraw
方法
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中、
- 获得缩放后取整的宽高和缩放后有些细微变化的比例值
- 获取
targetView
和blurView
的坐标值,通过相减计算出他们交叉区域的坐标点 - 获得交叉区域后,将
targetView
的这一部分内容绘制到canvas
上,也就是会知道了前面创建好的bitmap
上,然后模糊这一bitmap
在绘制到blurView
上就实现了对交叉这一区域的模糊。
下面用简单的图展示一下。
在QQ中,上面的聊天列表就是这个targetView
,最下面有一层blurView
,上面是透明的tab,实现动态模糊的逻辑就是不断去获取targetView
和blurView
交叉这一部分区域的视图,将这部分视图模糊后绘制在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类我都上传到了这里,本意是让大家学习大厂的代码逻辑和思想,如有侵权,第一时间联系我删除,谢谢了。