前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >拦截控件点击 - 巧用ASM处理防抖

拦截控件点击 - 巧用ASM处理防抖

作者头像
小鄧子
发布2018-08-20 15:31:16
1.4K0
发布2018-08-20 15:31:16
举报

我在链家网从事Android开发已经三年了,一直致力于优质APP的开发与探索,有时候会写一些小工具,但更多时候是用技术帮助业务增长。我们有专业的测试团队,我尝试与他们保持沟通,听取他们的建议和反馈,并及时的做出修正。

相信我,一个专业的测试团队会帮你节省很多时间。他们用严格的测试用例,来保证APP的质量,收集线上崩溃日志和用户反馈,然后将它们打包发送给你,这在一定程度上提高了你解决问题的效率,因为你只需要关注问题本身,不需要投入额外的精力到信息的收集上。

如果你是小型移动开发团队成员,或者开源项目贡献者,那么这些收集信息和跟进反馈的工作就成为了你责任的一部分,千万不要忽略这些,因为一旦有人使用了你的APP,你就应该为之负责,不是吗?

我最近收到了一封反馈邮件,我觉得这个“Exception”很有趣,同时也充满了挑战,并且我相信你也遇到过这种情况,因此我会在接下来的部分与大家分享,然后给出我的解决思路。

背景&现状

我们的测试团队向我反馈,在某些场景下用极快的速度双击APP中按钮时会唤起两个菜单或者快速双击Feed流卡片后会打开两个详情页,虽然这种行为不会导致崩溃,但会让我们的用户感到十分的困惑,在这种情况下,第二次点击往往属于误触碰或因手动导致,因此我们称这种现象为“抖动点击”

虽然在极少数情况下的确会有一些用户通过这种方式来“虐待”你的软件,而且在那些陈旧的响应很慢的设备中更容易复现,客观的讲,我们不能向所有用户普及:“嗨,请温柔点,不要点那么频繁”。我们的团队不希望用户觉得我们的APP很脆弱,就像我前面提到的那样,这是我们的责任,我们应该健壮我们的应用 : )

修改Activity启动模式?

针对所有打开Activity的情况,我们可以在AndroidManifest.xml中修改启动模式,避免打开重复的页面:

代码语言:javascript
复制
<activity android:name=".YourActivity"
          android:launchMode="singleTop" >
            ...
</activity>

但这种方法并不通用,我们还有很多唤起菜单和弹窗的操作,而且某些业务中的Activity是不能设置singleTop的,因此我们不能通过设置launchMode的方式来避免“抖动”的发生。

自定义DebouncedViewClickListener?

既然配置manifest的方式行不通,那我们就简单粗暴些“为所有的点击事件都加上防抖”

比如针对所有OnClickListener回调的,我可以很快写出一个通用的防抖抽象类:

代码语言:javascript
复制
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是个经验值,仅供参考。然后在需要处理点击事件的地方使用`:

代码语言:javascript
复制
    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更通用,也更灵活,而AspectJ在实现这个功能上实在有些绰绰有余。

在此声明,本篇文章并不是对ASM或AspectJ的讲解,你可以通过上网查到大量的学习资料和用例代码,因此请原谅我在这里不做详细的说明。

先看一下我们原始代码:

代码语言:javascript
复制
  @Override public void onClick(View v) {
    startActivity(new Intent(MainActivity.this, SecondActivity.class));
  }

示例代码很简单,在点击回调中打开另一个Activity

下面是我们期望被修改后的代码:

代码语言:javascript
复制
  @Override public void onClick(View v) {
    if (DebouncedClickPredictor.shouldDoClick(v)) {
      startActivity(new Intent(MainActivity.this, SecondActivity.class));
    }
  }

我们希望字节码被修改后,原有的逻辑被包含在一个防抖的if判断中,DebouncedClickPredictor类有一个重要的函数:shouldDoClick(View targetView)用来判断目标View的该次点击是否属于抖动,我们为每一个被点击的控件都设置一个冻结期,在这个期间不允许出现两次及其以上的点击发生,需要注意的是View的点击事件已经发生了,我们只是拦截了它的业务代码。

代码语言:javascript
复制
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;
    }
  }
}

然后在ASM代码中实现我们自己的ClassVisitor,并重写visitMethod函数,我这里处理了所有与onClick(View v)函数签名相同的方法。

代码语言:javascript
复制
  @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)的判断语句。

代码语言:javascript
复制
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进行了逻辑补充,仅仅是为函数添加新注解的操作:

代码语言:javascript
复制
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);
  }
}

总结

这个工具是在业务开发过程中孵化出来的,目前还没有公开的计划,所以我只能分享一些我看待问题的方式和解决思路,如果你也有过相似的困惑,希望能够对你有所帮助。

随着越来越多的人加入团队,无论业务需求的开发还是结束深度的挖掘,都变得越来越重要,我们非常希望用户能够对我们的产品报以期望,并且能够便捷,高效的使用它。

你可以通过github来查看这些示例代码

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

我的博客即将搬运同步至腾讯云+社区,邀请大家一同入驻:https://cloud.tencent.com/developer/support-plan?invite_code=2whezn9l7yeco

本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
原始发表:2018.03.27 ,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 背景&现状
  • 修改Activity启动模式?
  • 自定义DebouncedViewClickListener?
  • AOP ? YES !
  • Gradle插件
  • 总结
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档