查看原文
其他

AOP 实现 Android 点击事件防抖

AndroidPub 2023-02-21

作者:sy007
https://juejin.cn/post/7140113885528326181

前言

点击事件抖动是每个项目都会遇到的体验问题, 如何省时省心的处理是我们每一位开发者要思考的问题。这篇文章我将带你从原理到实践来完成一个功能完善的点击事件防抖插件。本篇文章的代码实现 DebouncePlugin 已上传到github。

https://github.com/sy007/DebouncePlugin

目录

1.点击事件防抖处理方式

Android中点击事件防抖一般通过两种方式处理:

  • 代码定义,主动调用
  • AOP处理

无论哪种处理方式,其原理都是一定时间内,事件只响应一次。我们先从简单的代码定义,主动调用方式开始。

1.1 代码定义,主动调用

  1. 定义代理类:
public abstract class DebounceListener implements View.OnClickListener {
    private long lastClickTime;
    private long interval = 1000L;
    @Override
    public void onClick(View v) {
        long currentTime = SystemClock.elapsedRealtime();
        if (currentTime - lastClickTime > interval) {
            onClick();
            lastClickTime = currentTime;
        } 
    }
    protected abstract void onClick();
}
  1. 调用 DebounceListener设置给需要防抖的View:
tv.setOnClickListener(new DebounceListener() {
    @Override
    protected void onClick() {
        // 处理点击逻辑
    }
});

使用这种方式的弊端是每一个点击事件都调用DebounceListener,显然这不是我们想要的,而且Android中点击事件可不止View#OnClickListener,还有ListView#OnItemClickListener等等。

1.2 AOP处理思路

我们先捋下思路,既然是AOP处理,那么需要扫描每一个类,判断是否实现OnClickListener接口并查找onClick方法,在方法前插入防抖的代码。如果是一段时间内多次点击,则return,否则执行业务代码。

下面是AOP插入后的伪代码:

tv.setOnClickListener(new OnClickListener() {
    
    @Override
    public void onClick(View v) {
       //AOP插入防抖代码 
       if($一段时间内多次点击){
          return;
       } 
       //do something
    }
});

1.2.1 设置点击事件的多种方式分析

既然思路有了,接下来我们分析下Android中有哪些方式设置点击事件,以及每种方式的应该怎么处理。毕竟 Android 中可不止 setOnClickListener(new OnClickListener(){});这一种设置点击事件的方式。这里以View设置onClick事件举例。

1.2.1.1 常规设置

内部类,静态内部类,都是实现了OnClickListener接口的类;匿名内部类编译后生成新的类,本质上还是实现了OnClickListener。所以这一类的处理都是扫描当前类是否实现OnClickListener来完成onClick方法的修改。

1.2.1.2 xml设置

在View源码中会解析xml属性,如设置了onClick属性,会创建一个DeclaredOnClickListener设置给当前View,在收到点击事件时,反射调用xml中onClick属性指定的方法。由于View是android.jar包下的类,只参与编译,所以无法利用AOP对android.jar下的类插入防抖代码。

难道走不通了吗?其实不然,我们现在项目中的Activity基本上都是继承AppCompatActivity,在AppCompatActivity对LayoutInflater设置自定义解析,如果xml中设置了onClick,则会创建一个DeclaredOnClickListener设置给当前View,在收到点击事件时,反射调用xml中onClick指定的方法。看起来好像跟android.jar包下View的处理一样,但是AppCompatActivity是androidx.appcompat:appcompat:x.y.z 包下的,这样我们就可以利用AOP愉快的插入防抖代码了。

但是并不是所有项目的Activity都继承AppCompatActivity,有的还继承了FragmentActivity,所以需要提供一个注解标记下xml设置的方法,AOP处理时扫描到标记的方法,然后插入防抖代码 。

1.2.1.3 Lambda表达式设置

Lambda表达式在编译后将方法体脱糖到一个内部私有方法(方法可能是静态或非静态),如果是方法引用则是调用了引用的方法。那我们只要找到这个内部私有方法或引用的方法,插入防抖代码就可以了。

如何找到这两种方法呢?

