AOP - 消除控件的点击抖动

Photo by Gary Bendig on Unsplash

我在链家网从事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
      }
    });

这看起来很完美,我们只需要多写几个代理类即可,以满足OnItemClickListenerDialogInterface$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);
  }
}

总结

以上内容就是我对“点击抖动”的看法,其实这个工具孵化于业务开发之中,现在我将它重新整理并决定开源,给那些有同样困惑的人提供一种解决思路,希望能够有所帮助。

随着越来越多的人加入团队,无论业务需求的开发还是技术深度的挖掘,都变得越来越重要,我们非常希望用户能够对我们的产品报以期望,高效并愉快的使用它们。不懈怠任何一处用户体验,理所应当成为每一位开发者的觉悟。

文章的最后,非常感谢您的阅读,欢迎在文章下方提出您的宝贵建议。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 148,637评论 1 318
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 63,443评论 1 266
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 99,164评论 0 218
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 42,075评论 0 188
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 50,080评论 1 266
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 39,365评论 1 184
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 30,901评论 2 283
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 29,649评论 0 176
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 33,122评论 0 223
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 29,734评论 2 225
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,093评论 1 236
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 27,548评论 2 222
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,028评论 3 216
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 25,765评论 0 9
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,291评论 0 178
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 34,162评论 2 239
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 34,293评论 2 242

推荐阅读更多精彩内容

  • 我的本期99天践行财富目标:99天家庭收入99万。 孩子方面的目标:俩孩子在学校开心充实,顺利适应集体住校生活。 ...
    寸心洁白阅读 316评论 0 2
  • 七、谈话 为了让病人有一个舒适幽静的休闲环境,医院的公园修建得很宽敞很优美。弯弯曲曲的走廊,造型独特的楼亭,清澈如...
    轻浅若烟阅读 583评论 0 1
  • 文/亦安姑娘 第一次用手帐的方式记录我的2017 愿我们的2018都被时光温柔以待。
    亦安姑娘阅读 322评论 10 10
  • 第六重天的主题:觉察烂故事 某某人,根据你的自我定义,你觉得怎样的自己才是值得被爱的?为什么? 根据我的自我定义,...
    李英花阅读 199评论 0 0