Skip to content

MicroKibaco/autotrace_app_click

Repository files navigation

本文相关代码Github地址autotrace_app_click,有帮助的话Star一波吧。

[TOC]

一. SDK业务背景

  你在开发中是否遇到过这样的场景,当点击同一个dialog或者button的时候,如果暴击多次,该dialogbutton的被点击行为会被瞬间执行多次,这时候有小伙伴可能要想了,我可以做一个view时间戳呀,让它延迟生效。错误的点击方式
  可是你们有木有想过一个问题,这么做?是不是会绑定view?如果工程里面有10000个点击事件需要处理,那岂不是要改10000行代码,有没有一种优雅的方式能实现如下需求呢?

  • 1.1 可以设置全局控制点击行为的开关
  • 1.2 可以动态控制点击时间戳
  • 1.3butterknife,kotlin,lambada表达式,dialog,xml点击事件高度支持
  • 1.4 对不需要防重复点击的事件,可以通过注解进行额外逻辑处理
  • 1.5 可以借助沪江aop思想,也可以自己编写transform plugin实现
  • 1.6 将打点事件带到线上,线上分析用户行为

小朋友,你是否有很多问号?

本文介绍的内容会详细解释以上问题,并在最后给解答。稳住,别慌~

二. SDK全局点击控制原理

2.0.1 全局监控activity生命周期

  • 在app的onCreate()方法初始化SDK,然后在ActivityLifeCycleCallBack注册,这样就能监控activity所有生命周期方法了。

2.0.2 获取activity 对应的根视图rootview

  • 我们在该回调事件里面的对应生命周期方法如:onActivityPaused(Activity activity),通过 activity.findViewById(android.R.id.content)方法可以拿到整块区域所对应的rootview,其实就是framelayout

2.0.3 树形递归根视图rootview,织入埋点代码

  • 2.0.3.1 自定义OnClickListener派生类WrapperOnClickListener,实现OnClickListener接口
  • 2.0.3.2 逐层递归遍历rootview,判断当前view是否设置了mOnClickListener对象
    • 2.0.3.2.1 如果已经设置了mOnClickListener并且mOnClickListener不是我们自定义的WrapperOnClickListener类型,则通过WrapperOnClickListener代理当前view设置的mOnClickListener
  • 2.0.3.3WrapperOnClickListeneronClick方法里会先调用view的原有mOnClickListener处理逻辑
    • 调用埋点代码,实现"插入"埋点,达到自动埋点效果。

三. SDK埋点信息介绍

public interface ITrackClickEvent {
    /**
     * 控件的类型
     */
    String CANONICAL_NAME = "$element_type";
    /**
     * 控件的id,即android:id属性指定的值
     */
    String VIEW_ID = "$element_id";
    /**
     * 控件显示的文本信息
     */
    String ELEMENT_CONTENT = "$element_content";

    /**
     * 当前控件所属的 Activity 页面
     */
    String ACTIVITY_NAME = "$activity";

    /**
     * 点击空间行为事件名称
     */
    String APP_CLICK = "$AppClick";

    String APP_VERSION = "$app_version";

    String APP_NAME = "$app_name";

    String SCREEN_HEIGHT = "$screen_height";

    String SCREEN_WIDTH = "$screen_width";

    String ELEMENT_POSITION = "$element_position";

    String ELEMENT_ID = "$element_id";

    String ELEMENT_ELEMENT = "$element_element";

    String MODEL = "$model";

    String LIB_VERSION = "$lib_version";

    String OS = "$os";

    String OS_VERSION = "$os_version";

    String MANUFACTURER = "$manufacturer";
    String LIB = "$lib";

}

四. SDK风险点介绍

4.1 DataBinding绑定的函数的点击事件是无法采集的

  DataBinding框架给Button设置OnClickListener对象动作稍微晚于onActivityResumed 回调方法,DataBinding还没来得及给我们Button对象设置mOnClickListener对象,我们再遍历RootView的时,当前View不满足hasObClickListener的判断条件,因此没有去代理mOnClickListener对象,给出的解决方案是给DataBinding框架一点延迟事件处理设置mOnClickListener对象操作

 new Handler().postDelayed(new Runnable() {
                    @Override
                    public void run() {
                        delegateViewsOnClickListener(activity, activity.findViewById(android.R.content));
                    }
                },300);