执行Lambda表达式时有一个JVM 指令invokedynamic,那么只要找到这个指令执行的BootstrapMethod,就能找到LambdaMetafactory#metafactory的参数列表。而LambdaMetafactory#metafactory参数列表中第二个参数implMethod就是脱糖后的方法 。

关于Lambda原理详解在第2节中详细介绍

1.2.1.4 APT设置

通过APT生成的模版代码本质上还是通过setOnClickListener设置点击事件,代表框架(ButterKnife)。所以这一类的处理也是扫描当前类是否实现OnClickListener来完成onClick方法的修改。

@UiThread
public MainActivity_ViewBinding(final MainActivity target, View source) {
    view.setOnClickListener(new DebouncingOnClickListener() {
        @Override
        public void doClick(View p0) {
            target.onClick();
        }
    });
}

2. Lambda原理

2.1 原理描述

Lambda表达式在编译后会生成一个内部静态方法或内部实例方法(方法引用除外,后面讲解)。而方法体就是Lambda表达式的方法体。生成的方法参数和Lambda表达式中声明的方法参数一致。那么程序在执行到Lambda表达式时如何调用生成的这个方法呢?程序执行到Lambda表达式时,通过LambdaMetafactory#metafactory生成一个内部类,而这个内部类就实现了函数式接口,在实现方法里面调用生成的内部的静态方法或内部实例方法。

是不是听起来很绕,没关系,接下来来带你验证上面的原理描述。

2.2 原理验证

编写测试类: LambdaSimple.java:

public class LambdaSimple {

    private int j = 1;

    public void test() {
        //Lambda表达式1
        Runnable run = () -> System.out.println("hello world!");
        run.run();

        //Lambda表达式2
        Arrays.asList(123).forEach(i -> {
            System.out.println("result:" + (i + this.j));
        });
    }
}

我们在Lambda表达式1的方法体中打印"hello world!";在Lambda表达式2的方法体中打印数组的每个元素和LambdaSimple成员变量j的相加结果。

下面我们看下两种表达式在字节码上表示有什么不同。

javac LambdaSimple.java

javap -c -p -v LambdaSimple.class
public class LambdaSimple {
  ...
  private void lambda$test$1(java.lang.Integer);
    Code:
       0: getstatic     #10                 // Field java/lang/System.out:Ljava/io/PrintStream;
       3new           #11                 // class java/lang/StringBuilder
       6: dup
       7: invokespecial #12                 // Method java/lang/StringBuilder."<init>":()V
      10: ldc           #13                 // String result
      12: invokevirtual #14                 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      15: aload_1
      16: invokevirtual #15                 // Method java/lang/Integer.intValue:()I
      19: aload_0
      20: getfield      #2                  // Field j:I
      23: iadd
      24: invokevirtual #16                 // Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
      27: invokevirtual #17                 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
      30: invokevirtual #18                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      33return

  private static void lambda$test$0();
    Code:
       0: getstatic     #10                 // Field java/lang/System.out:Ljava/io/PrintStream;
       3: ldc           #19                 // String hello world!
       5: invokevirtual #18                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
       8return
  ...        
}

我们看到输出的字节码多出两个方法:内部静态方法(lambda$test$0)和内部实例方法(lambda$test$1)。注意观察这两个方法的方法体实现,和我们代码中的实现一摸一样。那么我们现在可以佐证Lambda表达式通过编译后会生成一个内部方法,而内部方法的方法体就是Lambda表达式的方法体。这步操作叫脱糖(desugar) 。

为什么有时候生成的是内部实例方法,有时候生成的是内部静态方法呢?这是因为Lambda表达式中如果有捕获(用到) thissuper 或者外部实例的成员,生成的是内部实例方法。反之生成的是内部静态方法。这一点通过上面给出的代码和字节码可以佐证。

Lambda表达式的方法体内容被脱糖到另外一个方法中,那代码执行时是怎么知道要执行脱糖后的方法呢?还得通过字节码找蛛丝马迹。

public void test();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=4, locals=2, args_size=1
0: invokedynamic #3, 0 // InvokeDynamic #0:run:()Ljava/lang/Runnable;
5: astore_1
6: aload_1
7: invokeinterface #4, 1 // InterfaceMethod java/lang/Runnable.run:()V

