Android | 使用 AspectJ 限制按鈕快速點(diǎn)擊

前言

  • Android開發(fā)中,限制按鈕快速點(diǎn)擊(按鈕防抖)是一個(gè)常見的需求;
  • 在這篇文章里,我將介紹一種使用AspectJ的方法,基于注解處理器 & 運(yùn)行時(shí)注解反射的原理。如果能幫上忙,請(qǐng)務(wù)必點(diǎn)贊加關(guān)注,這真的對(duì)我非常重要。

系列文章

延伸文章


目錄


1. 定義需求

在開始講解之前,我們先定義需求,具體描述如下:

限制快速點(diǎn)擊需求 示意圖

2. 常規(guī)處理方法

目前比較常見的限制快速點(diǎn)擊的處理方法有以下兩種,具體如下:

2.1 封裝代理類

封裝一個(gè)代理類處理點(diǎn)擊事件,代理類通過判斷點(diǎn)擊間隔決定是否攔截點(diǎn)擊事件,具體代碼如下:

// 代理類
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) {
            // 經(jīng)過了足夠長的時(shí)間,允許點(diǎn)擊
            onClick();
            mLastClickTime = nowTime;
        } 
    }

    protected abstract void onClick();
}

在需要限制快速點(diǎn)擊的地方使用該代理類,具體如下:

tv.setOnClickListener(new FastClickListener() {
    @Override
    protected void onClick() {
        // 處理點(diǎn)擊邏輯
    }
});

2.2 RxAndroid 過濾表達(dá)式

使用RxJava的過濾表達(dá)式throttleFirst也可以限制快速點(diǎn)擊,具體如下:

RxView.clicks(view)
    .throttleFirst(1, TimeUnit.SECONDS)
    .subscribe(new Consumer<Object>() {
        @Override
        public void accept(Object o) throws Exception {
            // 處理點(diǎn)擊邏輯
        }
     });

2.3 小結(jié)

代理類RxAndroid過濾表達(dá)式這兩種處理方法都存在兩個(gè)缺點(diǎn):

  • 1. 侵入核心業(yè)務(wù)邏輯,需要將代碼替換到需要限制點(diǎn)擊的地方;
  • 2. 修改工作量大,每一個(gè)增加限制點(diǎn)擊的地方都要修改代碼。

我們需要一種方案能夠規(guī)避這兩個(gè)缺點(diǎn) —— AspectJ。 AspectJ是一個(gè)流行的Java AOP(aspect-oriented programming)編程擴(kuò)展框架,若還不了解,請(qǐng)務(wù)必查看文章:《Android | 一文帶你全面了解 AspectJ 框架》


3. 詳細(xì)步驟

在下面的內(nèi)容里,我們將使用AspectJ框架,把限制快速點(diǎn)擊的邏輯作為核心關(guān)注點(diǎn)從業(yè)務(wù)邏輯中抽離出來,單獨(dú)維護(hù)。具體步驟如下:

步驟1:添加AspectJ依賴

    1. 依賴滬江的AspectJXGradle插件 —— 在項(xiàng)目build.gradle中添加插件依賴:
// 項(xiàng)目級(jí)build.gradle
dependencies {
    classpath 'com.android.tools.build:gradle:3.5.3'
    classpath 'com.hujiang.aspectjx:gradle-android-plugin-aspectjx:2.0.8'
}

如果插件下載速度過慢,可以直接依賴插件 jar文件,將插件下載到項(xiàng)目根目錄(如/plugins),然后在項(xiàng)目build.gradle中添加插件依賴:

// 項(xiàng)目級(jí)build.gradle
dependencies {
    classpath 'com.android.tools.build:gradle:3.5.3'
    classpath fileTree(dir:'plugins', include:['*.jar'])
}
    1. 應(yīng)用插件 —— 在App Modulebuild.gradle中應(yīng)用插件:
// App Module的build.gradle
apply plugin: 'android-aspectjx'
...
    1. 依賴AspectJ框架 —— 在包含AspectJ代碼的Modulebuild.gradle文件中添加依賴:
