AOP - 消除控制元件的點選抖動

我叫小鄧子發表於2018-11-29

Photo by Gary Bendig on Unsplash

我在鏈家網從事Android開發已經三年了,一直致力於優質APP的開發與探索,有時候會寫一些工具來提高效率,但更多時候是用技術幫助業務增長。我們有專業的測試團隊,我嘗試與他們保持溝通,聽取他們的建議和反饋,並及時的做出修正。

如果你是小型移動開發團隊成員,或開源專案貢獻者,你就應該收集這些反饋資訊,並積極尋求解決方案,因為它們是你責任的一部分。

我最近收到了一些反饋,是關於使用者體驗的,而且我也相信如果不做特殊處理,很多應用都會出現類似問題,因此我會在接下來與大家分享我的解決思路。本文提到的所有程式碼都可以通過github下載。

背景&現狀

最近,我們的測試團隊向我反饋,如果頻繁點選列表頁的同一個卡片會同時開啟兩個詳情頁面,甚至過於頻繁地提交表單也會彈出兩個對話方塊。雖然這不會導致應用的崩潰,但卻是一個令人頭痛的體驗問題,會讓使用它的使用者感到困惑。

我抱著僥倖心理在經常使用的APP 中嘗試同樣的操作,想知道哪些應用會出現和我們一樣的現象。

在此之前,我需要鄭重申明,我沒有任何惡意詆譭的目的,如果侵犯了您的權益,請通知我

“知乎”和“網易雲音樂”是我日常使用頻率最高的兩款應用,不幸的是它們都會出現這種“抖動現象”。

我們先來看知乎的“抖動”現象:

AOP - 消除控制元件的點選抖動

很明顯我點選了頭像,但同時開啟了兩個主頁,我需要再點選兩次back鍵才能回到之前的頁面。

再來看一下網易雲音樂的:

AOP - 消除控制元件的點選抖動