private static void lambda$test$0();
descriptor: ()V
flags: ACC_PRIVATE, ACC_STATIC, ACC_SYNTHETIC
Code:
stack=2, locals=0, args_size=0
0: getstatic #10 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #19 // String hello world!
5: invokevirtual #18 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 9: 0

SourceFile: "LambdaSimple.java"
InnerClasses:
public static final #103= #102 of #106; //Lookup=class java/lang/invoke/MethodHandles$Lookup of class java/lang/invoke/MethodHandles
BootstrapMethods:
0: #37 invokestatic java/lang/invoke/LambdaMetafactory.metafactory:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
Method arguments:
#38 ()V
#39 invokestatic LambdaSimple.lambda$test$0:()V
#38 ()V

当代码执行到 Runnable run = () -> System.out.println("hello world!") 时,

对应的字节码是第6行 ****invokedynamic #3,0// InvokeDynamic #0:run:()Ljava/lang/Runnable;

提示: invokedynamic 指令是在 JDK 7 引入的,用来实现动态类型语言功能,简单来说就是能够在运行时去调用实际的代码。

#0表示的是class文件中的BootstrapMethod区域中引导方法的索引。根据字节码,在BootstrapMethod区域引导方法索引0号位置对应的字节码是:

invokestatic java/lang/invoke/LambdaMetafactory.metafactory:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
Method arguments:
#38 ()V
#39 invokestatic LambdaSimple.lambda$test$0:()V
#38 ()V

转换为Java代码:

public static CallSite metafactory(MethodHandles.Lookup caller,
                                   String invokedName,
                                   MethodType invokedType,
                                   MethodType samMethodType,
                                   MethodHandle implMethod,
                                   MethodType instantiatedMethodType)
    ...
}

调用LambdaMetafactory#metafactory时,在内存中动态生成一个实现Lambda表达式函数式接口的实例类型,并在接口的实现方法中调用脱糖后的方法。

这一点可以通过给虚拟机添加-Djdk.internal.lambda.dumpProxyClasses参数将内存中生成的实例类dump到磁盘验证。

运行,会在当前项目下生成LambdaSimple$$Lambda$1:

final class LambdaSimple$$Lambda$1 implements Runnable {
    private LambdaSimple$$Lambda$1() {
    }
    @Hidden
    public void run() {
        LambdaSimple.lambda$test$0();
    }
}

至此Lambda原理分析完毕,我们总结下:

  1. 编译后将Lambda表达式的方法体脱糖到一个内部私有方法(方法可能是静态或非静态) (Lambda方法引用除外)
  2. 在程序执行到Lambda表达式时,调用LambdaMetafactory.metafactory在内存中动态生成一个实现Lambda表达式函数式接口的实例类型。用这个实例替换Lambda表达式。
  3. 在执行函数式接口的方法时,调用脱糖后的方法。

可以用下图表示:

2.3 LambdaMetafactory#metafactory参数意义

public static CallSite metafactory(MethodHandles.Lookup caller,
                                   String invokedName,
                                   MethodType invokedType,
                                   MethodType samMethodType,
                                   MethodHandle implMethod,
                                   MethodType instantiatedMethodType)
    ...
}

在介绍metafactory参数意义之前,我们先了解下MethodTypeMethodHandleCallSite 这几个类是什么。

  1. MethodType

描述一个方法所需的参数签名和返回值签名,MethodType 类有多个静态方法来构造 MethodType 对象,示例如下:

MethodType methodType = MethodType.methodType(String.class,int.class);

上面这个 methodType 描述的是返回值为 String 类型,参数是一个 int 类型的方法签名

例如:String valueOf (int) 这个方法就符合这个描述。

  1. MethodHandle

表示的是一个方法的句柄,通过这个句柄可以调用相应的方法。MethodType 描述了方法的参数和返回值,MethodHandle 则是根据类名、方法名并且配合 MethodType 来找到特定方法然后执行它;MethodType 和 MethodHandle 配合起来完整表达了一个方法的构成。

例如:我们调用 String.valueOf(int) 方法,可以这么做:

