前几天在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()));