原文:http://www.imwillsun.com/pages/2014/01/10/auto-monitor-on-Android.html
当我们开发一款Android应用上线后,希望能收集一些用户操作的行为数据,比如用户在某个页面点击了多少次,在某个控件被点击了多少次,在某个页面停留了多少时间等。这些数据收集起来可以交给数据分析师,他们可以统计出应用的PV或UV;或者统计应用中哪些页面最受欢迎,哪些控件点击率最低,从而来改进应用。对于控件被点击多少次,一般做法是在控件点击事件中加入几行log代码,然后将此次的点击记录下来,最终发送到服务端,页面的点击也是类似,需要在页面生命周期的开始加入log代码。这种插入log代码记录操作行为的方式定义为埋点。但麻烦的是,如果业务逻辑复杂,页面众多,控件众多,那就要在许多地方插入这些log代码。这是一件多么重复的事情呀!
那有没有可能自动化去埋点呢?就是将界面的打开、关闭以及控件点击的log记录放到统一的地方去处理,而不用在许多业务逻辑中加入log代码。这块统一的监控代码需要做到如下的事情:
1.可以监控到界面打开或者关闭,并将这种操作记录到log中
2.当界面上的有控件被点击的时候,可以监控到哪个界面哪个控件被点击了,并将这些操作信息记录到log中
3.要能实现埋点的定制,即对需要埋点的控件或者界面才记录它们的操作log
下面分析一下自动化埋点的思路。
自动化埋点的思路
首先对Android的Activity和UI做个基本的了解,然后再提供一些自动化埋点的思路。
1.Activity的生命周期
学习Android,Activity的生命周期是必修课。Activity的生命周期分为onCreate,onStart,onResume,onPause,onStop和onDestroy,一个界面的展示和消失都会要经过这几个阶段,所以如果监控了Activity的生命周期,就可以监控一个界面的打开、关闭以及用户在界面上停留的时间。实现这种监控方式,可以通过创造一个界面基类,让所有业务界面去继承它,然后基类中重写所有Activity的生命周期方法,见如下代码。至于BaseActivity重写方法里做什么样的拦截处理,这个会在下面说到。
public BaseActivity extends Activity{
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
//这里对Activity的生命周期进行拦截,其他的方法也是
}
}
public BusinessActivity extends Activity{
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState); //一定要调用super的方法
//这里写一些业务代码
...
}
}
2.Android的UI布局
Activity中的UI布局是层层嵌套的,很类似HTML的布局。在一个Activity中,“根”view是PhoneWindow$DecorView,可通过this.getWindow().getDecorView()
获取到这个对象。通过实例说明一下,在一个空白的Activity界面中添加一个Button按钮,下图是利用hierarchyviewer截到的UI布局图。
图中的DecorView类似于HTML中的<html>标签,里面嵌套的第一层Linearlayout类似于<body>标签。Linearlayout中有两个子view,都是FrameLayout布局,它们类似于<div>标签。第一个FrameLayout是应用的titlebar,TextView是titlebar上的文字;第二个FrameLayout中的内容就是Activity中的布局。可以看到最外层的采用RelativeLayout布局,里面有个Button按钮。
先做下简单了解,下面会说到它跟自动化埋点的关系。
3.Android事件传递机制
Activity中的UI布局是层层嵌套的,如果点击一个界面上的控件,点击事件的传递是由父视图向子视图传递,然后再传到具体的控件中,这个跟HTML中的点击事件冒泡一样。我们简单看一下Android的事件传递机制是怎么实现的。
在View中关于事件响应的方法有两个:
public boolean dispatchTouchEvent(MotionEvent event)
public boolean onTouchEvent(MotionEvent event)
而ViewGroup除了这个方法还有一个方法:
public boolean onInterceptTouchEvent(MotionEvent event)
这些方法的返回值都是布尔型,返回的true或false决定了一个事件的响应能不能向下传递。我们来看一下这几个方法的作用。
- dispatchTouchEvent方法用于事件的分发,在Activity界面中所有的点击事件都要经过这个方法的分发。返回true表示不分发事件,由当前的View来消费事件响应;返回false则继续往下分发,如果是ViewGroup则分发给onInterceptTouchEvent进行判断是否拦截该事件。
- onTouchEvent方法用于事件的处理,返回true表示消费处理当前事件,返回false则不处理,交给子控件进行继续分发。这个方法主要在普通控件中,比如Button。
- onInterceptTouchEvent是ViewGroup中才有的方法,View中没有,它的作用是负责事件的拦截,返回true的时候表示拦截当前事件,不继续往下分发,交给自身的onTouchEvent进行处理。返回false则不拦截,继续往下传。这是ViewGroup特有的方法,因为ViewGroup中可能还有子View,而在Android中View中是不能再包含子View的。
除了上述的事件,Android提供了一个OnTouchListener的监听器,当事件传递到控件的时候,如果控件注册了这个监听器,则会执行监听器中的onTouch方法。同时,如果它返回true,则事件也是不继续向下传递了。
public boolean onTouch(View v, MotionEvent event)
上述的事件传递可以通过举一个例子说明,假设一个界面上有一个Button按钮,当我们touch down这个Button的时候,DOWN事件的传递如下:
Activity->dispatchTouchEvent
Button->dispatchTouchEvent
Button->onTouch
Button->onTouchEvent
这里的每一步返回false,事件就不会向下传递。当我们touch up这个Button的时候,UP事件的传递如下:
Activity->dispatchTouchEvent
Button->dispatchTouchEvent
Button->onTouch
Button->onTouchEvent
Button->click
可以看到,一个Button的click事件要经过上面几个过程。如果要监听一个Button的click事件,有一种思路是我们可以创建一个基类BaseButton继承自Button,在回调OnClickListener的地方加入拦截代码。但是麻烦的是,点击控件不一定是Button,可能是其他TextView或者Layout之类的,Android中控件很多,我们要造很多控件基类,这样应用中充满的控件都必须是我们自己创建的控件,这样的设计是相当庞杂的。
那么我们考虑另外一种思路:让创建的BaseActivity基类重写Activity的dispatchTouchEvent方法,当touch button时,可以获取到按下(DOWN)和抬起(UP)时产生的MotionEvent对象。这个MotionEvent对象有两个方法,getRawX()和getRawY(),通过这两个方法我们可以获取到“点击位置”在界面中的坐标。同时,上文中提到,Activity的UI是层层嵌套的,通过“根”view可以层层遍历其下的子view以及所有子View上的控件,这些View和控件在屏幕中的坐标和宽高我们是可以获取到的。好了,这样就可以搜索所有的子View或者控件的布局区域是否包含“点击位置”,从而来判断哪个View或控件被点击。具体判断可以通过如下代码实现。
public boolean isInView(View view,MotionEvent event){
int clickX = event.getRawX();
int clickY = event.getRawY();
//如下的view表示Activity中的子View或者控件
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; //这个条件成立,则判断这个view被点击了
}
return false;
}
自动化埋点的实现
综上我们可以整理一下自动化埋点的思路。对于自动化埋点第一个功能,可以通过创建基类BaseActivity重写Activity的所有的生命周期。对于自动化埋点的第二个功能,实现方式是,通过重写Activity的dispatchTouchEvent方法,点击事件发生时,通过MotionEvent对象获取点击位置坐标,然后遍历Activity界面中所有的View(控件也都是View),判断哪个View区域包含点击位置,从而判断哪个View被点击了。另外有个问题,当拦截到这些操作信息,如何将它放到一个统一的地方去处理呢?可以采用广播的方式,将相关数据发送出去,然后在一个BroadcastReceiver中统一处理埋点的log生成。看如下代码:
public BaseActivity extends Activity{
//其他的Activity生命周期重写类似
protected void onStart() {
super.onStart();
LocalBroadcastManager broadcastManager = LocalBroadcastManager.getInstance(this);
Intent intent = new Intent(ACTIVITY_START);
intent.putExtra(ACTIVITY_START, event);
broadcastManager.sendBroadcast(intent);
}
protected boolean dispatchTouchEvent(MotionEvent ev) {
if (event.getAction() == MotionEvent.ACTION_UP) {
LocalBroadcastManager broadcastManager = LocalBroadcastManager.getInstance(this);
Intent intent = new Intent(VIEW_CLICK);
intent.putExtra(VIEW_CLICK, event);
broadcastManager.sendBroadcast(intent);
}
}
}
public class AutoMonitorReceiver extends BroadcastReceiver {
public void onReceive(Context context, Intent intent) {
String action = intent.getAction();
if(action == VIEW_CLICK){
MotionEvent event = intent.getParcelableExtra(VIEW_CLICK);
//1.递归遍历Activity(就是Context)中的所有View,找出被点击的View
View clickView = searchClickView(view, event);
//2.生成log记录下来
writeLog();
}else if(action == ACTIVITY_START){
//可以知道某个界面被打开了,然后记录此次操作行为
writeLog();
}
}
private View searchClickView(View view, MotionEvent event) {
View clickView = null;
if (isInView(view, event) &&
view.getVisibility() == View.VISIBLE) { //这里一定要判断View是可见的
if (view instanceof ViewGroup) { //遇到一些Layout之类的ViewGroup,继续遍历它下面的子View
ViewGroup group = (ViewGroup) view;
for (int i = group.getChildCount() - 1; i >= 0; i--) {
View chilView = group.getChildAt(i);
clickView = searchClickView(chilView, event);
if (clickView != null) {
return clickView;
}
}
}
clickView = view;
}
return clickView;
}
}
又一个问题,代码中的writeLog方法到底要记录哪些数据作为log信息呢?log信息中最重要的是能让开发者看出来哪个界面被打开或者哪个控件被点击。对于界面,可以记录其类名;对于控件,一般没有确定的名称,那么可以记录下来这个控件在界面中的路径。比如上文中介绍Android UI布局的实例,如果要定位记录那个Button,则可以记录它所在界面的类名和Button的布局路径作为它的标识。那个Button的路径可以表示为DecorView>LinearLayout[0]>FrameLayout[1]>RelativeLayout[0]>Button[0]
,由于LinearLayout有多个子View,因此可以在子View中加入编号来区分。这样就解决了log信息的记录问题,log信息的格式大致要有如下几个字段:
monitor_type | ui_name | view_ui_path
比如是控件点击埋点log,则可以记录为
VIEW_CLICK | MainActivity | DecorView>LinearLayout[0]>FrameLayout[1]>RelativeLayout[0]>Button[0]
比如是MainActivity界面打开的埋点log,则可以记录为
ACTIVITY_START | MainActivity | NULL
这样我们分析这些日志信息,就可以统计出一个应用中各个页面被打开过多少次,某个界面的控件被点击过多少次。
对于自动化埋点的第三个功能,实现埋点的定制,这个比较好实现。如果我们需要对那些界面或者控件进行埋点,我们可以定制一个埋点列表。这个列表在应用启动的时候被下载到用户的手机上,然后AutoMonitorReceiver需要多做一点事,就是将广播发送过来的埋点信息与埋点列表进行比对,看是不是需要埋的点,如果是,就将其记录;如果不是,就不做处理。假设我们用json存储这个埋点列表,大致的结构如下:
{
"version": "app_1.1.1",
"view_id":"MainActivity": {
"DecorView>LinearLayout[0]>FrameLayout[1]>RelativeLayout[0]>Button[0]":"button_0"
"DecorView>LinearLayout[0]>FrameLayout[1]>RelativeLayout[0]>Button[1]":"button_1"
}
}
当然这个埋点列表如何生成,这个需要开发者自己写代码去处理。
自动化埋点存在的问题和难点
本文提供的自动化埋点的思路是,通过拦截屏幕点击事件来搜索被点击的控件,然后将其界面类名和控件的UI路径记录下来作为log信息。将控件的UI路径和所在的界面类名作为一个控件的标识,有时候会出现一些问题,比如:
1.Android版本不同会造成控件的UI路径不同。比如Android2.2与Android4.1版本下,获取到上文中Button的UI路径分别为
//android2.2
DecorView>FrameLayout[1]>RelativeLayout[0]>Button[0]
//android4.1
DecorView>LinearLayout[0]>FrameLayout[1]>RelativeLayout[0]>Button[0]
这个问题可以通过将控件的UI路径缩短来解决,比如就只用FrameLayout[1]>RelativeLayout[0]>Button[0]
路径来标识Button控件。
2.对一些隐藏控件、弹出窗口或者浮动窗口不好处理。比如,在上文中的Button同样位置存在另外一个Button,不过是隐藏的,有时出现,有时不出现。当一个Button被点击,单纯依靠DecorView>FrameLayout[1]>RelativeLayout[0]>Button[0]
路径不能判断出是哪个Button被点击。解决这个问题,有时做一些特殊处理可以解决,比如扩展log的字段,多加入一些控件的信息,比如Button上的文字等。但是有时,开发应用的控件布局千变万化,一些控件确实不能通过UI路径进行唯一标识,这种就没法自动化埋点了。只能通过手动埋点来来补充了。
3.文中提到为了实现埋点的定制,需要开发者自己写代码生成一个埋点列表,这个也是比较麻烦的。要遍历一个应用中所有界面和界面中控件的UI路径,这个比较容易,但是取出自己想要埋点的控件UI路径,这个可能需要人工去查看比对。另外,一些大型应用开发的时候,界面随时发生着变化,一些控件的布局在随时发生变化。每次发布应用的时候,都需要扫描一下应用控件信息,以及重新找一下埋点控件的UI路径,这个是相当麻烦的。如何实现这部分的自动化,也是一个难题。
参考文章
InfoQ: Android事件传递机制