我在鏈家網從事Android開發已經三年了,一直致力於優質APP的開發與探索,有時候會寫一些工具來提高效率,但更多時候是用技術幫助業務增長。我們有專業的測試團隊,我嘗試與他們保持溝通,聽取他們的建議和反饋,並及時的做出修正。
如果你是小型移動開發團隊成員,或開源專案貢獻者,你就應該收集這些反饋資訊,並積極尋求解決方案,因為它們是你責任的一部分。
我最近收到了一些反饋,是關於使用者體驗的,而且我也相信如果不做特殊處理,很多應用都會出現類似問題,因此我會在接下來與大家分享我的解決思路。本文提到的所有程式碼都可以通過github下載。
背景&現狀
最近,我們的測試團隊向我反饋,如果頻繁點選列表頁的同一個卡片會同時開啟兩個詳情頁面,甚至過於頻繁地提交表單也會彈出兩個對話方塊。雖然這不會導致應用的崩潰,但卻是一個令人頭痛的體驗問題,會讓使用它的使用者感到困惑。
我抱著僥倖心理在經常使用的APP 中嘗試同樣的操作,想知道哪些應用會出現和我們一樣的現象。
在此之前,我需要鄭重申明,我沒有任何惡意詆譭的目的,如果侵犯了您的權益,請通知我。
“知乎”和“網易雲音樂”是我日常使用頻率最高的兩款應用,不幸的是它們都會出現這種“抖動現象”。
我們先來看知乎的“抖動”現象:
很明顯我點選了頭像,但同時開啟了兩個主頁,我需要再點選兩次back鍵才能回到之前的頁面。
再來看一下網易雲音樂的:
我甚至開始困惑這是究竟產品屬性,還是因為“抖動”造成的錯誤現象 : (
不得不說的是,“點選抖動”在一定程度上影響了使用者體驗,而且在極端情況下必然引起程式的崩潰。那麼,接下來我們就進入主題,一起探索如何優雅的消除“點選抖動”的存在。
修改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
}
});
複製程式碼
這看起來很完美,我們只需要多寫幾個代理類即可,以滿足OnItemClickListener或DialogInterface$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);
......
}
}
複製程式碼
如果你覺得這些程式碼太抽象,那麼我們可以通過一張圖來更好的理解它:
一句話總結:我們攔截了處於凍結視窗內的點選事件,讓它們無法執行到我們的業務邏輯。
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);
}
}
複製程式碼
總結
以上內容就是我對“點選抖動”的看法,其實這個工具孵化於業務開發之中,現在我將它重新整理並決定**開源**,給那些有同樣困惑的人提供一種解決思路,希望能夠有所幫助。
隨著越來越多的人加入團隊,無論業務需求的開發還是技術深度的挖掘,都變得越來越重要,我們非常希望使用者能夠對我們的產品報以期望,高效並愉快的使用它們。不懈怠任何一處使用者體驗,理所應當成為每一位開發者的覺悟。
文章的最後,非常感謝您的閱讀,歡迎在文章下方提出您的寶貴建議。