4.2 mOnClickListener是无法采集MenuItem的点击事件

  我们通过android.R.content获取的RootView不包含Activity标题栏,其实也就是不包含MenuItem所对应的父容器的,自然当我们遍历RootView是无法获取MenuItem控件的,因此也无法代理mOnClickListener对象,间接导致MenuItem点击事件无法触发。我们可以借助DecorView来处理,那么什么是DecorView呢?

DecorView

官方解释是这样的:

The DecorView is the view that actually holds the window’s background drawable. Calling getWindow().setBackgroundDrawable() from your Activity changes the background of the window by changing the DecorView‘s background drawable. As mentioned before, this setup is very specific to the current implementation of Android and can change in a future version or even on another device.

  我的理解是: DecorView是整个Window最顶层View,他有且只有一个字孩子LinearLayout,其实就包括了通知栏,标题栏,内容显示栏,LinearLayout里面包括两个FrameLayout,第一个是标题栏显示的Title,第二个FrameLayout才是我们所说的android.R.content。所以针对我么上面提到的无法采集MenuItem点击事件的问题,我们只需要将activity.findViewById(android.R.content) 换成 activity.getWindow().getDecorView()就可以采集到MenuItem的点击事件了

       new Handler().postDelayed(new Runnable() {
                    @Override
                    public void run() {
                        delegateViewsOnClickListener(activity, activity.getWindow().getDecorView());
                    }
                },300);

            }

  但是上报的时候,我们发现却没有Button文本信息,这是为什么呢?element_content这个字段没问题呀,后面我查了一下,MenuView.ItemView的派生类是ActionMenuItemView,而非View,所以这里需要强转一下

 else if (view instanceof ActionMenuItemView) {
            text = ((ActionMenuItemView) view).getText().toString();
        } 
        

4.3 无法采集Button点击,在OnClickListener里动态创建一个Button,然后通过addView添加到页面上,这个动态添加的Button无法采其点击事件

ViewGroup rootView = findViewById(R.id.rootView);
        AppCompatButton button = new AppCompatButton(this);
        button.setText("动态创建的button");
        button.setOnClickListener(v -> {
            
        });
        rootView.addView(button);

当点击这个动态创建的 rootView ,当前方案是无法采集点击事件的。为啥会这样呢?这是因为我们在ActivityonResume之前去遍历整个rootView 并代理其mOnClickListener对象的,如果在onResume动态创建View当时肯定无法被遍历到的,后来我没没有再次遍历,所以它的mOnClickListener对就没有被代理过,因此点击控件是没有效果的,那么该怎么办呢?用OnGlobalLayoutListener来解决,什么是OnGlobalLayoutListener?官方解释是这样的:

Interface definition for a callback to be invoked when the global layout state or the visibility of views within the view tree changes.

我的理解是: 当一个View视图树发生改变的时候,我没给当前的View设置了OnGlobalLayoutListener监听器,就能回调 onGlobalLayout()方法,基于这个原理我们可以给我们的ActivityRootView注册一个这样的监听器,这样就能实时观察视图树布局的变化呢,我们重新遍历一次RootView,然后找到那些没有被代理过的OnGlobalLayoutListener对象View进行代理即可解决上面的问题

            private ViewTreeObserver.OnGlobalLayoutListener onGlobalLayoutListener;

            @Override
            public void onActivityCreated(@NonNull final Activity activity, @Nullable Bundle savedInstanceState) {
                onGlobalLayoutListener = new ViewTreeObserver.OnGlobalLayoutListener() {
                    @Override
                    public void onGlobalLayout() {
                        delegateViewsOnClickListener(activity,
                                SensorsDataHelper.getRootViewFromActivity(activity, false));
                    }
                };
            }

       

            @Override
            public void onActivityResumed(@NonNull final Activity activity) {

                SensorsDataHelper
                         .getRootViewFromActivity(activity, true)
                         .getViewTreeObserver()
                         .addOnGlobalLayoutListener(onGlobalLayoutListener);
            }
            
            @Override
            public void onActivityStopped(@NonNull Activity activity) {

                /*
                 * 移除顶层Activity的监听
                 */
                SensorsDataHelper.getRootViewFromActivity(activity, false)
                        .getViewTreeObserver()
                        .removeOnGlobalLayoutListener(onGlobalLayoutListener);

            }
            // -----------------省-------------------------
             }
        

