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

前言

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

系列文章

延伸文章


目錄


1. 定義需求

在開(kāi)始講解之前嵌灰,我們先定義需求,具體描述如下:

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

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

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

2.1 封裝代理類(lèi)

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

// 代理類(lèi)
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)過(guò)了足夠長(zhǎng)的時(shí)間剩瓶,允許點(diǎn)擊
            onClick();
            mLastClickTime = nowTime;
        } 
    }

    protected abstract void onClick();
}

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

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

2.2 RxAndroid 過(guò)濾表達(dá)式

使用RxJava的過(guò)濾表達(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é)

代理類(lèi)RxAndroid過(guò)濾表達(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ù)邏輯中抽離出來(lái)趴荸,單獨(dú)維護(hù)儒溉。具體步驟如下:

步驟1:添加AspectJ依賴(lài)

    1. 依賴(lài)滬江的AspectJXGradle插件 —— 在項(xiàng)目build.gradle中添加插件依賴(lài):
// 項(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'
}

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

// 項(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. 依賴(lài)AspectJ框架 —— 在包含AspectJ代碼的Modulebuild.gradle文件中添加依賴(lài):
// Module級(jí)build.gradle
dependencies {
    ...
    api 'org.aspectj:aspectjrt:1.8.9'
    ...
}

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

  • 我們先實(shí)現(xiàn)一個(gè)判斷View是否快速點(diǎn)擊的工具類(lèi)睁搭;
  • 實(shí)現(xiàn)原理是使用Viewtag屬性存儲(chǔ)最近一次的點(diǎn)擊時(shí)間,每次點(diǎn)擊時(shí)判斷當(dāng)前時(shí)間距離存儲(chǔ)的時(shí)間是否已經(jīng)經(jīng)過(guò)了足夠長(zhǎ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){
            // 未超過(guò)時(shí)間間隔锌唾,視為快速點(diǎn)擊
            return true;
        }else{
            // 保存最近點(diǎn)擊時(shí)間
            view.setTag(key, currentClickTime);
            return false;
        }
    }
}

步驟3:定義Aspect切面

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

@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)擊間隔是否超過(guò)2000惧财,判斷是否為快速點(diǎn)擊
        if (!FastClickCheckUtil.isFastClick(target, 2000)) {
            joinPoint.proceed();
        }
    }
}

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

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

// 源碼:
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);
          }
          
          // 原來(lái)在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è)切面绊寻,使用該注解修飾的類(lèi)會(huì)被AspectJ編譯器識(shí)別為切面類(lèi)花墩;
  • 使用@Pointcut注解定義一個(gè)切入點(diǎn),編譯期AspectJ編譯器將搜索所有匹配的JoinPoint澄步,執(zhí)行織入冰蘑;
  • 使用@Around注解定義一個(gè)增強(qiáng),增強(qiáng)會(huì)被織入匹配的JoinPoint

4. 演進(jìn)

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

需求回歸 示意圖

4.1 定制時(shí)間間隔

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

  • 定義注解
/**
 * 在需要定制時(shí)間間隔地方添加@FastClick注解
 */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface FastClick {
    long interval() default FastClickAspect.FAST_CLICK_INTERVAL_GLOBAL;
}
  • 修改切面類(lèi)的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)擊間隔是否超過(guò)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 完整場(chǎng)景覆蓋

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)系作者
  • 序言:七十年代末惶岭,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子犯眠,更是在濱河造成了極大的恐慌,老刑警劉巖症革,帶你破解...
    沈念sama閱讀 221,576評(píng)論 6 515
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件筐咧,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡噪矛,警方通過(guò)查閱死者的電腦和手機(jī)量蕊,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,515評(píng)論 3 399
  • 文/潘曉璐 我一進(jìn)店門(mén),熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)艇挨,“玉大人残炮,你說(shuō)我怎么就攤上這事∷醣酰” “怎么了势就?”我有些...
    開(kāi)封第一講書(shū)人閱讀 168,017評(píng)論 0 360
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)脉漏。 經(jīng)常有香客問(wèn)我苞冯,道長(zhǎng),這世上最難降的妖魔是什么侧巨? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 59,626評(píng)論 1 296
  • 正文 為了忘掉前任舅锄,我火速辦了婚禮,結(jié)果婚禮上司忱,老公的妹妹穿的比我還像新娘皇忿。我一直安慰自己,他們只是感情好坦仍,可當(dāng)我...
    茶點(diǎn)故事閱讀 68,625評(píng)論 6 397
  • 文/花漫 我一把揭開(kāi)白布鳍烁。 她就那樣靜靜地躺著,像睡著了一般桨踪。 火紅的嫁衣襯著肌膚如雪老翘。 梳的紋絲不亂的頭發(fā)上,一...
    開(kāi)封第一講書(shū)人閱讀 52,255評(píng)論 1 308
  • 那天,我揣著相機(jī)與錄音铺峭,去河邊找鬼墓怀。 笑死,一個(gè)胖子當(dāng)著我的面吹牛卫键,可吹牛的內(nèi)容都是我干的傀履。 我是一名探鬼主播,決...
    沈念sama閱讀 40,825評(píng)論 3 421
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼莉炉,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼钓账!你這毒婦竟也來(lái)了?” 一聲冷哼從身側(cè)響起絮宁,我...
    開(kāi)封第一講書(shū)人閱讀 39,729評(píng)論 0 276
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤梆暮,失蹤者是張志新(化名)和其女友劉穎,沒(méi)想到半個(gè)月后绍昂,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體啦粹,經(jīng)...
    沈念sama閱讀 46,271評(píng)論 1 320
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 38,363評(píng)論 3 340
  • 正文 我和宋清朗相戀三年窘游,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了唠椭。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 40,498評(píng)論 1 352
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡忍饰,死狀恐怖贪嫂,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情艾蓝,我是刑警寧澤力崇,帶...
    沈念sama閱讀 36,183評(píng)論 5 350
  • 正文 年R本政府宣布,位于F島的核電站饶深,受9級(jí)特大地震影響餐曹,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜敌厘,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,867評(píng)論 3 333
  • 文/蒙蒙 一台猴、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧俱两,春花似錦饱狂、人聲如沸。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 32,338評(píng)論 0 24
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)。三九已至尿孔,卻和暖如春俊柔,著一層夾襖步出監(jiān)牢的瞬間筹麸,已是汗流浹背。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 33,458評(píng)論 1 272
  • 我被黑心中介騙來(lái)泰國(guó)打工雏婶, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留物赶,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 48,906評(píng)論 3 376
  • 正文 我出身青樓留晚,卻偏偏與公主長(zhǎng)得像酵紫,于是被迫代替她去往敵國(guó)和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子错维,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,507評(píng)論 2 359

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