//声明参数和返回值类型
MethodType methodType = MethodType.methodType(String.class, int.class);
MethodHandles.Lookup lookup = MethodHandles.lookup();
//声明一个方法句柄:这里说明的是 String 类里面的 valueOf 方法,方法签名需要符合 methodType
MethodHandle methodHandle = lookup.findStatic(String.class, "valueOf", methodType);
//执行这个方法
String result = (String) methodHandle.invoke(99);
System.out.println(result);

这个跟反射很类似,从这个例子可以看出方法句柄里包含了需要执行的方法信息,只要传入所需的参数就可以执行这个方法了。

  1. CallSite

表示方法的调用点,调用点中包含了方法句柄信息,可以从调用点上获取 MethodHandle ,代码如下所示:

MethodHandle mh = callSite.getTarget();

现在来看LambdaMetafactory#metafactory参数的意义

public static CallSite metafactory(MethodHandles.Lookup caller,
                                   String invokedName,
                                   MethodType invokedType,
                                   MethodType samMethodType,
                                   MethodHandle implMethod,
                                   MethodType instantiatedMethodType)
  ...
}

这个方法会返回一个 Callsite 调用点,调用点中包括了方法句柄信息,我们现在来详细解释下这个方法的参数,其中前三个参数不需要关注,系统会自动生成,主要是看后面三个参数:

  1. samMethodType

函数式接口中抽象方法的签名描述信息,关于 MethodType 前面已经介绍。结合代码Runnable run = () -> System.out.println("hello world!"); 这里方法签名描述指的是Runnable#run的签名。

public interface Runnable {
    public abstract void run();
}
  1. implMethod

表示脱糖后的方法句柄,从上面的字节码可以看到,这个方法句柄的内容是 #39 invokestatic LambdaSimple.lambdatesttest0:()V,意思是调用静态方法 lambdatesttest0

  1. instantiatedMethodType

实现函数式接口中抽象方法的签名描述信息,一般情况下和samMethodType描述的信息一样, ( Lambda有捕获this、super 或者外部实例的成员除外)。

2.4 Lambda方法引用

前两节我们知道Lambda表达式中的代码会被脱糖到一个私有的内部方法, 也了解了LambdaMetafactory#metafactory函数各个参数的意义,那么这一节中我们对Lambda方法引用一探究竟。这对我们下一节AOP处理Lambda至关重要。

我们用Lambda表达式来实现匿名方法。但有些情况下,我们用Lambda表达式仅仅是调用一些已经存在的方法,除了调用动作外,没有其他任何多余的动作,在这种情况下,我们倾向于通过方法名来调用它,而Lambda表达式可以帮助我们实现这一要求,它使得Lambda在调用那些已经拥有方法名的方法的代码更简洁、更容易理解。方法引用可以理解为Lambda表达式的另外一种表现形式。

方法引用分类大致分以下几类:

  • 实例方法引用
  • 静态方法引用
  • 构造方法引用
  • 类方法引用

这四种对应的代码如下:

@FunctionalInterface
public interface Consumer<T> {
    void accept(T t);
}  

@FunctionalInterface
public interface BiConsumer<T, U> {
    void accept(T t, U u);
}

public class Outer {
    public Outer() {
    }
    public Outer(String s) {
        System.out.println(s);
    }
    public void instanceReference(String str) {
        System.out.println(str);
    }
    public static void staticReference(String str) {
        System.out.println(str);
    }
    public void classReference(String str) {
        System.out.println(str);
    }
}

class LambdaSimple{
   public static void main(String[] agrs){
       new LambdaSimple().test();
   }
}
public void test() {
    //实例方法引用
    Consumer<String> consumer = new Outer()::instanceReference;
    consumer.accept("hello instance reference!");
    //静态方法引用
    consumer = Outer::staticReference;
    consumer.accept("hello static reference!");
    //构造方法引用
    consumer = Outer::new;
    consumer.accept("hello static reference!");
    //类方法引用
    BiConsumer<Outer, String> classReference = Outer::classReference;
    classReference.accept(new Outer(), "hello class reference!");
}

下面我们看下四种方法引用在字节码上有什么不同。

javac LambdaSimple.java

