Android 全埋点解决方案

本文参考《Android全埋点解决方案》一书,并在实操中查漏补缺。

前言

为什么选择全埋点呢,因为传统的手动埋点虽然简单、扩展性强,但弊端也很明显:

  • 开发时间成本较高
  • 改动的时间成本也较高
  • 容易出现漏埋、埋错的情况
  • 代码侵入性强

但也不是说全埋点就一定没有弊端,比如扩展性较差。
经过调研,实际上都是以全埋点为主手动埋点为辅的情况,从而达到比较理想的埋点效果。

本文内容可能稍微有点长,但是很简单,别太长不看啊,最后有demo源码

页面

一般来说我们需要的数据就是,用户在哪个页面干了什么,也就是页面和事件,现在来说页面。

原理

通过生命周期可以计算出时长数据,以及页面对象。

Activity

页面有两个核心的需求数据:

  • 浏览时长
  • 页面唯一标示

这两个数据都挺好拿的,Application有一个registerActivityLifecycleCallbacks接口可以监测到activity的生命周期

有了生命周期,我们在onActivityResumed里面记录一下开始时间,然后在onActivityPaused中获取当前时间,就是整个页面的浏览时间

在生命周期方法中是有activity对象的,这样也可以拿到全路径作为唯一标示;

示例:

public static void registerActivityLifecycleCallbacks(Application application) {
        application.registerActivityLifecycleCallbacks(new Application.ActivityLifecycleCallbacks() {

            @Override
            public void onActivityCreated(Activity activity, Bundle bundle) {
            }

            @Override
            public void onActivityStarted(Activity activity) {
            }

            @Override
            public void onActivityResumed(Activity activity) {
                mBeginTime = System.currentTimeMillis();
            }

            @Override
            public void onActivityPaused(Activity activity) {
                trackAppViewScreen(activity);
            }

            @Override
            public void onActivityStopped(Activity activity) {
            }

            @Override
            public void onActivitySaveInstanceState(Activity activity, Bundle bundle) {
            }

            @Override
            public void onActivityDestroyed(Activity activity) {
            }
        });
    }

onActivityPaused的时候我们调用了一下trackAppViewScreen方法,并传入当前activity,来看看trackAppViewScreen方法。

private static void trackAppViewScreen(Activity activity) {
        try {
            if (activity == null) {
                return;
            }
            if (mIgnoredActivities.contains(activity.getClass().getCanonicalName())) {
                return;
            }

            JSONObject properties = new JSONObject();

            //获取页面的参数
            if (activity.toString().contains("SecondActivity")) {
                String userId = activity.getIntent().getStringExtra("userId");
                properties.put("userId", userId);
            }

            properties.put("activity", activity.getClass().getCanonicalName());
            SensorsDataAPI.getInstance().track("$AppViewScreen", properties, mBeginTime);

        } catch (Exception e) {
            e.printStackTrace();
        }
    }

我们在trackAppViewScreen方法中创建了JSONObject对象properties,用来添加我们需要埋点的数据,比如页面的唯一标示key我们用activity表示,并取全路径作为value

这里有一点需要注意的,我们除了可以收集一些固定参数之外,activity中intent的参数也是可以获取的,比如其他页面跳转到这个页面传的参数,我们同样可以获取到并作为埋点的参数使用的。

就像上面的SecondActivity,当MainActivity跳转到SecondActivity时传的userId是可以通过getIntent获取到的。

最后调用了SensorsDataAPI类的track方法,继续看

    public void track(@NonNull String eventName, @Nullable JSONObject properties, long beginTime) {
        try {
            JSONObject jsonObject = new JSONObject();
            jsonObject.put("event", eventName);
//            jsonObject.put("device_id", mDeviceId);

            JSONObject sendProperties = new JSONObject(mDeviceInfo);

            if (properties != null) {
                SensorsDataPrivate.mergeJSONObject(properties, sendProperties);
            }

            jsonObject.put("extras", sendProperties);
            jsonObject.put("beginTime", beginTime);
            jsonObject.put("endTime", System.currentTimeMillis());
            jsonObject.put("pageId", SensorsDataPrivate.getCurrentActivity().getClass().getCanonicalName());
            jsonObject.put("sessionId", UUID.randomUUID().toString().replace("-", ""));

            Log.i(TAG, SensorsDataPrivate.formatJson(jsonObject.toString()));

        } catch (Exception e) {
            e.printStackTrace();
        }
    }

这里也很简单,先后创建了两个JSONObject,一个是最外层的jsonObject ,一个是作为参数使用的sendProperties,然后又把传过来的参数合并到sendProperties中,然后sendProperties作为extrasvalue使用。

endTime结束时间就取当前时间。

sessionId表示是这个埋点的唯一标示,看自己需求,非必须。

