前言
- 在
Android开发中,限制按钮快速点击(按钮防抖)是一个常见的需求; - 在这篇文章里,我将介绍一种使用
AspectJ的方法,基于注解处理器 & 运行时注解反射的原理。如果能帮上忙,请务必点赞加关注,这真的对我非常重要。
系列文章
延伸文章
- 关于 反射,请阅读:《Java | 反射:在运行时访问类型信息(含 Kotlin)》
- 关于 注解,请阅读:《Java | 这是一篇全面的注解使用攻略(含 Kotlin)》
- 关于 注解处理器(APT),请阅读:《Java | 注解处理器(APT)原理解析 & 实践》
目录
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. 修改工作量大,每一个增加限制点击的地方都要修改代码。
我们需要一种方案能够规避这两个缺点 —— AspectJ。 AspectJ是一个流行的JavaAOP(aspect-oriented programming)编程扩展框架,若还不了解,请务必查看文章:《Android | 一文带你全面了解 AspectJ 框架》
3. 详细步骤
在下面的内容里,我们将使用AspectJ框架,把限制快速点击的逻辑作为核心关注点从业务逻辑中抽离出来,单独维护。具体步骤如下:
步骤1:添加AspectJ依赖
- 依赖沪江的
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']) } 复制代码
- 应用插件 —— 在
App Module的build.gradle中应用插件:
// App Module的build.gradle apply plugin: 'android-aspectjx' ... 复制代码
- 依赖AspectJ框架 —— 在包含
AspectJ代码的Module的build.gradle文件中添加依赖:
// Module级build.gradle dependencies { ... api 'org.aspectj:aspectjrt:1.8.9' ... } 复制代码
步骤2:实现判断快速点击的工具类
- 我们先实现一个判断
View是否快速点击的工具类; - 实现原理是使用
View的tag属性存储最近一次的点击时间,每次点击时判断当前时间距离存储的时间是否已经经过了足够长的时间; - 为了避免调用
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...


