埋点实战 - 动态代理实现点击事件埋点

1. 埋点方式

  • 静态代理
  • 通过编译期“织入”代码,或者修改代码(通常为修改字节码.class)。比如 AspectJ(AOP)、ASM、JavaSsist等均为此方式
  • 动态代理
  • 运行时进行代理。例如代理:View.OnClickListener、Window.Callback、View.AccesbilityDelegate等均为此方式。虽然叫做动态代理,但不是真的Proxy代理,而是动态地进行代码增强。

静态代理在性能方面明显优于动态代理,静态代理在编译器织入代码,或者修改代码,对运行时的程序不会带来太大的性能影响,而动态代理在程序运行阶段发生,可能需要反射,对程序性能有一定的影响。

2. 动态代理埋点思路

一般思路有两种:

  • 代理 View.OnClickListener
  • 监听触摸事件
  • 监听辅助功能回调

2.1 代理 View.OnClickListener

我们不希望在原有代码上做修改,我们就需要做功能增强,功能增强除了可以通过代理设计模式,还可以通过装饰器模式。我们的任务:

  1. 拿到 Activity 当前所有的 View
  2. 判断 View 是否设置了 View.OnClickListener
  3. 反射将其替换为代理类(装饰类)进行功能增强

除了在 OnResume 生命周期来到的时候进行上述遍历替换行为,在 OnResume 后动态 addView 的时候也要进行上述替换行为。对于后来动态添加View的情况,我们可以采用 ViewTreeObserer.OnGlobalLayoutListener 来监听View树的动态变化回调,从而重新进行上述遍历替换行为。

缺点

  • 除了反射性能问题、遍历耗时问题,还有兼容性问题
  • Application.ActivityLifecycleCallbacks 要求 API 14+

也可以通过hook Instrumentation来做生命周期监听

  • View.hasOnClickListeners() 要求 API 15+
  • removeOnGlobalLayoutListener 要求 API 16+
  • 无法采集其他Window的点击事件,例如 Dialog、PopupWindow等

2.2 监听触摸事件

我们可以通过代理 Window.Callback 增强其 dispatchTouchEvent 方法,或者通过增加一个顶级透明ViewGroup,增强其 dispatchTouchEvent 方法,目的都是为了获取到用户点击的位置,然后通过遍历View,找到点击的View是哪个。

2.3 辅助功能点击回调

View的点击事件 performClick() 内部会调用 sendAccessibilityEvent(AccessbilityEvent.TYPE_VIEW_CLICKED),里面调用了 mAccessbilityDelegate 对象的 sendAccessibilityEvent 方法,并传入了 View 对象。我们可以通过装饰器模式来增强 AccessibilityDelegate 对象。这里就不需要反射了,View本身就提供了 getter setter 该对象的方法。

缺点

  • 兼容问题:API:29+
  • 只能在点击事件完成之后进行捕获。

优点

  • 更少的反射

3. 简单实现

Activity结构如下,Activity的布局为LinearLayout为父布局,里面只放了一个Button按钮:

public class MainActivity extends AppCompatActivity {
    Button btn;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        btn = findViewById(R.id.btn_click);
        btn.setOnClickListener(v->{
            Toast.makeText(this, "hello", Toast.LENGTH_SHORT).show();
            Log.e(TAG,"btn clicked");
        });
    }
}

Application 基础结构:

  1. 重写Application,在其中监听Activity的onResume生命周期
  2. 遍历所有View
  3. 点击事件埋点
public class MyApplication extends Application {
    private static final String TAG = "MyApplicationTAG";
    @Override
    public void onCreate() {
        super.onCreate();
        registerActivityLifecycleCallbacks(new ActivityLifecycleCallbacks() {
            //...
            @Override
            public void onActivityResumed(@NonNull Activity activity) {
                //发起点击事件监听(埋点)
                listenToClick(activity);
            }
        });
    }
    private void listenToClick(@NonNull Activity activity) {
        //1. 拿到window
        Window window = activity.getWindow();//PhoneWindow
        //2.拿到根View
        View decor = window.getDecorView();//根View(除了ViewRootImpl)
        //3.层序遍历 空间复杂度为最大宽度
        Queue<View> queue = new LinkedList<>();
        queue.add(decor);
        while(!queue.isEmpty()){
            int n = queue.size();
            for(int i = 0; i< n;i++){
                View poll = queue.poll();
                //埋点
                if (poll instanceof ViewGroup) {
                    for (int j = 0; j < ((ViewGroup) poll).getChildCount(); j++) {
                        View child = ((ViewGroup) poll).getChildAt(j);
                        queue.add(child);
                    }
                }
            }
        }
    }
}

搭建上述结构的时候,可能会有一些问题:

  1. 为什么不用反射获取 ViewGroup 中的 mChildren?
    答:Android对反射Field增加了限制,一般反射只能反射到ViewGroup的 public 对象。但对方法的反射则暂时没用这样的限制。

3.1 代理 OnClickListener

基础结构中的 【埋点】 部分,实现为 insertWrapperOnClickListener(View view)

