我在链家网从事Android开发已经三年了,一直致力于优质APP的开发与探索,有时候会写一些工具来提升效率,但更多时候是用技术帮助业务增加。咱们有专业的测试团队,我尝试与他们保持沟通,听取他们的建议和反馈,并及时的作出修正。html
若是你是小型移动开发团队成员,或开源项目贡献者,你就应该收集这些反馈信息,并积极寻求解决方案,由于它们是你责任的一部分。java
我最近收到了一些反馈,是关于用户体验的,并且我也相信若是不作特殊处理,不少应用都会出现相似问题,所以我会在接下来与你们分享个人解决思路。本文提到的全部代码均可以经过github下载。android
最近,咱们的测试团队向我反馈,若是频繁点击列表页的同一个卡片会同时打开两个详情页面,甚至过于频繁地提交表单也会弹出两个对话框。虽然这不会致使应用的崩溃,但倒是一个使人头痛的体验问题,会让使用它的用户感到困惑。git
我抱着侥幸心理在常用的APP 中尝试一样的操做,想知道哪些应用会出现和咱们同样的现象。github
在此以前,我须要郑重申明,我没有任何恶意诋毁的目的,若是侵犯了您的权益,请通知我。web
“知乎”和“网易云音乐”是我平常使用频率最高的两款应用,不幸的是它们都会出现这种“抖动现象”。bash
咱们先来看知乎的“抖动”现象:app
很明显我点击了头像,但同时打开了两个主页,我须要再点击两次back键才能回到以前的页面。框架
再来看一下网易云音乐的:ide
我甚至开始困惑这是究竟产品属性,仍是由于“抖动”形成的错误现象 : (
不得不说的是,“点击抖动”在必定程度上影响了用户体验,并且在极端状况下必然引发程序的崩溃。那么,接下来咱们就进入主题,一块儿探索如何优雅的消除“点击抖动”的存在。
针对全部打开Activity
的状况,咱们能够在AndroidManifest.xml
中修改启动模式,避免打开重复的页面:
<activity android:name=".YourActivity"
android:launchMode="singleTop" >
...
</activity>
复制代码
但这种方法并不通用,咱们还有不少唤起菜单和对话框的操做,并且某些业务中的Activity
并不能设置singleTop
,所以咱们不能经过设置launchMode
的方式来避免“抖动”的产生。
既然配置AndroidManifest
的方式行不通,那咱们就粗暴地**“为全部的可点击控件都添加防抖策略”**。
最多见的就是给每个点击事件的监听接口添加拦截逻辑。拿OnClickListener接口举例,我能够很快写出一个通用的防抖抽象类:
public abstract class DebouncedView$OnClickListener implements View.OnClickListener {
private final long debounceIntervalInMillis;
private long previousClickTimestamp;
public DebouncedView$OnClickListener(long debounceIntervalInMillis) {
this.debounceIntervalInMillis = debounceIntervalInMillis;
}
@Override public void onClick(View view) {
final long currentClickTimestamp = TimeUnit.NANOSECONDS.toMillis(System.nanoTime());
if (previousClickTimestamp == 0
|| currentClickTimestamp - previousClickTimestamp >= debounceIntervalInMillis) {
//update click timestamp
previousClickTimestamp = currentClickTimestamp;
this.onDebouncedClick(view);
}
}
public abstract void onDebouncedClick(View v);
}
复制代码
用debounceIntervalInMillis
来设置防抖间隔,即在这段时间内不容许发生两次点击,值得一提的是点击事件已经发生了,咱们只是拦截它以致于再也不传递至业务逻辑罢了,300ms是个经验值,仅供参考。而后在须要处理点击事件的地方使用它:
findViewById(R.id.button).setOnClickListener(new DebouncedView$OnClickListener(300) {
@Override public void onDebouncedClick(View v) {
//do something
}
});
复制代码
这看起来很完美,咱们只须要多写几个代理类便可,以知足OnItemClickListener或DialogInterface$OnClickListener或其它回调接口。
真的解决了咱们全部疑惑吗?答案是:NO !
首先,咱们的项目已经启动好久了,而且有了稳定的线上版本,这就意味着咱们必须扫描代码仓库,并对全部相关代码进行替换,这种方式明显低效又愚蠢。
其次,咱们是一个团队在开发,并非我一我的,所以我必须将这种写法提交到咱们的编码规范中,以强制团队其余人去遵照规范,而且在code review中也要格外地注意,很显然在无形之中增长了人力成本。
最后,也是最重要的一点,它多多少少的侵入了业务,我认为这种防抖策略应该像无埋点统计工做那样,对于业务来说是透明的,也是无感知的。
综合以上几种状况的考虑,AOP无疑成了最好的解决方案。
幸运的是,我会使用一些诸如ASM和AspectJ这样的代码织入框架,在通过一番尝试后,最终选择使用ASM来打造这个小工具,由于ASM的语法更通俗易懂,而且与gradle的联动效果更好,它可以让我很是方便的修改字节码,而AspectJ在这些维度的比较上实在显得笨重。
在此声明,本篇文章并非对ASM的详解,你能够经过上网查到大量的学习资料和用例代码,所以请容许我在这里不作详细的说明。
先看一下咱们修改前的源代码,在点击回调中打开另外一个Activity
。:
@Override public void onClick(View v) {
startActivity(new Intent(MainActivity.this, SecondActivity.class));
}
复制代码
下面是咱们所指望的修改后的代码:
@Override public void onClick(View v) {
if (DebouncedClickPredictor.shouldDoClick(v)) {
startActivity(new Intent(MainActivity.this, SecondActivity.class));
}
}
复制代码
咱们但愿字节码被修改后,原有的逻辑被包含在一个if
判断中,DebouncedClickPredictor
类有一个重要的函数:boolean shouldDoClick(android.view.View)
用来判断目标View
的本次点击是否属于抖动,咱们为每个被点击的控件都设置一个冻结期,在这个期间不容许出现两次及其以上的点击发生。
再次重申:View
的点击事件已经发生了,咱们只是拦截它以致于不会达到业务代码。
public class DebouncedClickPredictor {
public static long FROZEN_WINDOW_MILLIS = 300L;
private static final String TAG = DebouncedClickPredictor.class.getSimpleName();
private static final Map<View, FrozenView> viewWeakHashMap = new WeakHashMap<>();
public static boolean shouldDoClick(View targetView) {
FrozenView frozenView = viewWeakHashMap.get(targetView);
final long now = now();
if (frozenView == null) {
frozenView = new FrozenView(targetView);
frozenView.setFrozenWindow(now + FROZEN_WINDOW_MILLIS);
viewWeakHashMap.put(targetView, frozenView);
return true;
}
if (now >= frozenView.getFrozenWindowTime()) {
frozenView.setFrozenWindow(now + FROZEN_WINDOW_MILLIS);
return true;
}
return false;
}
private static long now() {
return TimeUnit.NANOSECONDS.toMillis(System.nanoTime());
}
private static class FrozenView extends WeakReference<View> {
private long FrozenWindowTime;
FrozenView(View referent) {
super(referent);
}
long getFrozenWindowTime() {
return FrozenWindowTime;
}
void setFrozenWindow(long expirationTime) {
this.FrozenWindowTime = expirationTime;
}
}
}
复制代码
而后是字节码织入操做,建立咱们本身的ClassVisitor,并重写visitMethod
函数,在这里处理全部与View.OnClickListener函数签名相同的方法。
@Override
public MethodVisitor visitMethod(int access, final String name, String desc, String signature,
String[] exceptions) {
MethodVisitor methodVisitor = cv.visitMethod(access, name, desc, signature, exceptions);
// android.view.View.OnClickListener.onClick(android.view.View)
if (((access & ACC_PUBLIC) != 0 && (access & ACC_STATIC) == 0) && //
name.equals("onClick") && //
desc.equals("(Landroid/view/View;)V")) {
methodVisitor = new View$OnClickListenerMethodAdapter(methodVisitor);
}
return methodVisitor;
}
复制代码
最后在View$OnClickListenerMethodAdapter
类中作相应的函数字节修改逻辑,即全部知足条件函数的第一行插入DebouncedClickPredictor.shouldDoClick(v)
。
class View$OnClickListenerMethodAdapter extends MethodVisitor {
View$OnClickListenerMethodAdapter(MethodVisitor methodVisitor) {
super(Opcodes.ASM5, methodVisitor);
}
@Override public void visitCode() {
super.visitCode();
......
mv.visitVarInsn(ALOAD, 1);
mv.visitMethodInsn(INVOKESTATIC, "com/smartdengg/clickdebounce/DebouncedPredictor", "shouldDoClick",
"(Landroid/view/View;)Z", false);
Label label = new Label();
mv.visitJumpInsn(IFNE, label);
mv.visitInsn(RETURN);
mv.visitLabel(label);
......
}
}
复制代码
若是你以为这些代码太抽象,那么咱们能够经过一张图来更好的理解它:
一句话总结:咱们拦截了处于冻结窗口内的点击事件,让它们没法执行到咱们的业务逻辑。
以上就是咱们关于处理抖动的核心思路,看起来代码量并很少,并且也不难理解,为了方便使用,我决定将它作成gradle插件。在插件中咱们只须要对输入的字节码进行转换,而后将修改后的字节码写入到指定位置以便下一个任务继续使用,感兴趣的能够自行阅读DebounceGradlePlugin的源码实现。须要注意的是,咱们必须分别处理普通文件和压缩文件的转换,而且尽量的支持增量构建,毕竟构建时间就是黄金。
值得一提的是,我但愿这个插件不只支持application
,还应该支持library
,所以我在修改字节码的过程当中,为全部已经修改过的函数添加了一个注解@Debounced
,从而避免二次修改所形成的逻辑错误,所以对上面提到的View$OnClickListenerMethodAdapter
补充了织入注解的逻辑。
class View$OnClickListenerMethodAdapter extends MethodVisitor {
private boolean weaved;
View$OnClickListenerMethodAdapter(MethodVisitor methodVisitor) {
super(Opcodes.ASM5, methodVisitor);
}
@Override public void visitCode() {
super.visitCode();
if (weaved) return;
AnnotationVisitor annotationVisitor =
mv.visitAnnotation("Lcom/smartdengg/clickdebounce/Debounced;", false);
annotationVisitor.visitEnd();
mv.visitVarInsn(ALOAD, 1);
mv.visitMethodInsn(INVOKESTATIC, "com/smartdengg/clickdebounce/DebouncedPredictor",
"shouldDoClick", "(Landroid/view/View;)Z", false);
Label label = new Label();
mv.visitJumpInsn(IFNE, label);
mv.visitInsn(RETURN);
mv.visitLabel(label);
}
@Override public AnnotationVisitor visitAnnotation(String desc, boolean visible) {
/*Lcom/smartdengg/clickdebounce/Debounced;*/
weaved = desc.equals("Lcom/smartdengg/clickdebounce/Debounced;");
return super.visitAnnotation(desc, visible);
}
}
复制代码
以上内容就是我对“点击抖动”的见解,其实这个工具孵化于业务开发之中,如今我将它从新整理并决定**开源**,给那些有一样困惑的人提供一种解决思路,但愿可以有所帮助。
随着愈来愈多的人加入团队,不管业务需求的开发仍是技术深度的挖掘,都变得愈来愈重要,咱们很是但愿用户可以对咱们的产品报以指望,高效并愉快的使用它们。不懈怠任何一处用户体验,理所应当成为每一位开发者的觉悟。
文章的最后,很是感谢您的阅读,欢迎在文章下方提出您的宝贵建议。