五. SDK拓展采集能力

5.0.1 怎样获取TextView的显示文本?

((TextView) view).getText().toString();

5.0.2 怎样获取ImageView的显示文本信息?

view.getContentDescription().toString();

5.0.3 怎样采集CheckBox的点击事件?

5.0.3.1 自定义WrapperOnCheckedChangeListener并且 织入埋点代码
public class WrapperOnCheckedChangeListener implements CompoundButton.OnCheckedChangeListener {

    private CompoundButton.OnCheckedChangeListener source;

    public WrapperOnCheckedChangeListener(CompoundButton.OnCheckedChangeListener source) {
        this.source = source;
    }

    @Override
    public void onCheckedChanged(CompoundButton compoundButton, boolean b) {
        //调用原有的 OnClickListener
        try {
            if (source != null) {
                source.onCheckedChanged(compoundButton, b);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }

        //插入埋点代码
        SensorsDataPrivate.trackViewOnClick(compoundButton);
    }
}
5.0.3.2 获取 CheckBox 设置的 OnCheckedChangeListener
    /**
     * CheckBox  代理事件
     *
     * @param view compoundButton代理ui
     */
    public void compoundButtonItemClick(View view) {
        if (view instanceof CheckBox){
            final CompoundButton.OnCheckedChangeListener onCheckedChangeListener =
                    SensorsDataHelper.getOnCheckedChangeListener(view);
            if (onCheckedChangeListener != null &&
                    !(onCheckedChangeListener instanceof MkOnCheckedChangeListener)) {
                ((CompoundButton) view).setOnCheckedChangeListener(
                        new MkOnCheckedChangeListener(onCheckedChangeListener));
            }
        }

    }

5.0.4 怎样采集RadioGroup的点击事件?

  • 5.0.4.1 确定 RadioGroup 设置的Listener类型OnGroupClickListener,然后自定义MkOnGroupClickListener
public class MkOnGroupClickListener implements ExpandableListView.OnGroupClickListener {

    private ExpandableListView.OnGroupClickListener source;

    public MkOnGroupClickListener(ExpandableListView.OnGroupClickListener source) {
        this.source = source;
    }

    @Override
    public boolean onGroupClick(ExpandableListView expandableListView, View view, int groupPosition, long id) {
        SensorsDataManager.trackAdapterView(expandableListView, view, groupPosition, -1);
        if (source != null) {
            source.onGroupClick(expandableListView, view, groupPosition, id);
        }
        return false;
    }
}
  • 5.0.4.2 判断当前ViewRadioGroup
 if (view instanceof RadioGroup) {
            try {
                RadioGroup radioGroup = (RadioGroup) view;
                Activity activity = SensorsDataHelper.getActivityFromView(view);
                if (activity != null) {
                    int checkedRadioButtonId = radioGroup.getCheckedRadioButtonId();
                    RadioButton radioButton = activity.findViewById(checkedRadioButtonId);
                    if (radioButton != null) {
                        text = radioButton.getText().toString();
                    }
                }
            } catch (Exception e) {
                Log.getStackTraceString(e);
            }
        }
        
  • 5.0.4.3 如果反射已经设置了mOnClickListener,且mOnClickListener不为空,比去不是我们自定义的MkOnGroupClickListener
    • 通过MkOnGroupClickListener代理
     if (onGroupClickListener != null && !(onGroupClickListener instanceof MkOnGroupClickListener)) {

                   ((ExpandableListView) view).setOnGroupClickListener(new MkOnGroupClickListener(onGroupClickListener));

               }

5.0.5 怎样采集RattingBar的点击事件?

5.0.6 怎样采集 SeekBar 的点击事件?

    public void seekBarItemClick(View view) {
        final SeekBar.OnSeekBarChangeListener onSeekBarChangeListener =
                SensorsDataHelper.getOnSeekBarChangeListener(view);
        if (onSeekBarChangeListener != null &&
                !(onSeekBarChangeListener instanceof MkOnSeekBarChangeListener)) {
            ((SeekBar) view).setOnSeekBarChangeListener(
                    new MkOnSeekBarChangeListener(onSeekBarChangeListener));
        }
    }

5.0.7 怎样采集 Spinner 的点击事件?

5.0.8 怎样采集 ListView ,GridView 的点击事件?

