攔截控制元件點選 - 巧用ASM處理防抖!

語言: CN / TW / HK

 BATcoder技術 群,讓一部分人先進大廠

大家好,我是劉望舒,騰訊最具價值專家,著有三本業內知名暢銷書,連續五年蟬聯電子工業出版社年度優秀作者, 百度百科收錄的資深技術專家。

前華為面試官、獨角獸公司技術總監。

想要 加入  BATcoder技術群,公號回覆 BAT  即可。

作者:小鄧子,

地址:https://cloud.tencent.com/developer/article/1190508

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

相信我,一個專業的測試團隊會幫你節省很多時間。他們用嚴格的測試用例,來保證APP的質量,收集線上崩潰日誌和使用者反饋,然後將它們打包傳送給你,這在一定程度上提高了你解決問題的效率,因為你只需要關注問題本身,不需要投入額外的精力到資訊的收集上。

如果你是小型移動開發團隊成員,或者開源專案貢獻者,那麼這些收集資訊和跟進反饋的工作就成為了你責任的一部分,千萬不要忽略這些,因為一旦有人使用了你的APP,你就應該為之負責,不是嗎?

我最近收到了一封反饋郵件,我覺得這個“Exception”很有趣,同時也充滿了挑戰,並且我相信你也遇到過這種情況,因此我會在接下來的部分與大家分享,然後給出我的解決思路。

背景&現狀

我們的測試團隊向我反饋,在某些場景下用極快的速度雙擊APP中按鈕時會喚起兩個選單或者快速雙擊Feed流卡片後會開啟兩個詳情頁,雖然這種行為不會導致崩潰,但會讓我們的使用者感到十分的困惑,在這種情況下,第二次點選往往屬於誤觸碰或因手動導致,因此我們稱這種現象為 “抖動點選”

雖然在極少數情況下的確會有一些使用者通過這種方式來“虐待”你的軟體,而且在那些陳舊的響應很慢的裝置中更容易復現,客觀的講,我們不能向所有使用者普及:“嗨,請溫柔點,不要點那麼頻繁”。我們的團隊不希望使用者覺得我們的APP很脆弱,就像我前面提到的那樣,這是我們的責任,我們應該健壯我們的應用 : )

修改Activity啟動模式?

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

<activity android:name=".YourActivity"
android:launchMode="singleTop" >

...
</activity>

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

自定義 DebouncedViewClickListener

既然配置manifest的方式行不通,那我們就簡單粗暴些“為所有的點選事件都加上防抖”。

比如針對所有 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更通用,也更靈活,而AspectJ在實現這個功能上實在有些綽綽有餘。

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

先看一下我們原始程式碼:

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

示例程式碼很簡單,在點選回撥中開啟另一個 Activity

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

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

我們希望位元組碼被修改後,原有的邏輯被包含在一個防抖的if判斷中, DebouncedClickPredictor 類有一個重要的函式: shouldDoClick(View targetView) 用來判斷目標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;
}
}
}

然後在ASM程式碼中實現我們自己的 ClassVisitor ,並重寫 visitMethod 函式,我這裡處理了所有與 onClick(View v) 函式簽名相同的方法。

  @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);

......

}
}

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

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

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);
}
}

總結

這個工具是在業務開發過程中孵化出來的,目前還沒有公開的計劃,所以我只能分享一些我看待問題的方式和解決思路,如果你也有過相似的困惑,希望能夠對你有所幫助。

隨著越來越多的人加入團隊,無論業務需求的開發還是結束深度的挖掘,都變得越來越重要,我們非常希望使用者能夠對我們的產品報以期望,並且能夠便捷,高效的使用它。

你可以通過github來檢視這些示例程式碼。

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