内容简介:我在链家网从事Android开发已经三年了,一直致力于优质APP的开发与探索,有时候会写一些工具来提高效率,但更多时候是用技术帮助业务增长。我们有专业的测试团队,我尝试与他们保持沟通,听取他们的建议和反馈,并及时的做出修正。如果你是小型移动开发团队成员,或开源项目贡献者,你就应该收集这些反馈信息,并积极寻求解决方案,因为它们是你责任的一部分。我最近收到了一些反馈,是关于用户体验的,而且我也相信如果不做特殊处理,很多应用都会出现类似问题,因此我会在接下来与大家分享我的解决思路。本文提到的所有代码都可以通过
我在链家网从事Android开发已经三年了,一直致力于优质APP的开发与探索,有时候会写一些 工具 来提高效率,但更多时候是用技术帮助业务增长。我们有专业的测试团队,我尝试与他们保持沟通,听取他们的建议和反馈,并及时的做出修正。
如果你是小型移动开发团队成员,或开源项目贡献者,你就应该收集这些反馈信息,并积极寻求解决方案,因为它们是你责任的一部分。
我最近收到了一些反馈,是关于用户体验的,而且我也相信如果不做特殊处理,很多应用都会出现类似问题,因此我会在接下来与大家分享我的解决思路。本文提到的所有代码都可以通过 github 下载。
背景&现状
最近,我们的测试团队向我反馈,如果频繁点击列表页的同一个卡片会同时打开两个详情页面,甚至过于频繁地提交表单也会弹出两个对话框。虽然这不会导致应用的崩溃,但却是一个令人头痛的体验问题,会让使用它的用户感到困惑。
我抱着侥幸心理在经常使用的APP 中尝试同样的操作,想知道哪些应用会出现和我们一样的现象。
在此之前,我需要郑重申明,我没有任何恶意诋毁的目的,如果侵犯了您的权益,请通知我 。
“知乎”和“网易云音乐”是我日常使用频率最高的两款应用,不幸的是它们都会出现这种“抖动现象”。
我们先来看知乎的“抖动”现象:
很明显我点击了头像,但同时打开了两个主页,我需要再点击两次back键才能回到之前的页面。
再来看一下网易云音乐的:
我甚至开始困惑这是究竟产品属性,还是因为“抖动”造成的错误现象 : (
不得不说的是,“点击抖动”在一定程度上影响了用户体验,而且在极端情况下必然引起程序的崩溃。那么,接下来我们就进入主题,一起探索如何优雅的消除“点击抖动”的存在。
修改Activity启动模式?
针对所有打开 Activity
的情况,我们可以在 AndroidManifest.xml
中修改启动模式,避免打开重复的页面:
<activity android:name=".YourActivity" android:launchMode="singleTop" > ... </activity> 复制代码
但这种方法并不通用,我们还有很多唤起 菜单 和 对话框 的操作,而且某些业务中的 Activity
并不能设置 singleTop
,因此我们不能通过设置 launchMode
的方式来避免“抖动”的产生。
自定义DebouncedViewClickListener?
既然配置 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 ? YES !
综合以上几种情况的考虑,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插件
以上就是我们关于处理抖动的核心思路,看起来代码量并不多,而且也不难理解,为了方便使用,我决定将它做成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); } } 复制代码
总结
以上内容就是我对“点击抖动”的看法,其实这个工具孵化于业务开发之中,现在我将它重新整理并决定** 开源 **,给那些有同样困惑的人提供一种解决思路,希望能够有所帮助。
随着越来越多的人加入团队,无论业务需求的开发还是技术深度的挖掘,都变得越来越重要,我们非常希望用户能够对我们的产品报以期望,高效并愉快的使用它们。不懈怠任何一处用户体验,理所应当成为每一位开发者的觉悟。
文章的最后,非常感谢您的阅读,欢迎在文章下方提出您的宝贵建议。
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网
猜你喜欢:- 前端架构之vue+axios 前端实现登录拦截(路由拦截、http拦截)
- react离开页面,自定义弹框拦截,路由拦截
- Springboot整合Hibernate拦截器时无法向拦截器注入Bean
- 基于原生fetch封装一个带有拦截器功能的fetch,类似axios的拦截器
- SpringMVC拦截器
- IOS 拦截器
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。