// Module級(jí)build.gradle
dependencies {
    ...
    api 'org.aspectj:aspectjrt:1.8.9'
    ...
}

步驟2:實(shí)現(xiàn)判斷快速點(diǎn)擊的工具類

  • 我們先實(shí)現(xiàn)一個(gè)判斷View是否快速點(diǎn)擊的工具類;
  • 實(shí)現(xiàn)原理是使用Viewtag屬性存儲(chǔ)最近一次的點(diǎn)擊時(shí)間,每次點(diǎn)擊時(shí)判斷當(dāng)前時(shí)間距離存儲(chǔ)的時(shí)間是否已經(jīng)經(jīng)過了足夠長的時(shí)間;
  • 為了避免調(diào)用View#setTag(int key,Object tag)時(shí)傳入的key與其他地方傳入的key沖突而造成覆蓋,務(wù)必使用在資源文件中定義的 id,資源文件中的 id 能夠有效保證全局唯一性,具體如下:
// ids.xml
<resources>
    <item type="id" name="view_click_time" />
</resources>
public class FastClickCheckUtil {

    /**
     * 判斷是否屬于快速點(diǎn)擊
     *
     * @param view     點(diǎn)擊的View
     * @param interval 快速點(diǎn)擊的閾值
     * @return true:快速點(diǎn)擊
     */
    public static boolean isFastClick(@NonNull View view, long interval) {
        int key = R.id.view_click_time;

        // 最近的點(diǎn)擊時(shí)間
        long currentClickTime = System.currentTimeMillis();

        if(null == view.getTag(key)){
            // 1. 第一次點(diǎn)擊

            // 保存最近點(diǎn)擊時(shí)間
            view.setTag(key, currentClickTime);
            return false;
        }
        // 2. 非第一次點(diǎn)擊

        // 上次點(diǎn)擊時(shí)間
        long lastClickTime = (long) view.getTag(key);
        if(currentClickTime - lastClickTime < interval){
            // 未超過時(shí)間間隔,視為快速點(diǎn)擊
            return true;
        }else{
            // 保存最近點(diǎn)擊時(shí)間
            view.setTag(key, currentClickTime);
            return false;
        }
    }
}

步驟3:定義Aspect切面

使用@Aspect注解定義一個(gè)切面,使用該注解修飾的類會(huì)被AspectJ編譯器識(shí)別為切面類:

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

步驟4:定義PointCut切入點(diǎn)

使用@Pointcut注解定義一個(gè)切入點(diǎn),編譯期AspectJ編譯器將搜索所有匹配的JoinPoint,執(zhí)行織入:

@Aspect
public class FastClickAspect {

    // 定義一個(gè)切入點(diǎn):View.OnClickListener#onClick()方法
    @Pointcut("execution(void android.view.View.OnClickListener.onClick(..))")
    public void methodViewOnClick() {
    }

    // 隨后填充 Advice
}

步驟5:定義Advice增強(qiáng)

增強(qiáng)的方式有很多種,在這里我們使用@Around注解定義環(huán)繞增強(qiáng),它將包裝PointCut,在PointCut前后增加橫切邏輯,具體如下:

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

    // 定義環(huán)繞增強(qiáng),包裝methodViewOnClick()切入點(diǎn)
    @Around("methodViewOnClick()")
    public void aroundViewOnClick(ProceedingJoinPoint joinPoint) throws Throwable {
        // 取出目標(biāo)對(duì)象
        View target = (View) joinPoint.getArgs()[0];
        // 根據(jù)點(diǎn)擊間隔是否超過2000,判斷是否為快速點(diǎn)擊
        if (!FastClickCheckUtil.isFastClick(target, 2000)) {
            joinPoint.proceed();
        }
    }
}

步驟6:實(shí)現(xiàn)View.OnClickListener

在這一步我們?yōu)?code>View設(shè)置OnClickListener,可以看到我們并沒有添加限制快速點(diǎn)擊的相關(guān)代碼,增強(qiáng)的邏輯對(duì)原有邏輯沒有侵入,具體代碼如下:

// 源碼:
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編譯器執(zhí)行織入后的.class文件。還不了解如何查找編譯后的.class文件,請(qǐng)務(wù)必查看文章:《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;
            // 重構(gòu)JoinPoint,執(zhí)行環(huán)繞增強(qiáng),也執(zhí)行@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()中的代碼,相當(dāng)于核心業(yè)務(wù)邏輯
          private static final void onClick_aroundBody0(null ajc$this, View v, JoinPoint param1JoinPoint) {
            Log.i("AspectJ", "click");
          }
          
          // @Around方法中的代碼,即源碼中的aroundViewOnClick(),相當(dāng)于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)) {
              // 非快速點(diǎn)擊,執(zhí)行點(diǎn)擊邏輯
              ProceedingJoinPoint proceedingJoinPoint = joinPoint;
              onClick_aroundBody0(ajc$this, v, (JoinPoint)proceedingJoinPoint);
              null;
            } 
          }
        });
  }
}

小結(jié)

到這里,我們就講解完使用AspectJ框架限制按鈕快速點(diǎn)擊的詳細(xì),總結(jié)如下:

  • 使用@Aspect注解描述一個(gè)切面,使用該注解修飾的類會(huì)被AspectJ編譯器識(shí)別為切面類;
  • 使用@Pointcut注解定義一個(gè)切入點(diǎn),編譯期AspectJ編譯器將搜索所有匹配的JoinPoint,執(zhí)行織入;
  • 使用@Around注解定義一個(gè)增強(qiáng),增強(qiáng)會(huì)被織入匹配的JoinPoint

4. 演進(jìn)

現(xiàn)在,我們回歸文章開頭定義的需求,總共有4點(diǎn)。其中前兩點(diǎn)使用目前的方案中已經(jīng)能夠?qū)崿F(xiàn),現(xiàn)在我們關(guān)注后面兩點(diǎn),即允許定制時(shí)間間隔覆蓋盡可能多的點(diǎn)擊場景。

需求回歸 示意圖

4.1 定制時(shí)間間隔

在實(shí)際項(xiàng)目不同場景中的按鈕,往往需要限制不同的點(diǎn)擊時(shí)間間隔,因此我們需要有一種簡便的方式用于定制不同場景的時(shí)間間隔,或者對(duì)于一些不需要限制快速點(diǎn)擊的地方,有辦法跳過快速點(diǎn)擊判斷,具體方法如下:

  • 定義注解
/**
 * 在需要定制時(shí)間間隔地方添加@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. 全局統(tǒng)一的時(shí)間間隔
        long interval = FAST_CLICK_INTERVAL_GLOBAL;

        if (method.isAnnotationPresent(FastClick.class)) {
            // 2. 如果方法使用了@FastClick修飾,取出定制的時(shí)間間隔

            FastClick singleClick = method.getAnnotation(FastClick.class);
            interval = singleClick.interval();
        }
        // 取出目標(biāo)對(duì)象
        View target = (View) joinPoint.getArgs()[0];
        // 3. 根據(jù)點(diǎn)擊間隔是否超過interval,判斷是否為快速點(diǎn)擊
        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...


推薦閱讀

感謝喜歡!你的點(diǎn)贊是對(duì)我最大的鼓勵(lì)!歡迎關(guān)注彭旭銳的GitHub!

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,702評(píng)論 6 534
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 98,615評(píng)論 3 419
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事?!?“怎么了?”我有些...
    開封第一講書人閱讀 176,606評(píng)論 0 376
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,044評(píng)論 1 314
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 71,826評(píng)論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 55,227評(píng)論 1 324
  • 那天,我揣著相機(jī)與錄音,去河邊找鬼。 笑死,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,307評(píng)論 3 442
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 42,447評(píng)論 0 289
  • 序言:老撾萬榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 48,992評(píng)論 1 335
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 40,807評(píng)論 3 355
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 43,001評(píng)論 1 370
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,550評(píng)論 5 361
  • 正文 年R本政府宣布,位于F島的核電站,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 44,243評(píng)論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,667評(píng)論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,930評(píng)論 1 287
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 51,709評(píng)論 3 393
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 47,996評(píng)論 2 374

推薦閱讀更多精彩內(nèi)容