我在鏈家網從事Android開發已經三年了,一直致力於優質APP的開發與探索,有時候會寫一些工具來提升效率,但更多時候是用技術幫助業務增加。咱們有專業的測試團隊,我嘗試與他們保持溝通,聽取他們的建議和反饋,並及時的作出修正。html
若是你是小型移動開發團隊成員,或開源項目貢獻者,你就應該收集這些反饋信息,並積極尋求解決方案,由於它們是你責任的一部分。java
我最近收到了一些反饋,是關於用戶體驗的,並且我也相信若是不作特殊處理,不少應用都會出現相似問題,所以我會在接下來與你們分享個人解決思路。本文提到的全部代碼均可以經過github下載。android
最近,咱們的測試團隊向我反饋,若是頻繁點擊列表頁的同一個卡片會同時打開兩個詳情頁面,甚至過於頻繁地提交表單也會彈出兩個對話框。雖然這不會致使應用的崩潰,但倒是一個使人頭痛的體驗問題,會讓使用它的用戶感到困惑。git
我抱着僥倖心理在常用的APP 中嘗試一樣的操做,想知道哪些應用會出現和咱們同樣的現象。github
在此以前,我須要鄭重申明,我沒有任何惡意詆譭的目的,若是侵犯了您的權益,請通知我。web
「知乎」和「網易雲音樂」是我平常使用頻率最高的兩款應用,不幸的是它們都會出現這種「抖動現象」。bash
咱們先來看知乎的「抖動」現象:app
很明顯我點擊了頭像,但同時打開了兩個主頁,我須要再點擊兩次back鍵才能回到以前的頁面。框架
再來看一下網易雲音樂的:ide
我甚至開始困惑這是究竟產品屬性,仍是由於「抖動」形成的錯誤現象 : (
不得不說的是,「點擊抖動」在必定程度上影響了用戶體驗,並且在極端狀況下必然引發程序的崩潰。那麼,接下來咱們就進入主題,一塊兒探索如何優雅的消除「點擊抖動」的存在。
針對全部打開Activity
的狀況,咱們能夠在AndroidManifest.xml
中修改啓動模式,避免打開重複的頁面:
<activity android:name=".YourActivity"
android:launchMode="singleTop" >
...
</activity>
複製代碼
但這種方法並不通用,咱們還有不少喚起菜單和對話框的操做,並且某些業務中的Activity
並不能設置singleTop
,所以咱們不能經過設置launchMode
的方式來避免「抖動」的產生。
既然配置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
}
});
複製代碼
這看起來很完美,咱們只須要多寫幾個代理類便可,以知足OnItemClickListener或DialogInterface$OnClickListener或其它回調接口。
真的解決了咱們全部疑惑嗎?答案是:NO !
首先,咱們的項目已經啓動好久了,而且有了穩定的線上版本,這就意味着咱們必須掃描代碼倉庫,並對全部相關代碼進行替換,這種方式明顯低效又愚蠢。
其次,咱們是一個團隊在開發,並非我一我的,所以我必須將這種寫法提交到咱們的編碼規範中,以強制團隊其餘人去遵照規範,而且在code review中也要格外地注意,很顯然在無形之中增長了人力成本。
最後,也是最重要的一點,它多多少少的侵入了業務,我認爲這種防抖策略應該像無埋點統計工做那樣,對於業務來說是透明的,也是無感知的。
綜合以上幾種狀況的考慮,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);
......
}
}
複製代碼
若是你以爲這些代碼太抽象,那麼咱們能夠經過一張圖來更好的理解它:
一句話總結:咱們攔截了處於凍結窗口內的點擊事件,讓它們沒法執行到咱們的業務邏輯。
以上就是咱們關於處理抖動的核心思路,看起來代碼量並很少,並且也不難理解,爲了方便使用,我決定將它作成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);
}
}
複製代碼
以上內容就是我對「點擊抖動」的見解,其實這個工具孵化於業務開發之中,如今我將它從新整理並決定**開源**,給那些有一樣困惑的人提供一種解決思路,但願可以有所幫助。
隨着愈來愈多的人加入團隊,不管業務需求的開發仍是技術深度的挖掘,都變得愈來愈重要,咱們很是但願用戶可以對咱們的產品報以指望,高效並愉快的使用它們。不懈怠任何一處用戶體驗,理所應當成爲每一位開發者的覺悟。
文章的最後,很是感謝您的閱讀,歡迎在文章下方提出您的寶貴建議。