前言
- 在
Android
开发中,限制按钮快速点击(按钮防抖)是一个常见的需求; - 在这篇文章里,我将介绍一种使用
AspectJ
的方法,基于注解处理器 & 运行时注解反射的原理。如果能帮上忙,请务必点赞加关注,这真的对我非常重要。
系列文章
- 《Android | 一文带你全面了解 AspectJ 框架》
- 《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
2.3 小结
代理类
和RxAndroid过滤表达式
这两种处理方法都存在两个缺点:
- 1. 侵入核心业务逻辑,需要将代码替换到需要限制点击的地方;
- 2. 修改工作量大,每一个增加限制点击的地方都要修改代码。
我们需要一种方案能够规避这两个缺点 —— AspectJ
。 AspectJ
是一个流行的Java
AOP(aspect-oriented programming)
编程扩展框架,若还不了解,请务必查看文章:《Android | 一文带你全面了解 AspectJ 框架》
3. 详细步骤
在下面的内容里,我们将使用AspectJ
框架,把限制快速点击的逻辑作为核心关注点
从业务逻辑中抽离出来,单独维护。具体步骤如下:
步骤1:添加AspectJ
依赖
-
- 依赖沪江的
AspectJX
Gradle插件 —— 在项目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
文件中添加依赖:
- 依赖AspectJ框架 —— 在包含
// 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
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...
推荐阅读
- 密码学 | Base64是加密算法吗?
- 算法面试题 | 回溯算法解题框架
- 算法面试题 | 链表问题总结
- Java | 带你理解 ServiceLoader 的原理与设计思想
- 计算机网络 | 图解 DNS & HTTPDNS 原理
- Android | 说说从 android:text 到 TextView 的过程
- Android | 面试必问的 Handler,你确定不看看?
- Android | 带你探究 LayoutInflater 布局解析原理
- Android | View & Fragment & Window 的 getContext() 一定返回 Activity 吗?