javap -c -v -p LambdaSimple.class
BootstrapMethods:
  //实例方法引用
  0: #45 invokestatic java/lang/invoke/LambdaMetafactory.metafactory:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
    Method arguments:
      #46 (Ljava/lang/Object;)V
      #47 invokevirtual Outer.instanceReference:(Ljava/lang/String;)V
      #48 (Ljava/lang/String;)V
  //静态方法引用    
  1: #45 invokestatic java/lang/invoke/LambdaMetafactory.metafactory:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
    Method arguments:
      #46 (Ljava/lang/Object;)V
      #52 invokestatic Outer.staticReference:(Ljava/lang/String;)V
      #48 (Ljava/lang/String;)V
  //构造方法引用    
  2: #45 invokestatic java/lang/invoke/LambdaMetafactory.metafactory:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
    Method arguments:
      #46 (Ljava/lang/Object;)V
      #55 newinvokespecial Outer."<init>":(Ljava/lang/String;)V
      #48 (Ljava/lang/String;)V
  //类方法引用    
  3: #45 invokestatic java/lang/invoke/LambdaMetafactory.metafactory:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
    Method arguments:
      #56 (Ljava/lang/Object;Ljava/lang/Object;)V
      #57 invokevirtual Outer.classReference:(Ljava/lang/String;)V
      #58 (LOuter;Ljava/lang/String;)V
yuansun@YuandeMacBookPro java % 

通过字节码 #47#52#55#57发现,Lambda方法引用没有生成脱糖方法,只是调用了引用的方法。

2.5 AOP处理Lambda

通过前面分析,我们知道Lambda表达式在编译后将方法体脱糖到一个内部私有方法(方法可能是静态或非静态),如果是方法引用则是调用了引用的方法。那我们只要找到这个内部私有方法或引用的方法,插入防抖逻辑就可以了。

如何找到这两种方法呢?

通过字节码分析我们知道在执行Lambda表达式时有一个JVM 指令invokedynamic,那么只要找到这个指令执行的BootstrapMethod,就能找到LambdaMetafactory#metafactory的参数列表。而LambdaMetafactory#metafactory参数列表中第二个参数implMethod就是脱糖后的方法 。

3. AOP框架选择

AOP的代表框架有:AspectJ,Javassist,ASM,对于全局点击事件插桩这个需求,他们三个都能满足。但是在处理Lambda时就泾渭分明了。

AspectJ无法对Lambda进行操作。Javassist虽然提供源代码级别API只需要很少的字节码知识;但是源代码级别API无法对Lambda进行操作。Javassist另一种基于字节码方式可以,通过字节码常量池ConstPool多次索引查找,即可找到Lambda表达式脱糖后的内部私有方法, 操作起来还是有一定的复杂度。ASM基于TreeApi的方式提供了调用method的指令,通过判断指令是否是InvokeDynamicInsnNode(invokedynamic),来完成脱糖后的方法查找。

  val iterator: MutableListIterator<AbstractInsnNode> = methodNode.instructions.iterator()
            while (iterator.hasNext()) {
                val node: AbstractInsnNode = iterator.next()
                //是否是 invokedynamic 指令   
                if (node is InvokeDynamicInsnNode) {
                    val desc: String = node.desc
                    val samBaseType = Type.getType(desc).returnType
                    //接口名
                    val samBase = samBaseType.descriptor.replaceFirst("L""")
                        .replace(";""")
                    //接口定义的方法名
                    val samMethodName: String = node.name
                    val bsmArgs: Array<Any> = node.bsmArgs
                    //接口方法描述符
                    val samMethodType = bsmArgs[0as Type
                    //脱糖后的方法,从Handle中取出该方法的信息
                    val handle: Handle = bsmArgs[1as Handle
                        
                    //handle.owner 方法所在的类
                    //handle.name  方法名称
                    //handle.desc  方法描述
                    //handle.tag  Opcodes.H_INVOKESTATIC 表示私有静态方法;Opcodes.H_INVOKESPECIAL 表示私有实例方法
                    ...    
                }
            }
        })

这么看来在处理Lambda表达式时ASM基于TreeApi的操作比Javassist基于字节码常量池多次查找方便的多。另外选择ASM还有一个重要的原因: 字节码插桩方面ASM比Javassist要高效一些。

4. ASM点击事件防抖处理