  ListView ,GridViewAdapterView子类,设置代理逻辑判断都大同小异

  • 5.0.8.1 设置 ListView/ GridView的代理MkAdapterViewOnItemClick
    /**
     * 列表代理事件
     *
     * @param view gridView代理ui
     */
    public void gridViewItemClick(View view) {
        if (view instanceof ListView || view instanceof GridView){
            AdapterView.OnItemClickListener onItemClickListener = ((AdapterView) view).getOnItemClickListener();

            if (onItemClickListener != null && !(onItemClickListener instanceof MkAdapterViewOnItemClick)) {

                ((AdapterView) view).setOnItemClickListener(new MkAdapterViewOnItemClick(onItemClickListener));

            }
        }

    }
  • 5.0.8.1 自定义 MkAdapterViewOnItemClick源码如下:
public class MkAdapterViewOnItemClick implements AdapterView.OnItemClickListener {
    private AdapterView.OnItemClickListener source;

    public MkAdapterViewOnItemClick(AdapterView.OnItemClickListener source) {
        this.source = source;
    }

    @Override
    public void onItemClick(AdapterView<?> adapterView, View view, int position, long id) {
        if (source != null) {
            source.onItemClick(adapterView, view, position, id);
        }

        SensorsDataManager.trackAdapterView(adapterView, view, position);
    }
}

5.0.9 怎样采集 ExpandableListView的点击事件?

   ExpandableListViewAdapterView的子类,同时也是ListView的子类,ListView的点击事件分为GroupClickChildClick,它设置的监听器也有两种,这里我们就需要增加额外的方式去做处理啦,我们来看看 ExpandableListView的点击事件是如何处理的吧

    public static void trackAdapterView(AdapterView<?> adapterView, View view, int groupPosition, int childPosition) {
        try {
            final JSONObject jsonObject = new JSONObject();
            jsonObject.put(ITrackClickEvent.CANONICAL_NAME, adapterView.getClass().getCanonicalName());
            jsonObject.put(ITrackClickEvent.ELEMENT_ID, SensorsDataHelper.getViewId(adapterView));
            if (childPosition > -1) {
                jsonObject.put(ITrackClickEvent.ELEMENT_POSITION, String.format(Locale.CHINA, "%d:%d", groupPosition, childPosition));
            } else {
                jsonObject.put(ITrackClickEvent.ELEMENT_POSITION, String.format(Locale.CHINA, "%d", groupPosition));
            }
            StringBuilder stringBuilder = new StringBuilder();
            String viewText = SensorsDataHelper.traverseViewContent(stringBuilder, view);
            if (!TextUtils.isEmpty(viewText)) {
                jsonObject.put(ITrackClickEvent.ELEMENT_ELEMENT, viewText);
            }
            final Activity activity = SensorsDataHelper.getActivityFromView(adapterView);
            if (activity != null) {
                jsonObject.put(ITrackClickEvent.ACTIVITY_NAME, activity.getClass().getCanonicalName());
            }

            SensorsReporter.getSensorsDataApiInstance().track(ITrackClickEvent.APP_CLICK, jsonObject);
        } catch (Exception e) {
            Log.getStackTraceString(e);
        }
    }

   那么我们是如何获取ExpandableListView的呢?这里主要体现在 $element_position这个参数上,我们需要根据是group或者child做不同的逻辑处理

        /**
     * ExpandableListView代理事件
     *
     * @param view ExpandableListView代理view
     */
    public void expandableItemClick(View view) {
       if (view instanceof  ExpandableListView){
           try {

               final Class viewClazz = Class.forName("android.widget.ExpandableListView");

               // ---------------------------------------Child---------------------------------------
               Field mOnChildClickListenerField = viewClazz.getDeclaredField("mOnChildClickListener");

               if (!mOnChildClickListenerField.isAccessible()) {

                   mOnChildClickListenerField.setAccessible(true);

               }
               ExpandableListView.OnChildClickListener onChildClickListener =
                       (ExpandableListView.OnChildClickListener) mOnChildClickListenerField.get(view);

               if (onChildClickListener != null && !(onChildClickListener instanceof MkOnChildClickListener)) {

                   ((ExpandableListView) view).setOnChildClickListener(new MkOnChildClickListener(onChildClickListener));

               }

               // ---------------------------------------Group---------------------------------------
               Field mOnGroupClickListenerField = viewClazz.getDeclaredField("mOnGroupClickListener");

               if (!mOnGroupClickListenerField.isAccessible()) {
                   mOnGroupClickListenerField.setAccessible(true);
               }

               ExpandableListView.OnGroupClickListener onGroupClickListener =
                       (ExpandableListView.OnGroupClickListener) mOnGroupClickListenerField.get(view);
               if (onGroupClickListener != null && !(onGroupClickListener instanceof MkOnGroupClickListener)) {

                   ((ExpandableListView) view).setOnGroupClickListener(new MkOnGroupClickListener(onGroupClickListener));

               }

           } catch (Exception e) {
               Log.getStackTraceString(e);
           }
       }
    }