最后调用了Log打印出来,来看一下最后完整的数据:

{
	"event": "$AppViewScreen",
	"extras": {
		"app_name": "TrackDemo",
		"screen_width": 1440,
		"screen_height": 2621,
		"app_version": "1.0",
		"os_version": "10",
		"model": "Android SDK built for x86",
		"manufacturer": "Google",
		"activity": "com.yechaoa.trackdemo.ui.MainActivity"
	},
	"beginTime": 1603279291751,
	"endTime": 1603279293759,
	"pageId": "com.yechaoa.trackdemo.ui.MainActivity",
	"sessionId": "5dbb96807e634b6498f897784972ade3"
}

可以看到除了我们必要的参数之外,还有一些附加参数,比如手机型号、系统版本等等。

Fragment

上面是Activity的埋点,关于fragment书中并没有讲解,不过我们也可以按照生命周期的方式来处理,比如在BaseFragment中进行统一埋点,又或者单独处理,正好演示一下手动埋点的操作。

示例:

    private var mBeginTime = 0L

    override fun onResume() {
        super.onResume()

        mBeginTime = System.currentTimeMillis()
    }

首先在onResume中记录一下开始时间。

    override fun onHiddenChanged(hidden: Boolean) {
        super.onHiddenChanged(hidden)

        val blankFragment = this

        if (hidden) {
            val activity = activity as SecondActivity
            val jsonObject = JSONObject()
            jsonObject.put("useActivity", true)
            jsonObject.put("fragment", activity.javaClass.canonicalName + blankFragment.javaClass.canonicalName + "-custom"
            )
            SensorsDataAPI.getInstance().track("AppViewScreen", jsonObject, mBeginTime)
        }
    }

然后在onHiddenChanged中判断显示与否进行埋点,自定义数据,然后调用track方法进行埋点。

唯一标示的key用fragment表示,value用当前引用的activity全路径,加上fragment的全路径,最后加上自定义的参数,即可作为唯一标示

以上即为页面埋点的主要代码,以及一些关键的代码细节,最后附Demo地址。

别忘了在Application中初始化埋点:

class App : Application() {

    override fun onCreate() {
        super.onCreate()

        //初始化埋点
        SensorsDataAPI.init(this)
    }
}

事件

一般来说就是点击事件,书中的解决方案挺多的,今天现在说说比较简单的,即代理模式

原理

拦截系统的点击事件,然后替换成我们自己的点击事件,然后在自己的点击事件中进行埋点操作。

通过获取页面的根布局,然后递归遍历出所有的view,并代理它们的click事件。

示例:

    public static void registerActivityLifecycleCallbacks(Application application) {
        application.registerActivityLifecycleCallbacks(new Application.ActivityLifecycleCallbacks() {

            private ViewTreeObserver.OnGlobalLayoutListener onGlobalLayoutListener;

            @Override
            public void onActivityCreated(final Activity activity, android.os.Bundle bundle) {
                final ViewGroup rootView = getRootViewFromActivity(activity, true);
                onGlobalLayoutListener = new ViewTreeObserver.OnGlobalLayoutListener() {
                    @Override
                    public void onGlobalLayout() {
                        delegateViewsOnClickListener(activity, rootView);
                    }
                };
            }

            @Override
            public void onActivityStarted(Activity activity) {
            }

            @Override
            public void onActivityResumed(Activity activity) {
                mBeginTime = System.currentTimeMillis();
                mCurrentActivity = activity;
                //trackAppViewScreen(activity);

                //添加视图树监听器
                final ViewGroup rootView = getRootViewFromActivity(activity, true);
                rootView.getViewTreeObserver().addOnGlobalLayoutListener(onGlobalLayoutListener);
            }

            @Override
            public void onActivityPaused(Activity activity) {
                trackAppViewScreen(activity);
            }

            @Override
            public void onActivityStopped(Activity activity) {
                //移除
                final ViewGroup rootView = getRootViewFromActivity(activity, true);
                rootView.getViewTreeObserver().removeOnGlobalLayoutListener(onGlobalLayoutListener);
            }

            @Override
            public void onActivitySaveInstanceState(Activity activity, android.os.Bundle bundle) {
            }

            @Override
            public void onActivityDestroyed(Activity activity) {
            }
        });
    }
  • onActivityCreated中初始化代理方法,
  • onActivityResumed中添加代理事件,
  • onActivityStopped中移除代理事件。