我甚至開始困惑這是究竟產品屬性,還是因為“抖動”造成的錯誤現象 : (

不得不說的是,“點選抖動”在一定程度上影響了使用者體驗,而且在極端情況下必然引起程式的崩潰。那麼,接下來我們就進入主題,一起探索如何優雅的消除“點選抖動”的存在。

修改Activity啟動模式?

針對所有開啟Activity的情況,我們可以在AndroidManifest.xml中修改啟動模式,避免開啟重複的頁面:

<activity android:name=".YourActivity"
          android:launchMode="singleTop" >
            ...
</activity>
複製程式碼

但這種方法並不通用,我們還有很多喚起選單對話方塊的操作,而且某些業務中的Activity並不能設定singleTop,因此我們不能通過設定launchMode的方式來避免“抖動”的產生。

自定義DebouncedViewClickListener?

既然配置AndroidManifest的方式行不通,那我們就粗暴地**“為所有的可點選控制元件都新增防抖策略”**。

最常見的就是給每一個點選事件的監聽介面新增攔截邏輯。拿OnClickListener介面舉例,我可以很快寫出一個通用的防抖抽象類:

public abstract class DebouncedView$OnClickListener implements View.OnClickListener {

  private final long debounceIntervalInMillis;
  private long previousClickTimestamp;

  public DebouncedView$OnClickListener(long debounceIntervalInMillis) {
    this.debounceIntervalInMillis = debounceIntervalInMillis;
  }

  @Override public void onClick(View view) {

    final long currentClickTimestamp = TimeUnit.NANOSECONDS.toMillis(System.nanoTime());

    if (previousClickTimestamp == 0
        || currentClickTimestamp - previousClickTimestamp >= debounceIntervalInMillis) {

      //update click timestamp
      previousClickTimestamp = currentClickTimestamp;

      this.onDebouncedClick(view);
    }
  }

  public abstract void onDebouncedClick(View v);
}

複製程式碼

debounceIntervalInMillis來設定防抖間隔,即在這段時間內不允許發生兩次點選,值得一提的是點選事件已經發生了,我們只是攔截它以至於不再傳遞至業務邏輯罷了,300ms是個經驗值,僅供參考。然後在需要處理點選事件的地方使用它:

    findViewById(R.id.button).setOnClickListener(new DebouncedView$OnClickListener(300) {
      @Override public void onDebouncedClick(View v) {
        //do something
      }
    });
複製程式碼

這看起來很完美,我們只需要多寫幾個代理類即可,以滿足OnItemClickListenerDialogInterface$OnClickListener或其它回撥介面。

真的解決了我們所有疑惑嗎?答案是:NO !

首先,我們的專案已經啟動很久了,並且有了穩定的線上版本,這就意味著我們必須掃描程式碼倉庫,並對所有相關程式碼進行替換,這種方式明顯低效又愚蠢。

其次,我們是一個團隊在開發,並不是我一個人,因此我必須將這種寫法提交到我們的編碼規範中,以強制團隊其他人去遵守規範,並且在code review中也要格外地注意,很顯然在無形之中增加了人力成本。

最後,也是最重要的一點,它多多少少的侵入了業務,我認為這種防抖策略應該像無埋點統計工作那樣,對於業務來講是透明的,也是無感知的。

AOP ? YES !

綜合以上幾種情況的考慮,AOP無疑成了最好的解決方案。

幸運的是,我會使用一些諸如ASM和AspectJ這樣的程式碼織入框架,在經過一番嘗試後,最終選擇使用ASM來打造這個小工具,因為ASM的語法更通俗易懂,並且與gradle的聯動效果更好,它能夠讓我非常方便的修改位元組碼,而AspectJ在這些維度的比較上實在顯得笨重。

在此宣告,本篇文章並不是對ASM的詳解,你可以通過上網查到大量的學習資料和用例程式碼,因此請允許我在這裡不做詳細的說明。

先看一下我們修改前的原始碼,在點選回撥中開啟另一個Activity。:

  @Override public void onClick(View v) {
    startActivity(new Intent(MainActivity.this, SecondActivity.class));
  }
複製程式碼

下面是我們所期望的修改後的程式碼:

  @Override public void onClick(View v) {
    if (DebouncedClickPredictor.shouldDoClick(v)) {
      startActivity(new Intent(MainActivity.this, SecondActivity.class));
    }
  }
複製程式碼

我們希望位元組碼被修改後,原有的邏輯被包含在一個if判斷中,DebouncedClickPredictor類有一個重要的函式:boolean shouldDoClick(android.view.View)用來判斷目標View的本次點選是否屬於抖動,我們為每一個被點選的控制元件都設定一個凍結期,在這個期間不允許出現兩次及其以上的點選發生

再次重申:View的點選事件已經發生了,我們只是攔截它以至於不會達到業務程式碼

public class DebouncedClickPredictor {

  public static long FROZEN_WINDOW_MILLIS = 300L;

  private static final String TAG = DebouncedClickPredictor.class.getSimpleName();

  private static final Map<View, FrozenView> viewWeakHashMap = new WeakHashMap<>();

  public static boolean shouldDoClick(View targetView) {

    FrozenView frozenView = viewWeakHashMap.get(targetView);
    final long now = now();

    if (frozenView == null) {
      frozenView = new FrozenView(targetView);
      frozenView.setFrozenWindow(now + FROZEN_WINDOW_MILLIS);
      viewWeakHashMap.put(targetView, frozenView);
      return true;
    }

    if (now >= frozenView.getFrozenWindowTime()) {
      frozenView.setFrozenWindow(now + FROZEN_WINDOW_MILLIS);
      return true;
    }

    return false;
  }

  private static long now() {
    return TimeUnit.NANOSECONDS.toMillis(System.nanoTime());
  }

  private static class FrozenView extends WeakReference<View> {
    private long FrozenWindowTime;

    FrozenView(View referent) {
      super(referent);
    }

    long getFrozenWindowTime() {
      return FrozenWindowTime;
    }

    void setFrozenWindow(long expirationTime) {
      this.FrozenWindowTime = expirationTime;
    }
  }
}

複製程式碼

然後是位元組碼織入操作,建立我們自己的ClassVisitor,並重寫visitMethod函式,在這裡處理所有與View.OnClickListener函式簽名相同的方法。

  @Override
  public MethodVisitor visitMethod(int access, final String name, String desc, String signature,
      String[] exceptions) {

    MethodVisitor methodVisitor = cv.visitMethod(access, name, desc, signature, exceptions);

    // android.view.View.OnClickListener.onClick(android.view.View)
    if (((access & ACC_PUBLIC) != 0 && (access & ACC_STATIC) == 0) && //
        name.equals("onClick") && //
        desc.equals("(Landroid/view/View;)V")) {
      methodVisitor = new View$OnClickListenerMethodAdapter(methodVisitor);
    }

    return methodVisitor;
  }

複製程式碼

最後在View$OnClickListenerMethodAdapter類中做相應的函式位元組修改邏輯,即所有滿足條件函式的第一行插入DebouncedClickPredictor.shouldDoClick(v)

class View$OnClickListenerMethodAdapter extends MethodVisitor {

  View$OnClickListenerMethodAdapter(MethodVisitor methodVisitor) {
    super(Opcodes.ASM5, methodVisitor);
  }

  @Override public void visitCode() {
    super.visitCode();

    ......

    mv.visitVarInsn(ALOAD, 1);
    mv.visitMethodInsn(INVOKESTATIC, "com/smartdengg/clickdebounce/DebouncedPredictor", "shouldDoClick",
        "(Landroid/view/View;)Z", false);
    Label label = new Label();
    mv.visitJumpInsn(IFNE, label);
    mv.visitInsn(RETURN);
    mv.visitLabel(label);

    ......

  }
}
複製程式碼

如果你覺得這些程式碼太抽象,那麼我們可以通過一張圖來更好的理解它:

AOP - 消除控制元件的點選抖動

一句話總結:我們攔截了處於凍結視窗內的點選事件,讓它們無法執行到我們的業務邏輯。

Gradle外掛

以上就是我們關於處理抖動的核心思路,看起來程式碼量並不多,而且也不難理解,為了方便使用,我決定將它做成gradle外掛。在外掛中我們只需要對輸入的位元組碼進行轉換,然後將修改後的位元組碼寫入到指定位置以便下一個任務繼續使用,感興趣的可以自行閱讀DebounceGradlePlugin的原始碼實現。需要注意的是,我們必須分別處理普通檔案和壓縮檔案的轉換,並且儘可能的支援增量構建,畢竟構建時間就是黃金。

值得一提的是,我希望這個外掛不僅支援application,還應該支援library,因此我在修改位元組碼的過程中,為所有已經修改過的函式新增了一個註解@Debounced,從而避免二次修改所造成的邏輯錯誤,因此對上面提到的View$OnClickListenerMethodAdapter補充了織入註解的邏輯。


class View$OnClickListenerMethodAdapter extends MethodVisitor {

  private boolean weaved;

  View$OnClickListenerMethodAdapter(MethodVisitor methodVisitor) {
    super(Opcodes.ASM5, methodVisitor);
  }

  @Override public void visitCode() {
    super.visitCode();

    if (weaved) return;

    AnnotationVisitor annotationVisitor =
        mv.visitAnnotation("Lcom/smartdengg/clickdebounce/Debounced;", false);
    annotationVisitor.visitEnd();

    mv.visitVarInsn(ALOAD, 1);
    mv.visitMethodInsn(INVOKESTATIC, "com/smartdengg/clickdebounce/DebouncedPredictor",
        "shouldDoClick", "(Landroid/view/View;)Z", false);
    Label label = new Label();
    mv.visitJumpInsn(IFNE, label);
    mv.visitInsn(RETURN);
    mv.visitLabel(label);
  }

  @Override public AnnotationVisitor visitAnnotation(String desc, boolean visible) {

     /*Lcom/smartdengg/clickdebounce/Debounced;*/
    weaved = desc.equals("Lcom/smartdengg/clickdebounce/Debounced;");

    return super.visitAnnotation(desc, visible);
  }
}

複製程式碼

總結

以上內容就是我對“點選抖動”的看法,其實這個工具孵化於業務開發之中,現在我將它重新整理並決定**開源**,給那些有同樣困惑的人提供一種解決思路,希望能夠有所幫助。

隨著越來越多的人加入團隊,無論業務需求的開發還是技術深度的挖掘,都變得越來越重要,我們非常希望使用者能夠對我們的產品報以期望,高效並愉快的使用它們。不懈怠任何一處使用者體驗,理所應當成為每一位開發者的覺悟。

文章的最後,非常感謝您的閱讀,歡迎在文章下方提出您的寶貴建議。

相關文章