//...
View poll = queue.poll();
//代理 OnCLickLisenter
insertWrapperOnClickListener(poll);
if(poll instanceof ViewGroup){...}
//...

首先需要实现一个装饰类WrapperOnClickListener来做功能增强:

//Wrapper包装类,功能增强
private static class WrapperOnClickListener implements View.OnClickListener {
	//装饰对象
    View.OnClickListener upStream;

    public WrapperOnClickListener(View.OnClickListener upStream) {
        this.upStream = upStream;
    }

    //功能增强
    @Override
    public void onClick(View v) {
        //pre
        Log.e(TAG, "before click");
        //打印view信息(可以用于存储埋点)
        Log.e(TAG,"view info :"+createViewInfo(v));
        //perform click
        upStream.onClick(v);
        //after
        Log.e(TAG, "after click");
    }

    //打印控件的唯一标识信息
    //parent1[index]#id/parent2[index]#id/view[index]#id
    private String createViewInfo(View v) {
        Deque<String> path = new LinkedList<>();
        View cur = v;
        StringBuilder info;
        while ((cur)!=null){
            info = new StringBuilder();
            info.append(cur.getClass().getSimpleName());
            info.append("[");
            if (cur.getParent()!=null && cur.getParent() instanceof ViewGroup){
                info.append(((ViewGroup) cur.getParent()).indexOfChild((View)cur));
            }else{
                info.append(0);
            }
            info.append("]");
            if (((View) cur).getId()!=-1) {
                //如果有id
                info.append("#");
                info.append(((View) cur).getId());
            }
            path.add(info.toString());
            if (!(cur.getParent() instanceof View))break;
            cur = (View)cur.getParent();
        }
        //拼接
        StringBuilder result = new StringBuilder();
        result.append("path: ");
        while (!path.isEmpty()){
            result.append(path.pollLast());
            if (!path.isEmpty()){
                result.append("/");
            }
        }
        return result.toString();
    }
}

有了功能增强的装饰类,接下来就是注入了,我们通过反射拿到 View的ListenerInfo的mOnClickListener对象,将其替换为我们的装饰类:

private void insertWrapperOnClickListener(View view) {
    if (view.hasOnClickListeners()) {
        //如果有主动设置点击监听器
        //反射拿到原来的mOnClickListener
        Class<?> v = View.class;
        try {
            //注意这里要用 getDeclaredMethod 而不是 getMethod
            Method getListenerInfo = v.getDeclaredMethod("getListenerInfo");
            getListenerInfo.setAccessible(true);
            Object li = getListenerInfo.invoke(view);
            //下列注释代码运行失败,虽然反射实例性能更快,但是android有反射限制,只能通过反射方法来获取 ListenerInfo 对象
            //                Field mListenerInfo = v.getDeclaredField("mListenerInfo");
            //mListenerInfo.setAccessible(true);
            //                Object li = mListenerInfo.get(v);
            if (li != null) {
                //拿到li中的mOnClickListener
                Class<?> liClass = li.getClass();
                //这是个public的域,是允许直接反射获取的
                Field clickListenerField = liClass.getDeclaredField("mOnClickListener");
                clickListenerField.setAccessible(true);
                View.OnClickListener o = (View.OnClickListener) clickListenerField.get(li);
                //之前已经判空过了
                //注入装饰类
                Log.e(TAG, "get one click listener : " + o);
                clickListenerField.set(li, new WrapperOnClickListener(o));
                o = (View.OnClickListener) clickListenerField.get(li);
                Log.e(TAG, "get one click listener : " + o);
            }

        } catch (IllegalAccessException | NoSuchFieldException | NoSuchMethodException | InvocationTargetException e) {
            e.printStackTrace();
            Log.e(TAG, "error : " + e);
        }
    }
}

运行结果:

2023-03-18 16:39:59.611 21646-21646/? E/MyApplicationTAG: get one click listener : MainActivity$$ExternalSyntheticLambda0@fea8936
2023-03-18 16:39:59.611 21646-21646/? E/MyApplicationTAG: get one click listener : MyApplication$WrapperOnClickListener@26f1037
//点击按钮之后:
2023-03-18 16:40:05.041 21646-21646/? E/MyApplicationTAG: before click
2023-03-18 16:40:05.041 21646-21646/? E/MyApplicationTAG: view info :path: DecorView[0]/LinearLayout[0]/FrameLayout[1]/ActionBarOverlayLayout[0]#2131230845/ContentFrameLayout[0]#16908290/LinearLayout[0]/MaterialButton[0]#2131230808
2023-03-18 16:40:05.055 21646-21646/? E/MyApplicationTAG: btn clicked
2023-03-18 16:40:05.055 21646-21646/? E/MyApplicationTAG: after click

可以看到代理成功。在替换 OnClickListener 前,点击监听器为匿名内部类,替换完成后,点击监听器变为了我们的装饰器类。点击按钮之后,除了执行按钮本身监听器内容之外,还执行了我们增强的功能。也就是埋点的功能。

3.2 辅助功能回调

基础结构中的 【埋点】 部分,实现为替换 AccessibilityDelegate:

