概念

埋点,是对网站、App或者后台等应用程序进行数据采集的一种方法。通过埋点,可以收集用户在应用中的产生行为,进而用于分析和优化产品后续的体验,也可以为产品的运营提供数据支撑,其中常见的指标有PV、UV、页面时长和按钮的点击等,通常可以采集到下面这些数据。

  • 行为数据:时间、地点、人物、交互的内容等
  • 质量数据:App运行情况、浏览器加载情况、错误异常等
  • 环境数据:手机型号、操作系统版本、浏览器UA、地理、运营商、网络环境等
  • 运营数据:PV、UV、点击量、日活、留存、渠道来源等

采集行为数据时,通常需要在Web页面/App里面添加一些代码,当用户的行为达到某种条件时,就会向服务器上报用户的行为。其实添加这些代码的过程就可以叫做“埋点”,在很久以前就已经出现了这种技术。随着技术的发展和大家对数据采集要求的不断提高。

分析

以AspectJ为代表的“静态Hook”解决方案,那么,有没有其他办法可以不修改源代码,只是在App运行的时候去“动态Hook”点击行为的处理呢?答案是肯定的,JAVA里面有一个设计模式叫代理模式,从这个角度出发,看下怎么在运行时实现点击事件的监测上报。

android.view.View.java的源码(API>=14)中,有这么几个关键的方法:

// getListenerInfo方法:返回所有的监听器信息mListenerInfo
ListenerInfo getListenerInfo() {
    if (mListenerInfo != null) {
        return mListenerInfo;
    }
    mListenerInfo = new ListenerInfo();
    return mListenerInfo;
}
// 监听器信息
static class ListenerInfo {
    ... // 此处省略各种xxxListener
    /**
     * Listener used to dispatch click events.
     * This field should be made private, so it is hidden from the SDK.
     * {@hide}
     */
    public OnClickListener mOnClickListener;
    /**
     * Listener used to dispatch long click events.
     * This field should be made private, so it is hidden from the SDK.
     * {@hide}
     */
    protected OnLongClickListener mOnLongClickListener;
    ...
}
ListenerInfo mListenerInfo;
// 我们非常熟悉的方法,内部其实是把mListenerInfo的mOnClickListener设成了我们创建的OnclickListner对象
public void setOnClickListener(@Nullable OnClickListener l) {
    if (!isClickable()) {
        setClickable(true);
    }
    getListenerInfo().mOnClickListener = l;
}
/**
 * 判断这个View是否设置了点击监听器
 * Return whether this view has an attached OnClickListener.  Returns
 * true if there is a listener, false if there is none.
 */
public boolean hasOnClickListeners() {
    ListenerInfo li = mListenerInfo;
    return (li != null && li.mOnClickListener != null);
}

通过上面几个方法可以看到,点击监听器其实被保存在了mListenerInfo.mOnClickListener里面。那么实现Hook点击监听器时,只要将这个mOnClickListener替换成我们包装的点击监听器代理对象就可以实现点击监听的代理了。

实现

创建点击监听器的代理类

// 点击监听器的代理类,具有上报点击行为的功能
class OnClickListenerWrapper implements View.OnClickListener {
    // 原始的点击监听器对象
    private View.OnClickListener onClickListener;
    public OnClickListenerWrapper(View.OnClickListener onClickListener) {
        this.onClickListener = onClickListener;
    }
    @Override
    public void onClick(View view) {
        // 让原来的点击监听器正常工作
        if(onClickListener != null){
            onClickListener.onClick(view);
        }
        // 点击事件上报,可以获取被点击view的一些属性
        track(APP_CLICK_EVENT_NAME, getSomeProperties(view));
    }
}

反射获取一个View的mListenerInfo.mOnClickListener,替换成代理的点击监听器

// 对一个View的点击监听器进行hook
public void hookView(View view) {
    // 1. 反射调用View的getListenerInfo方法(API>=14),获得mListenerInfo对象
    Class viewClazz = Class.forName("android.view.View");
    Method getListenerInfoMethod = viewClazz.getDeclaredMethod("getListenerInfo");
    if (!getListenerInfoMethod.isAccessible()) {
        getListenerInfoMethod.setAccessible(true);
    }
    Object mListenerInfo = listenerInfoMethod.invoke(view);
    
    // 2. 然后从mListenerInfo中反射获取mOnClickListener对象
    Class listenerInfoClazz = Class.forName("android.view.View$ListenerInfo");
    Field onClickListenerField = listenerInfoClazz.getDeclaredField("mOnClickListener");
    if (!onClickListenerField.isAccessible()) {
        onClickListenerField.setAccessible(true);
    }
    View.OnClickListener mOnClickListener = (View.OnClickListener) onClickListenerField.get(mListenerInfo);
    
    // 3. 创建代理的点击监听器对象
    View.OnClickListener mOnClickListenerWrapper = new OnClickListenerWrapper(mOnClickListener);
    
    // 4. 把mListenerInfo的mOnClickListener设成新的onClickListenerWrapper
    onClickListenerField.set(mListenerInfo, mOnClickListenerWrapper);
    // 用这个似乎也可以:view.setOnClickListener(mOnClickListenerWrapper);     
}

注意,如果是API<14的话,mOnClickListener直接是直接以一个Field保存在View对象中的,没有ListenerInfo,因此反射的次数要更少一些。

 对App中所有的View进行动态Hook

我们在分析的是全埋点,那么怎样把App里面所有的View点击都Hook到呢?有两种方式:

  • 第一种:当Activity创建完成后,开始从Activity的DecorView开始自顶向下深度遍历ViewTree,遍历到一个View的时候,对它进行hookView操作。这种方式有点暴力,由于这里面遍历ViewTree的时候用到了大量反射,性能会有影响。
  • 第二种:比第一种方式稍微优秀一些,来源是一个Github上的开源库AndroidTracker(Kotlin实现)。他的处理方式是当Activity创建完成后,在DecorView中添加一个透明的View作为子View,在这个子View的onTouchEvent方法中,根据触摸坐标找到屏幕中包含了这个坐标的View,再对这些View尝试进行hookView操作。这种方式比较取巧,首先是拿到了手指按下的位置,根据这个位置来找需要被Hook的View,避免了在遍历ViewTree的同时对View进行反射。具体实现是在遍历ViewTree中的每个View时,判断这个View的坐标是否包含手指按下的坐标,以及View是否Visible,如果满足这两个条件,就把这个View保存到一个ArrayListhitViews。然后再遍历这个ArrayList里面的View,如果一个View#hasOnClickListeners返回true,那么才对他进行hookView操作。

动态Hook小结

整体来看,动态Hook的思路这里用到了反射,难免对程序性能产生影响,如果要采用这种方式实现全埋点方案,还需要好好评估。既然提到了代理,要说一下这里的“代理模式”其实还是JAVA的静态代理,不是动态代理。因为OnClickListenerOnClickListenerWrapper是在编写代码的时候就确定了,并不是在运行时动态生成了一个OnClickListenerWrapper。在JDK中动态代理是使用Native去生成了代理类的字节码(比如使用ASM等工具),并使用ClassLoader加载进来的。