Android | 使用 AspectJ 限制按钮快速点击

3,378 阅读4分钟

请点赞关注,你的支持对我意义重大。

🔥 Hi,我是小彭。本文已收录到 GitHub · AndroidFamily 中。这里有 Android 进阶成长知识体系,有志同道合的朋友,关注公众号 [彭旭锐] 带你建立核心竞争力。

前言

  • Android开发中,限制按钮快速点击(按钮防抖)是一个常见的需求;
  • 在这篇文章里,我将介绍一种使用AspectJ的方法,基于注解处理器 & 运行时注解反射的原理。如果能帮上忙,请务必点赞加关注,这真的对我非常重要。

系列文章

延伸文章


目录


1. 定义需求

在开始讲解之前,我们先 定义需求,具体描述如下:

  • 限制快速点击需求 示意图:


2. 常规处理方法

目前比较常见的限制快速点击的处理方法有以下两种,具体如下:

2.1 封装代理类

封装一个代理类处理点击事件,代理类通过判断点击间隔决定是否拦截点击事件,具体代码如下:

// 代理类
public abstract class FastClickListener implements View.OnClickListener {
    private long mLastClickTime;
    private long interval = 1000L;

    public FastClickListener() {
    }

    public FastClickListener(long interval) {
        this.interval = interval;
    }

    @Override
    public void onClick(View v) {
        long currentTime = System.currentTimeMillis();
        if (currentTime - mLastClickTime > interval) {
            // 经过了足够长的时间,允许点击
            onClick();
            mLastClickTime = nowTime;
        } 
    }

    protected abstract void onClick();
}

在需要限制快速点击的地方使用该代理类,具体如下:

tv.setOnClickListener(new FastClickListener() {
    @Override
    protected void onClick() {
        // 处理点击逻辑
    }
});

2.2 RxAndroid 过滤表达式

使用RxJava的过滤表达式throttleFirst也可以限制快速点击,具体如下:

RxView.clicks(view)
    .throttleFirst(1, TimeUnit.SECONDS)
    .subscribe(new Consumer<Object>() {
        @Override
        public void accept(Object o) throws Exception {
            // 处理点击逻辑
        }
     });

2.3 小结

代理类RxAndroid过滤表达式这两种处理方法都存在两个缺点:

  • 1. 侵入核心业务逻辑,需要将代码替换到需要限制点击的地方;
  • 2. 修改工作量大,每一个增加限制点击的地方都要修改代码。

我们需要一种方案能够规避这两个缺点 —— AspectJAspectJ是一个流行的Java AOP(aspect-oriented programming)编程扩展框架,若还不了解,请务必查看文章:《Android | 一文带你全面了解 AspectJ 框架》


3. 详细步骤

在下面的内容里,我们将使用AspectJ框架,把限制快速点击的逻辑作为核心关注点从业务逻辑中抽离出来,单独维护。具体步骤如下:

步骤1:添加AspectJ依赖

    1. 依赖沪江的AspectJXGradle插件 —— 在项目build.gradle中添加插件依赖:
// 项目级build.gradle
dependencies {
    classpath 'com.android.tools.build:gradle:3.5.3'
    classpath 'com.hujiang.aspectjx:gradle-android-plugin-aspectjx:2.0.8'
}

如果插件下载速度过慢,可以直接依赖插件 jar文件,将插件下载到项目根目录(如/plugins),然后在项目build.gradle中添加插件依赖:

// 项目级build.gradle
dependencies {
    classpath 'com.android.tools.build:gradle:3.5.3'
    classpath fileTree(dir:'plugins', include:['*.jar'])
}
    1. 应用插件 —— 在App Modulebuild.gradle中应用插件:
// App Module的build.gradle
apply plugin: 'android-aspectjx'
...
    1. 依赖AspectJ框架 —— 在包含AspectJ代码的Modulebuild.gradle文件中添加依赖:
// Module级build.gradle
dependencies {
    ...
    api 'org.aspectj:aspectjrt:1.8.9'
    ...
}