文章前面我们列出了开发中常见设置点击事件的方式,然后对每一种方式详细分析并给出解决思路。那么这一节就是思路的实现与细节处理。

4.1 插入代码定义

按照实现思路,我们先定义出要插入的代码。在这之前,笔者发现有些开发者使用AOP处理点击事件防抖时将判断一段时间多次点击的状态设计为静态。例如:

class BounceChecker{
    private static long lastClickTime = 0L;
    private static long interval = 1000L;   
    

    public static boolean check() {
        boolean isBounce = SystemClock.elapsedRealtime() - lastClickTime < sCheckTime;
        if (sDebug) {
            Log.d("BounceChecker""[checkTime:" + sCheckTime + ",isBounce:" + isBounce + "]");
        }
        if (!isBounce) {
            lastClickTime = SystemClock.elapsedRealtime();
        }
        return isBounce;
    }
}

tv.setOnClickListener(new OnClickListener() {
    @Override
    public void onClick(View v) {
       //一段时间多次点击
       if(BounceChecker.check()){
          return;
       } 
       //do something
    }
});

一般情况下这样处理没问题,但是业务中如果存在点击事件嵌套回调的情况。如果按照静态方法插入,会破坏原有的执行逻辑,这将是一个致命的问题。

例如:事件嵌套回调,插桩后的代码如下:

public class XXXManager {
    
    public static void start(View.OnClickListener click) {
        ...
        view.setOnClickListener(v -> {
           if(BounceChecker.check()){
               return;
            }
            //do something
            if (click != null) {  
                click.onClick(v);
            }
            //do something
        });
    }
}

public class XXXActivity{
    
    private void test(){
        XXXManager.start(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                if(BounceChecker.check()){
                    return;
                }
                //do something 
            }
        });
    }
}

按照调用逻辑XXXActivity#test中定义的回调逻辑将不能执行,这是因为当XXXManager收到点击事件时,check()返回false,执行回调;这时回调接口中又执行了一次check()此时返回true。两处check()使用的是同一个状态,这种情况的的代码插入就破坏了原有的执行逻辑。

通过嵌套回调场景分析我们不能把判断一段时间多次点击状态设计为静态的。应该是在哪里插入逻辑,就在哪里重置状态。而重置状态可以通过重新BounceChecker对象来完成。所以最终插入的代码应该是:

class BounceChecker{
    private long lastClickTime = 0L;
    private long interval = 1000L;   
    
    public boolean check() {
        boolean isBounce = SystemClock.elapsedRealtime() - lastClickTime < sCheckTime;
        if (sDebug) {
            Log.d("BounceChecker""[checkTime:" + sCheckTime + ",isBounce:" + isBounce + "]");
        }
        if (!isBounce) {
            lastClickTime = SystemClock.elapsedRealtime();
        }
        return isBounce;
    }
}
tv.setOnClickListener(new OnClickListener() {
    
    private BounceChecker $$bounceChecker;
    
    @Override
    public void onClick(View v) {
       if($$bounceChecker==null){
          $$bounceChecker=new BounceChecker();
       } 
       //一段时间多次点击
       if($$bounceChecker.check()){
          return;
       } 
       //do something
    }
});

这样在遇到嵌套回调时,就不会破坏原有的执行逻辑。

4.2 子类重写父类点击事件处理

