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

栏目: Java · 发布时间: 5年前

内容简介:我在链家网从事Android开发已经三年了,一直致力于优质APP的开发与探索,有时候会写一些工具来提高效率,但更多时候是用技术帮助业务增长。我们有专业的测试团队,我尝试与他们保持沟通,听取他们的建议和反馈,并及时的做出修正。如果你是小型移动开发团队成员,或开源项目贡献者,你就应该收集这些反馈信息,并积极寻求解决方案,因为它们是你责任的一部分。我最近收到了一些反馈,是关于用户体验的,而且我也相信如果不做特殊处理,很多应用都会出现类似问题,因此我会在接下来与大家分享我的解决思路。本文提到的所有代码都可以通过
拦截控件点击 - 巧用ASM处理防抖

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

如果你是小型移动开发团队成员,或开源项目贡献者,你就应该收集这些反馈信息,并积极寻求解决方案,因为它们是你责任的一部分。

我最近收到了一些反馈,是关于用户体验的,而且我也相信如果不做特殊处理,很多应用都会出现类似问题,因此我会在接下来与大家分享我的解决思路。本文提到的所有代码都可以通过 github 下载。

背景&现状

最近,我们的测试团队向我反馈,如果频繁点击列表页的同一个卡片会同时打开两个详情页面,甚至过于频繁地提交表单也会弹出两个对话框。虽然这不会导致应用的崩溃,但却是一个令人头痛的体验问题,会让使用它的用户感到困惑。

我抱着侥幸心理在经常使用的APP 中尝试同样的操作,想知道哪些应用会出现和我们一样的现象。

在此之前,我需要郑重申明,我没有任何恶意诋毁的目的,如果侵犯了您的权益,请通知我

“知乎”和“网易云音乐”是我日常使用频率最高的两款应用,不幸的是它们都会出现这种“抖动现象”。

我们先来看知乎的“抖动”现象:

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

很明显我点击了头像,但同时打开了两个主页,我需要再点击两次back键才能回到之前的页面。

再来看一下网易云音乐的:

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

我甚至开始困惑这是究竟产品属性,还是因为“抖动”造成的错误现象 : (

不得不说的是,“点击抖动”在一定程度上影响了用户体验,而且在极端情况下必然引起程序的崩溃。那么,接下来我们就进入主题,一起探索如何优雅的消除“点击抖动”的存在。

修改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);

    ......

  }
}
复制代码

如果你觉得这些代码太抽象,那么我们可以通过一张图来更好的理解它:

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

一句话总结: 我们拦截了处于冻结窗口内的点击事件,让它们无法执行到我们的业务逻辑。

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);
  }
}

复制代码

总结

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

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

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


以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

Out of their Minds

Out of their Minds

Dennis Shasha、Cathy Lazere / Springer / 1998-07-02 / USD 16.00

This best-selling book is now available in an inexpensive softcover format. Imagine living during the Renaissance and being able to interview that eras greatest scientists about their inspirations, di......一起来看看 《Out of their Minds》 这本书的介绍吧!

HTML 压缩/解压工具
HTML 压缩/解压工具

在线压缩/解压 HTML 代码

XML、JSON 在线转换
XML、JSON 在线转换

在线XML、JSON转换工具

RGB HSV 转换
RGB HSV 转换

RGB HSV 互转工具