步骤2:实现判断快速点击的工具类

  • 我们先实现一个判断View是否快速点击的工具类;
  • 实现原理是使用Viewtag属性存储最近一次的点击时间,每次点击时判断当前时间距离存储的时间是否已经经过了足够长的时间;
  • 为了避免调用View#setTag(int key,Object tag)时传入的key与其他地方传入的key冲突而造成覆盖,务必使用在资源文件中定义的 id,资源文件中的 id 能够有效保证全局唯一性,具体如下:
// ids.xml
<resources>
    <item type="id" name="view_click_time" />
</resources>
public class FastClickCheckUtil {

    /**
     * 判断是否属于快速点击
     *
     * @param view     点击的View
     * @param interval 快速点击的阈值
     * @return true:快速点击
     */
    public static boolean isFastClick(@NonNull View view, long interval) {
        int key = R.id.view_click_time;

        // 最近的点击时间
        long currentClickTime = System.currentTimeMillis();

        if(null == view.getTag(key)){
            // 1. 第一次点击

            // 保存最近点击时间
            view.setTag(key, currentClickTime);
            return false;
        }
        // 2. 非第一次点击

        // 上次点击时间
        long lastClickTime = (long) view.getTag(key);
        if(currentClickTime - lastClickTime < interval){
            // 未超过时间间隔,视为快速点击
            return true;
        }else{
            // 保存最近点击时间
            view.setTag(key, currentClickTime);
            return false;
        }
    }
}

步骤3:定义Aspect切面

使用@Aspect注解定义一个切面,使用该注解修饰的类会被AspectJ编译器识别为切面类:

@Aspect
public class FastClickCheckerAspect {
    // 随后填充
}

步骤4:定义PointCut切入点

使用@Pointcut注解定义一个切入点,编译期AspectJ编译器将搜索所有匹配的JoinPoint,执行织入:

@Aspect
public class FastClickAspect {

    // 定义一个切入点:View.OnClickListener#onClick()方法
    @Pointcut("execution(void android.view.View.OnClickListener.onClick(..))")
    public void methodViewOnClick() {
    }

    // 随后填充 Advice
}

步骤5:定义Advice增强

增强的方式有很多种,在这里我们使用@Around注解定义环绕增强,它将包装PointCut,在PointCut前后增加横切逻辑,具体如下:

@Aspect
public class FastClickAspect {
    
    // 定义切入点:View.OnClickListener#onClick()方法
    @Pointcut("execution(void android.view.View.OnClickListener.onClick(..))")
    public void methodViewOnClick() {}

    // 定义环绕增强,包装methodViewOnClick()切入点
    @Around("methodViewOnClick()")
    public void aroundViewOnClick(ProceedingJoinPoint joinPoint) throws Throwable {
        // 取出目标对象
        View target = (View) joinPoint.getArgs()[0];
        // 根据点击间隔是否超过2000,判断是否为快速点击
        if (!FastClickCheckUtil.isFastClick(target, 2000)) {
            joinPoint.proceed();
        }
    }
}

步骤6:实现View.OnClickListener

在这一步我们为View设置OnClickListener,可以看到我们并没有添加限制快速点击的相关代码,增强的逻辑对原有逻辑没有侵入,具体代码如下:

// 源码:
public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        findViewById(R.id.text).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                Log.i("AspectJ","click");
            }
        });
    }
}

编译代码,随后反编译AspectJ编译器执行织入后的.class文件。还不了解如何查找编译后的.class文件,请务必查看文章:《Android | 一文带你全面了解 AspectJ 框架》