private void insertWrapperAccessibilityDelegate(View view){
    //插入装饰类
    if (view.getAccessibilityDelegate()==null){
        view.setAccessibilityDelegate(new WrapperAccessibilityDelegate(null));
    }else{
        view.setAccessibilityDelegate(new WrapperAccessibilityDelegate(view.getAccessibilityDelegate()));
    }
}

//包装类
private static class WrapperAccessibilityDelegate extends View.AccessibilityDelegate{
    //包装对象
    View.AccessibilityDelegate upstream;

    public WrapperAccessibilityDelegate(View.AccessibilityDelegate upstrea) {
        this.upstream = upstrea;
    }

    //功能增强
    @Override
    public void sendAccessibilityEvent(View host, int eventType) {
        //pre 监听事件
        Log.e(TAG,"begin sendAccessibilityEvent");
        Log.e(TAG,"view info :"+createViewInfo(host));
        //调用装饰对象的方法
        if (upstream!=null)upstream.sendAccessibilityEvent(host,eventType);
        else super.sendAccessibilityEvent(host,eventType);
        //post 监听事件
        Log.e(TAG,"end sendAccessibilityEvent");
    }

    //打印控件的唯一标识信息
    //parent1[index]#id/parent2[index]#id/view[index]#id
    private String createViewInfo(View v) {
        Deque<String> path = new LinkedList<>();
        View cur = v;
        StringBuilder info;
        while ((cur)!=null){
            info = new StringBuilder();
            info.append(cur.getClass().getSimpleName());
            info.append("[");
            if (cur.getParent()!=null && cur.getParent() instanceof ViewGroup){
                info.append(((ViewGroup) cur.getParent()).indexOfChild((View)cur));
            }else{
                info.append(0);
            }
            info.append("]");
            if (((View) cur).getId()!=-1) {
                //如果有id
                info.append("#");
                info.append(((View) cur).getId());
            }
            path.add(info.toString());
            if (!(cur.getParent() instanceof View))break;
            cur = (View)cur.getParent();
        }
        //拼接
        StringBuilder result = new StringBuilder();
        result.append("path: ");
        while (!path.isEmpty()){
            result.append(path.pollLast());
            if (!path.isEmpty()){
                result.append("/");
            }
        }
        return result.toString();
    }
}

运行效果:

2023-03-18 16:42:32.510 21856-21856/? E/MyApplicationTAG: btn clicked
2023-03-18 16:42:32.510 21856-21856/? E/MyApplicationTAG: begin sendAccessibilityEvent
2023-03-18 16:42:32.511 21856-21856/? E/MyApplicationTAG: view info :path: DecorView[0]/LinearLayout[0]/FrameLayout[1]/ActionBarOverlayLayout[0]#2131230845/ContentFrameLayout[0]#16908290/LinearLayout[0]/MaterialButton[0]#2131230808
2023-03-18 16:42:32.511 21856-21856/? E/MyApplicationTAG: end sendAccessibilityEvent

可以看到代理成功。按钮执行完点击事件后,回调到了 mAccessibilityDeletage的sendAccessibilityEvent方法。

4. 相关源码

4.1 无法直接反射 Field 获取 mListenerInfo 变量

在View.java中,这个变量定义如下,这是一个 private 修饰的变量

//View.java
ListenerInfo mListenerInfo;

而 Android 的 Class.java 的反射方法被修改了,只能获取 public 修饰的变量:

//Class.java in Android
// Android-changed: Removed SecurityException.
public native Field getDeclaredField(String name) throws NoSuchFieldException;

但是可以通过反射 getListenerInfo() 方法来获得 mListenerInfo 变量

public Method getDeclaredMethod(String name, Class<?>... parameterTypes)
    throws NoSuchMethodException, SecurityException {
    return getMethod(name, parameterTypes, false);
}

可以直接通过反射 Field 来获取 mOnClickListener,因为它的访问权限是 public

//View.java
static class ListenerInfo {
    //未来可能会变成 private
    public OnClickListener mOnClickListener;
}

4.2 View的点击事件会回调到辅助功能

View如果消费了点击事件,最后回来到 performClick() 方法:

public boolean performClick() {
    notifyAutofillManagerOnClick();
    //如果点击监听器有设置的话,回调监听器
    final boolean result;
    final ListenerInfo li = mListenerInfo;
    if (li != null && li.mOnClickListener != null) {
        playSoundEffect(SoundEffectConstants.CLICK);
        li.mOnClickListener.onClick(this);
        //返回值表示是否被点击监听器处理
        result = true;
    } else {
        //没有点击监听器可以处理
        result = false;
    }
    //回调到辅助功能
    sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_CLICKED);
    notifyEnterOrExitForAutoFillIfNeeded(true);

    return result;
    
}

//如果有 mAccessibilityDelegate的话,会回调其 sendAccessibilityEvent 方法
public void sendAccessibilityEvent(int eventType) {
    if (mAccessibilityDelegate != null) {
        mAccessibilityDelegate.sendAccessibilityEvent(this, eventType);
    } else {
        sendAccessibilityEventInternal(eventType);
    }
}