如果父类实现了OnClickListener,子类重写了onClick,那么只能处理父类的onClick,无法处理子类的onClick。这是因为使用ASM对Class扫描时,只能获取到当前Class实现的interfaces。所以这种情况就需要额外处理下。从当前类向上遍历直到Object,判断是否实现OnClickListener,如果实现了则记录下来。在后续遍历当前类的methods时,再判断有没有重写onClick方法,有重写进行防抖代码插入,没重写就不需要处理。下面代码是向上遍历查找直接或间接实现目标接口的接口。

    /**
     * 查找直接或间接实现目标接口的接口
     *
     * @param className             当前类
     * @param targetInterfaces      目标接口
     * @param collectImplInterfaces 记录直接或间接实现目标接口的接口
     * 记录结果放入collectImplInterfaces中
     */

    fun findTargetInterfaceImpl(
        className: String,
        targetInterfaces: Set<String>,
        collectImplInterfaces: MutableSet<String>
    )
 {
        if (isObject(className) || targetInterfaces.size == collectImplInterfaces.size) {
            return
        }
        val reader = getClassReader(className) ?: return
        matchTargetInterface(reader.interfaces, targetInterfaces, collectImplInterfaces)
        findTargetInterfaceImpl(reader.superName, targetInterfaces, collectImplInterfaces)
    }

    /**
     * 匹配目标接口,将匹配结果放入recordImplInterfaceSet中
     * @param interfaces             待检查接口
     * @param targetInterfaceSet     目标接口
     * @param recordImplInterfaceSet 匹配结果
     */

    private fun matchTargetInterface(
        interfaces: Array<String>,
        targetInterfaceSet: Set<String>,
        recordImplInterfaceSet: MutableSet<String>
    )
 {
        if (interfaces.isEmpty() || targetInterfaceSet.size == recordImplInterfaceSet.size) {
            return
        }
        for (inter in interfaces) {
            if (targetInterfaceSet.contains(inter)) {
                recordImplInterfaceSet.add(inter)
            } else {
                val reader = getClassReader(inter) ?: return
                matchTargetInterface(reader.interfaces, targetInterfaceSet, recordImplInterfaceSet)
            }
        }
    }

4.3 Lambda防抖处理

ASM基于TreeApi的方式提供了调用method的指令,通过判断指令是否是InvokeDynamicInsnNode(invokedynamic),来完成脱糖后的方法查找。

关于这一点你可以回顾下 3. AOP框架选择一节内容;另外Lambda表达式可能会脱糖为静态方法或实例方法,也可能是引用的方法调用。所以我们在收集Lambda表达式真实调用的方法时需要标记该方法是静态的还是非静态的。关于这一点你可以回顾下2. Lambda原理一节内容。

如果是静态的需要调整下插入逻辑:

public class MainActivity{    
    private static BounceChecker $$sBounceChecker;
    private static void lambda$onCreate$0(View view){
        if($$sBounceChecker==null){
           $$sBounceChecker = new BounceChecker();
        }
        if($$sBounceChecker.isCheck()){
           return
        }
        //do something
    }
}

4.4 黑白名单处理

4.4.1 文件级黑白名单

项目中可能存在处理或排除某些路径下代码的防抖处理场景,所以设计时需要提供文件级黑白名单能力。如下所示:

debounce {
    //不处理com/example包下所有代码(包含example包下的子包)
    excludes = ["com/example/**/*",]
    //只处理com/example/ui包下的所有代码(包含ui包下的子包)    
    includes = ["com/example/ui/**/*"]     
}

excludesincludes 使用glob模式匹配 (.gitignore就是用glob模式匹配)

4.4.2 方法黑白名单

同样会存在某个方法处理或排除防抖处理的场景,所以设计时需要提供方法级黑白名单能力。includeForMethodAnnotationexcludeForMethodAnnotation

假设项目使用了ButterKnife且只处理主工程下的事件防抖

debounce {
    includes = [$主工程代码路径]
    /**
     * 排除ButterKnife生成的模版类
     * ButterKnife事件防抖由includeForMethodAnnotation保证
     */

    excludes = ["**/*_ViewBinding*.class"]

    /**
     * 声明在方法上的的这些注解都需要插桩
     * 比如处理ButterKnife OnClick和OnItemClick点击事件
     */

    includeForMethodAnnotation = ["Lbutterknife/OnClick;",
                                  "Lbutterknife/OnItemClick;"]     
}

假设对某一个View的点击事件不需要防抖处理,只需在事件方法上声明IgnoreClickDeBounce注解

tv.setOnClickListener(new View.OnClickListener() {
    @IgnoreClickDeBounce
    @Override
    public void onClick(View v) {
     
    }
});

注意: includeForMethodAnnotation和excludeForMethodAnnotation配置的是注解的字节码信息。

插件内部默认给includeForMethodAnnotation添加ClickDeBounce注解信息,所以无需配置ClickDeBounce注解,直接使用。

插件内部默认给excludeForMethodAnnotation添加IgnoreClickDeBounce注解信息,所以无需配置IgnoreClickDeBounce注解,直接使用。

4.4.3 两种黑白名单的作用和优先级