public class MainActivity extends AppCompatActivity {
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(2131361820);
    findViewById(2131165349).setOnClickListener(new View.OnClickListener() {
          private static final JoinPoint.StaticPart ajc$tjp_0;
          
          // View.OnClickListener#onClick()
          public void onClick(View v) {
            View view = v;
            // 重构JoinPoint,执行环绕增强,也执行@Around修饰的方法
            JoinPoint joinPoint = Factory.makeJP(ajc$tjp_0, this, this, view);
            onClick_aroundBody1$advice(this, view, joinPoint, FastClickAspect.aspectOf(), (ProceedingJoinPoint)joinPoint);
          }
          
          static {
            ajc$preClinit();
          }
          
          private static void ajc$preClinit() {
            Factory factory = new Factory("MainActivity.java", null.class);
            ajc$tjp_0 = factory.makeSJP("method-execution", (Signature)factory.makeMethodSig("1", "onClick", "com.have.a.good.time.aspectj.MainActivity$1", "android.view.View", "v", "", "void"), 25);
          }
          
          // 原来在View.OnClickListener#onClick()中的代码,相当于核心业务逻辑
          private static final void onClick_aroundBody0(null ajc$this, View v, JoinPoint param1JoinPoint) {
            Log.i("AspectJ", "click");
          }
          
          // @Around方法中的代码,即源码中的aroundViewOnClick(),相当于Advice
          private static final void onClick_aroundBody1$advice(null ajc$this, View v, JoinPoint thisJoinPoint, FastClickAspect ajc$aspectInstance, ProceedingJoinPoint joinPoint) {
            View target = (View)joinPoint.getArgs()[0];
            if (!FastClickCheckUtil.isFastClick(target, 2000)) {
              // 非快速点击,执行点击逻辑
              ProceedingJoinPoint proceedingJoinPoint = joinPoint;
              onClick_aroundBody0(ajc$this, v, (JoinPoint)proceedingJoinPoint);
              null;
            } 
          }
        });
  }
}

小结

到这里,我们就讲解完使用AspectJ框架限制按钮快速点击的详细,总结如下:

  • 使用@Aspect注解描述一个切面,使用该注解修饰的类会被AspectJ编译器识别为切面类;
  • 使用@Pointcut注解定义一个切入点,编译期AspectJ编译器将搜索所有匹配的JoinPoint,执行织入;
  • 使用@Around注解定义一个增强,增强会被织入匹配的JoinPoint

4. 演进

现在,我们回归文章开头定义的需求,总共有4点。其中前两点使用目前的方案中已经能够实现,现在我们关注后面两点,即允许定制时间间隔覆盖尽可能多的点击场景

  • 需求回归 示意图:

4.1 定制时间间隔

在实际项目不同场景中的按钮,往往需要限制不同的点击时间间隔,因此我们需要有一种简便的方式用于定制不同场景的时间间隔,或者对于一些不需要限制快速点击的地方,有办法跳过快速点击判断,具体方法如下:

  • 定义注解
/**
 * 在需要定制时间间隔地方添加@FastClick注解
 */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface FastClick {
    long interval() default FastClickAspect.FAST_CLICK_INTERVAL_GLOBAL;
}
  • 修改切面类的Advice
@Aspect
public class SingleClickAspect {

    public static final long FAST_CLICK_INTERVAL_GLOBAL = 1000L;

    @Pointcut("execution(void android.view.View.OnClickListener.onClick(..))")
    public void methodViewOnClick() {}

    @Around("methodViewOnClick()")
    public void aroundViewOnClick(ProceedingJoinPoint joinPoint) throws Throwable {
        // 取出JoinPoint的签名
        MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
        // 取出JoinPoint的方法
        Method method = methodSignature.getMethod();

        // 1. 全局统一的时间间隔
        long interval = FAST_CLICK_INTERVAL_GLOBAL;

        if (method.isAnnotationPresent(FastClick.class)) {
            // 2. 如果方法使用了@FastClick修饰,取出定制的时间间隔

            FastClick singleClick = method.getAnnotation(FastClick.class);
            interval = singleClick.interval();
        }
        // 取出目标对象
        View target = (View) joinPoint.getArgs()[0];
        // 3. 根据点击间隔是否超过interval,判断是否为快速点击
        if (!FastClickCheckUtil.isFastClick(target, interval)) {
            joinPoint.proceed();
        }
    }
}
  • 使用注解
findViewById(R.id.text).setOnClickListener(new View.OnClickListener() {
    @FastClick(interval = 5000L)
    @Override
    public void onClick(View v) {
        Log.i("AspectJ","click");
    }
});

4.2 完整场景覆盖

ButterKnife @OnClick android:onClick OK RecyclerView / ListView Java Lambda NO Kotlin Lambda OK DataBinding OK

Editting...


我是小彭,带你构建 Android 知识体系。技术和职场问题,请关注公众号 [彭旭锐]私信我提问。