Android自動化埋點的實踐
前幾天在app里加上了按鈕點選事件的自動埋點功能,這個功能的實現在面試中問過很多次,得到的答案都不盡如人意,歸根到底是沒有理解“自動”這個需求,自己也思考過一些方案,但是一直沒有一個比較靠譜的實現方式,直到看了這篇文章,才豁然開朗。
思路是基於Android的事件傳遞機制,當手指觸控到螢幕時,當前的activity就接收到了一個按下的事件,這個事件通常會被activity傳遞給自己的子view,否則使用者就點不了螢幕上的按鈕了。因此可以給應用中所有的activity提供一個基類,在基類中對手指按下事件做統一的統計,這樣就解決了在單個頁面上埋點的問題。
既然要統計view的點選事件,那麼首先要找到使用者點選的是哪個view。但是各個頁面的佈局結構千差萬別,怎麼去定位這個target呢?如果你瞭解activity頁面結構的話,這個問題就不是問題了。Activity有著跟html類似的佈局結構,都有一個根佈局,然後在根佈局上再新增各種各樣的view。在Activity中這個根就是DecorView,可以通過activity.getWindow().getDecorView()獲得這個物件。簡單來說,decorview裡面包含兩個子view,一個是titlelayout一個是contentlayout,一般來說titlelayout我們都直接隱藏的,因為會用到自己的titlebar,所以Activity裡顯示的內容就是contentlayout的內容,也就是setContentView()方法設定的layout,通過遍歷我們可以得到這個頁面上的所有view,然後通過view.getLocationOnScreen()可以得到這個view的大小和在螢幕上的位置,點選事件的位置可以通過event.getRawX()和event.getRawY()獲得,這樣我們就可以知道event是落在哪個view上了。
public boolean eventInView(View view, MotionEvent event) { if (view.getVisibility() == View.INVISIBLE || view.getVisibility() == View.GONE) { return false; } int clickX = (int) event.getRawX(); int clickY = (int) event.getRawY(); int[] location = new int[2]; view.getLocationOnScreen(location); int x = location[0]; int y = location[1]; int width = view.getWidth(); int height = view.getHeight(); if (clickX > x && clickX < (x + width) && clickY > y && clickY < (y + height)) { return true; } return false; }
然而,事情並沒有那麼簡單。頁面佈局通常會進行巢狀,一個button可能是巢狀在一個父layout裡,這樣使用上面的方法判斷下來的話,就有兩個view獲取到這個event了,實際情況可能是三個或者更多,這當然是個錯誤。怎麼解決呢?思考一下我們是怎麼來做佈局的吧,如果現在有一個relativelayout,裡面放了一個button,我們只給button註冊點選事件,那relativelayout就不應該被統計到,此時只有button有onclick事件,relativelayout是沒有的。如果反過來,點選relativelayout有事件,而點選button沒有的話,就是relativelayout有onclick事件而button沒有。因此,在得到event落在哪些view裡以後,需要進行一個判斷,哪個view有onclick事件,就認為哪個view實際被點選了。基於android的事件傳遞機制,我們應該從外到裡從上到下進行遍歷,只要外層view有點選事件,我們就可以結束遍歷了。
怎麼知道一個view是否有點選事件呢,android並沒有提供類似於view.getOnClickListener()的api,這個問題只能通過反射來解決,具體實現依sdk版本不同而不同。實現如下:
public View.OnClickListener getOnClickListener(View view) {
if (view == null) {
return null;
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
return getOnClickListenerV14(view);
} else {
return getOnClickListenerV(view);
}
}
//Used for APIs lower than ICS (API 14)
private View.OnClickListener getOnClickListenerV(View view) {
View.OnClickListener retrievedListener = null;
String viewStr = "android.view.View";
Field field;
try {
field = Class.forName(viewStr).getDeclaredField("mOnClickListener");
retrievedListener = (View.OnClickListener) field.get(view);
} catch (NoSuchFieldException ex) {
Log.e("Reflection", "No Such Field.");
} catch (IllegalAccessException ex) {
Log.e("Reflection", "Illegal Access.");
} catch (ClassNotFoundException ex) {
Log.e("Reflection", "Class Not Found.");
}
return retrievedListener;
}
//Used for new ListenerInfo class structure used beginning with API 14 (ICS)
private View.OnClickListener getOnClickListenerV14(View view) {
View.OnClickListener retrievedListener = null;
String viewStr = "android.view.View";
String lInfoStr = "android.view.View$ListenerInfo";
try {
Field listenerField = Class.forName(viewStr).getDeclaredField("mListenerInfo");
Object listenerInfo = null;
if (listenerField != null) {
listenerField.setAccessible(true);
listenerInfo = listenerField.get(view);
}
Field clickListenerField = Class.forName(lInfoStr).getDeclaredField("mOnClickListener");
if (clickListenerField != null && listenerInfo != null) {
retrievedListener = (View.OnClickListener) clickListenerField.get(listenerInfo);
}
} catch (NoSuchFieldException ex) {
Log.e("Reflection", "No Such Field.");
} catch (IllegalAccessException ex) {
Log.e("Reflection", "Illegal Access.");
} catch (ClassNotFoundException ex) {
Log.e("Reflection", "Class Not Found.");
}
return retrievedListener;
}
好了,思路基本上就是這樣,最後一個問題,我們應該記些啥內容?既然是自動埋點,肯定只能記一些通用的資訊,有特殊需求的還是要手動去加。因此,我只記錄了當前頁面的類名和當前view的id:
Log.d(TAG,this.getClass().getSimpleName() + "-" + getResources().getResourceEntryName(view.getId()));