我们在看看这个代理事件是怎么代理的:

    protected static void delegateViewsOnClickListener(final Context context, final android.view.View view) {
        if (context == null || view == null) {
            return;
        }

        //获取当前 view 设置的 OnClickListener
         final android.view.View.OnClickListener listener = getOnClickListener(view);

         //判断已设置的 OnClickListener 类型,如果是自定义的 WrapperOnClickListener,说明已经被 hook 过,防止重复 hook
         if (listener != null && !(listener instanceof WrapperOnClickListener)) {
             //替换成自定义的 WrapperOnClickListener
             view.setOnClickListener(new WrapperOnClickListener(listener));
         } else if (view instanceof CompoundButton) {
             final CompoundButton.OnCheckedChangeListener onCheckedChangeListener = getOnCheckedChangeListener(view);
             if (onCheckedChangeListener != null &&
                     !(onCheckedChangeListener instanceof WrapperOnCheckedChangeListener)) {
                 ((CompoundButton) view).setOnCheckedChangeListener(
                         new WrapperOnCheckedChangeListener(onCheckedChangeListener));
             }
         } else if (view instanceof RadioGroup) {
             final RadioGroup.OnCheckedChangeListener radioOnCheckedChangeListener =
                     getRadioGroupOnCheckedChangeListener(view);
             if (radioOnCheckedChangeListener != null &&
                     !(radioOnCheckedChangeListener instanceof WrapperRadioGroupOnCheckedChangeListener)) {
                 ((RadioGroup) view).setOnCheckedChangeListener(
                         new WrapperRadioGroupOnCheckedChangeListener(radioOnCheckedChangeListener));
             }
         } else if (view instanceof RatingBar) {
             final RatingBar.OnRatingBarChangeListener onRatingBarChangeListener =
                     ((RatingBar) view).getOnRatingBarChangeListener();
             if (onRatingBarChangeListener != null &&
                     !(onRatingBarChangeListener instanceof WrapperOnRatingBarChangeListener)) {
                 ((RatingBar) view).setOnRatingBarChangeListener(
                         new WrapperOnRatingBarChangeListener(onRatingBarChangeListener));
             }
         } else if (view instanceof android.widget.SeekBar) {
             final android.widget.SeekBar.OnSeekBarChangeListener onSeekBarChangeListener =
                     getOnSeekBarChangeListener(view);
             if (onSeekBarChangeListener != null &&
                     !(onSeekBarChangeListener instanceof WrapperOnSeekBarChangeListener)) {
                 ((android.widget.SeekBar) view).setOnSeekBarChangeListener(
                         new WrapperOnSeekBarChangeListener(onSeekBarChangeListener));
             }
         }


        //如果 view 是 ViewGroup,需要递归遍历子 View 并 hook
        if (view instanceof ViewGroup) {
            final ViewGroup viewGroup = (ViewGroup) view;
            int childCount = viewGroup.getChildCount();
            if (childCount > 0) {
                for (int i = 0; i < childCount; i++) {
                    android.view.View childView = viewGroup.getChildAt(i);
                    //递归
                    delegateViewsOnClickListener(context, childView);
                }
            }
        }
    }

可以看到除了click之外还有check等事件,其实原理都是想通的,我们来挑一个click来看看。

先获取OnClickListener,怎么获取呢,看getOnClickListener方法:

    private static android.view.View.OnClickListener getOnClickListener(android.view.View view) {
        boolean hasOnClick = view.hasOnClickListeners();
        if (hasOnClick) {
            try {
                Class viewClazz = Class.forName("android.view.View");
                Method listenerInfoMethod = viewClazz.getDeclaredMethod("getListenerInfo");
                if (!listenerInfoMethod.isAccessible()) {
                    listenerInfoMethod.setAccessible(true);
                }
                Object listenerInfoObj = listenerInfoMethod.invoke(view);
                Class listenerInfoClazz = Class.forName("android.view.View$ListenerInfo");
                Field onClickListenerField = listenerInfoClazz.getDeclaredField("mOnClickListener");
                if (!onClickListenerField.isAccessible()) {
                    onClickListenerField.setAccessible(true);
                }
                return (android.view.View.OnClickListener) onClickListenerField.get(listenerInfoObj);
            } catch (ClassNotFoundException e) {
                e.printStackTrace();
            } catch (NoSuchMethodException e) {
                e.printStackTrace();
            } catch (InvocationTargetException e) {
                e.printStackTrace();
            } catch (IllegalAccessException e) {
                e.printStackTrace();
            } catch (NoSuchFieldException e) {
                e.printStackTrace();
            }
        }
        return null;
    }

通过反射拿到OnClickListener,然后再判断是否被代理,如果没有代理,就换成我们自己的Listener

 view.setOnClickListener(new WrapperOnClickListener(listener));

看一下我们自定义的WrapperOnClickListener