  先通过反射获取mOnGroupClickListener对象和mOnChildClickListener,如果listener不为空,并且不是我们需要的Listener类型,那么分别通过自定义的的MkOnGroupClickListenerMkOnChildClickListener区代理完成。

5.0.9.1 OnGroupClickListene源码如下:
/**
 * @author 杨正友(小木箱)于 2020/10/4 15 22 创建
 * @Email: [email protected]
 * @Tel: 18390833563
 * @function description:
 */
public class MkOnGroupClickListener implements ExpandableListView.OnGroupClickListener {

    private ExpandableListView.OnGroupClickListener source;

    public MkOnGroupClickListener(ExpandableListView.OnGroupClickListener source) {
        this.source = source;
    }

    @Override
    public boolean onGroupClick(ExpandableListView expandableListView, View view, int groupPosition, long id) {
        SensorsDataManager.trackAdapterView(expandableListView, view, groupPosition, -1);
        if (source != null) {
            source.onGroupClick(expandableListView, view, groupPosition, id);
        }
        return false;
    }
}

在其onGroupClick方法内部,我们首先调用埋点代码,然后调用原有的listenerOnGroupClickListener方法,这样即可实现插入埋点代码的效果

5.0.9.2 OnChildClickClickListener源码如下:
/**
 * @author 杨正友(小木箱)于 2020/10/4 15 21 创建
 * @Email: [email protected]
 * @Tel: 18390833563
 * @function description:
 */
public class MkOnChildClickListener implements ExpandableListView.OnChildClickListener {
    private ExpandableListView.OnChildClickListener source;

    public MkOnChildClickListener(ExpandableListView.OnChildClickListener source) {
        this.source = source;
    }

    @Override
    public boolean onChildClick(ExpandableListView expandableListView, View view, int groupPosition, int childPosition, long id) {

        SensorsDataManager.trackAdapterView(expandableListView, view, groupPosition, childPosition);

        if (source != null) {
            return source.onChildClick(expandableListView, view, groupPosition, childPosition, id);
        }

        return false;
    }
}

5.1.0 怎样采集 Dialog 的点击事件?

   目前这种全埋点的方案是无法采集activity上游离的view,如: Dialog ,因为无法遍历到被点击的view。对于这样的dialog,我们可以通过如下方式解决

    /**
     * Track Dialog 的点击
     * @param activity Activity
     * @param dialog Dialog
     */
    public void trackDialog(@NonNull final Activity activity, @NonNull final Dialog dialog) {
        if (dialog.getWindow() != null) {
            dialog.getWindow().getDecorView().getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
                @Override
                public void onGlobalLayout() {
                    SensorsDataManager.delegateViewsOnClickListener(activity, dialog.getWindow().getDecorView());
                }
            });
        }

    }

然后在Dialogshow之前调用即可

        AlertDialog alertDialog = new AlertDialog.Builder(this)
                .setNegativeButton("取消", (dialog, which) -> {})
                .setPositiveButton("确定", (dialog, which) -> {})
                .setTitle("小木箱").setMessage("一定要加油努力哦~").create();

        SensorsReporter.getSensorsDataApiInstance().trackDialog(this,alertDialog);

六. SDK缺陷

6.1 由于使用反射,效率比较低,对App的整体性能有一定影响,也可能会伴随着一些兼容问题

6.2 无法直接采集游离与activity上的view的点击,比如dialog,popuWindow

七. 参考资料

你的 点赞、评论、收藏、转发,是对我的巨大鼓励!

About

全局点击埋点代理OnClickListener SDK 编写

Resources

Stars

Watchers

Forks

Packages

No packages published

Languages