4.4.3.1 黑白名单作用

设计includeexcludeincludeForMethodAnnotationexcludeForMethodAnnotation 主要解决事件防抖个性化的需求,不是每个app都需要全局处理事件防抖。

于是有了includeexclude用于需要或排除文件级别的事件防抖。那还有一种场景是某个方法不需要防抖,于是插设计了includeForMethodAnnotationexcludeForMethodAnnotation用于需要或排除方法级别的防抖。

4.4.3.2 黑白名单优先级

exclude优先级高于includeexcludeForMethodAnnotation优先级高于includeForMethodAnnotation

分为两个步骤:

  • 执行时会遍历所有class文件,根据exclude的配置排除某些class文件处理,剩余的class文件再根据include配置判断是否需要处理
  • 第一步结束后会得到需要处理的class文件,然后遍历每一个class的method列表,通过excludeForMethodAnnotation的配置排除某个方法处理,剩余的method再根据includeForMethodAnnotation配置判断是否需要处理

4.5 多种点击事件防抖处理

项目中不止View#setOnClickListener这一种点击事件,所以我们还需要处理下其他控件设置的点击事件

例如:

  • ListView#onItemClick
  • ListView#onItemSelected
  • ExpandableListView#onGroupClick
  • ExpandableListView#onChildClick
  • BaseQuickAdapter#setOnItemClickListener
  • ....

点击事件种类太多,所以需要对外开放一个配置接口,上层应用想处理哪种点击事件,配置下就可以了。

debounce {
    /**
     * 需要防抖的事件信息,即想要处理哪些事件防抖。
     * 除了includeForMethodAnnotation方法级别白名单外,代码中只有匹配methodEntities声明的事件信息才会防抖。
     * 注意:插件中默认添加了`View.OnClickListener#onClick`事件信息,所以如果只是处理View的OnClickListener事件防抖,不需要声明methodEntities并添加事件信息。
     * 假如我们想处理`ListView#onItemClick`事件防抖,那么只在methodEntities声明`ListView#onItemClick`事件信息即可。
     * 当然你还可以添加其他类型事件信息。
     */

    methodEntities {
        onItemClick {//随便填写,在methodEntities只要唯一,就像你在写productFlavors
            methodName 'onItemClick'//方法名称
            methodDesc '(Landroid/widget/AdapterView;Landroid/view/View;IJ)V'//方法描述
            interfaceName 'android/widget/AdapterView$OnItemClickListener' //事件方法所在的接口名
        }
    }
}

注意:methodDesc和interfaceName配置的是字节码符号引用

4.6 html报告输出

ASM对代码插桩后的结果,通过 app\build\intermediates\transforms${custom transform}\ 路径可以查看,但是查看方式很不友好, 需要手动点开目录一个个文件查看,更难受的是想查看三方jar包的插桩情况,还需要把xx.jar依赖到项目中才能查看。

如果ASM对代码的插桩能提供一份直观的修改报告,那自然是再好不过。为了直观的查看ASM对代码的插桩情况,所以需要收集被插桩的class和方法信息,在trasform执行完毕后输出html报告。

那如何知道trasform执行完毕呢?

我们创建的trasform是添加到AppExtension的transforms列表中的,这些transforms会被创建为TransformTask,所以我们只需要监听transforms列表最后一个TransformTask是否执行完毕,执行完毕后输出html报告。

project.afterEvaluate {
    ...
    classesTransformCompleteListener(project) {
        dumpHtmlReprot(project, it.name)
    }
}

private fun classesTransformCompleteListener(
    project: Project,
    complete: (variant: BaseVariant) -> Unit
)
 {
    val appEx = project.appEx
    appEx.applicationVariants.forEach { variant ->
        val transform = findLastClassesTransform(appEx)
        val variantName = variant.name.capitalize()
        project.tasks.withType(TransformTask::class.java).find { transformTask ->
            transformTask.name.endsWith(variantName) && transformTask.transform == transform
        }?.doLast {
            complete(variant)
        }
    }
}

html报告:


- FIN -


推荐阅读

关于Java字节码,了解这些就够了

手把手教你处理 Android 编译期注解

再见 KAPT!使用 KSP 为 Kotlin 编译提速



您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存