/*public*/ class WrapperOnClickListener implements android.view.View.OnClickListener {
    private android.view.View.OnClickListener source;

    WrapperOnClickListener(android.view.View.OnClickListener source) {
        this.source = source;
    }

    @Override
    public void onClick(android.view.View view) {
        //调用原有的 OnClickListener
        try {
            if (source != null) {
                source.onClick(view);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }

        //插入埋点代码
        SensorsDataPrivate.trackViewOnClick(view);
    }
}

很简单,也是实现系统的OnClickListener方法,然后在执行click的时候插入埋点代码

然后看一下trackViewOnClick方法:

    public static void trackViewOnClick(android.view.View view) {
        try {
            JSONObject jsonObject = new JSONObject();
            jsonObject.put("element_type", view.getClass().getCanonicalName());
            jsonObject.put("element_id", getViewId(view));
            jsonObject.put("element_content", getElementContent(view));

            Activity activity = getActivityFromView(view);
            if (activity != null) {
                jsonObject.put("activity", activity.getClass().getCanonicalName());
            }

            SensorsDataAPI.getInstance().trackClick("$AppClick", jsonObject);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

比较简单,但是有两个参数是需要注意的:

  • element_type 控件的类型,比如TextView、Button
  • element_id 控件的id,页面全路径 + 控件id即可表示唯一标示了

然后就是trackClick方法了

    public void trackClick(@androidx.annotation.NonNull String eventName, @androidx.annotation.Nullable JSONObject properties) {
        try {
            JSONObject jsonObject = new JSONObject();
            jsonObject.put("event", eventName);
//            jsonObject.put("device_id", mDeviceId);

            JSONObject sendProperties = new JSONObject(mDeviceInfo);

            String act = properties.get("activity").toString();
            //获取页面的参数
            if (act.contains("SecondActivity")) {
                SecondActivity activity = (SecondActivity) SensorsDataPrivate.getCurrentActivity();
                String userId = activity.getIntent().getStringExtra("userId");
                properties.put("userId", userId);
            }

            if (properties != null) {
                SensorsDataPrivate.mergeJSONObject(properties, sendProperties);
            }

            jsonObject.put("extras", sendProperties);
            jsonObject.put("eventTime", System.currentTimeMillis());
            jsonObject.put("sessionId", UUID.randomUUID().toString().replace("-", ""));

            Log.i(TAG, SensorsDataPrivate.formatJson(jsonObject.toString()));

        } catch (Exception e) {
            e.printStackTrace();
        }
    }

跟页面的埋点基本差不太多,来看看数据:

{
	"event": "$AppClick",
	"extras": {
		"app_name": "TrackDemo",
		"screen_width": 1440,
		"screen_height": 2621,
		"app_version": "1.0",
		"os_version": "10",
		"model": "Android SDK built for x86",
		"manufacturer": "Google",
		"element_type": "androidx.appcompat.widget.AppCompatButton",
		"element_id": "button",
		"element_content": "点击传值跳转",
		"activity": "com.yechaoa.trackdemo.ui.MainActivity"
	},
	"eventTime": 1603279293756,
	"sessionId": "b8d1aa32039a4fb1b2ece7772d60cd0e"
}

可以看到数据都是正常的,但是element_content字段并不能太过依赖,为什么呢,因为这个是获取的控件文本,不是所有的控件都有文本的,比如没有描述的ImageView、CheckBox等。

这些都是系统的控件,那如果是我们自定义View怎么办呢,正好演示一下事件的手动埋点。

        button2.setOnClickListener {
            val jsonObject = JSONObject()
            jsonObject.put("element_type", "androidx.constraintlayout.widget.ConstraintLayout")
            jsonObject.put("element_id", "自定义id")
            jsonObject.put("element_content", "自定义内容")
            jsonObject.put("id", 1234)
            jsonObject.put("activity", this.javaClass.canonicalName)
            SensorsDataAPI.getInstance().trackClick("AppClick", jsonObject)
        }

看一下数据:

 {
    	"event":"AppClick",
    	"extras":{
    		"app_name":"TrackDemo",
    		"screen_width":1440,
    		"screen_height":2621,
    		"app_version":"1.0",
    		"os_version":"10",
    		"model":"Android SDK built for x86",
    		"manufacturer":"Google",
    		"element_type":"androidx.constraintlayout.widget.ConstraintLayout",
    		"element_id":"自定义id",
    		"element_content":"自定义内容",
    		"id":1234,
    		"activity":"com.yechaoa.trackdemo.ui.SecondActivity",
    		"userId":"111"
    	},
    	"eventTime":1603283095128,
    	"sessionId":"addbc3d8335244328fcd352221a7a11d"
    }

加入自定义view监测不到的情况下,就可以用这种方式来手动埋点。

除了正常的单个控件的点击事件之外,还有列表的item click事件、还有RatingBar等等,限于篇幅就不细说了,原理都是相通的,具体可以查看Demo。


Demo:https://github.com/yechaoa/TrackDemo

  • 2
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

yechaoa

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值