Android端可视化埋点的实现
客户端埋点是数据收集的最基本手段,对于一款APP来说,代码埋点(就是在业务代码中,在需要埋点的view的点击事件回调处做点击上报的处理,当此view被点击时,进行相应事件的上报)是最为常见的埋点方式,但由于业务迭代速度很快,手动埋点方案虽然灵活多变,但是极大的增加了客户端开发人员的工作量。开发完成业务功能需要花费很大的精力处理埋点事宜,而且随着迭代版本,新的需求增加,埋点的数量会越来越多,这些老旧埋点的维护工作也需要付出不小的努力。并且,代码埋点需要跟版本迭代,如果在开发过程中忘记埋点,就只能等待下个版本再埋点。所以,如果能够实现不需要或者很少需要开发人员介入就能实现根据不同业务场景埋点的功能对于提高版本迭代速度和开发人员的幸福感绝对是一件非常有价值的事情。 所以,为了减少代码埋点的不便,不需要开发人员介入,使运营或者用研的同学就可以随时动态调整需要上报的点,我们需要改变下当前的埋点方式。
一、需求背景
1、什么是埋点
预先在目标应用采集数据,对特定用户行为或事件进行捕获、处理以一定方式上报至服务器的相关技术及其实施过程。
在大数据话的今天,一个好的产品就应该符合用户的需求,我们做统计就是收集用户的行为,分析用户的喜好,不断的根据用户行为改进我们的产品,当然专业的需要数据分析来完成,我们需要的就是为分析提供用户数据。
原来我们的方式是代码埋点,在每一个要上报的view的点击事件都都要由开发人员加入上报的代码。
typescript
public void onClick(View v) {
..
..
//在点击事件回调中走统一的上报
ClickReport.collect(ClickReport.MineAction.MINE_MORE_PRIVILEGE);
..
..
}
复制代码
2、存在的问题
但由于业务迭代速度很快,手动埋点方案虽然灵活多变,但是也存在很多问题:
1、开发完成业务功能需要花费很大的精力处理埋点事宜,而且多余的代码显得很冗余,浪费开发时间
2、随着迭代版本,新的需求增加,埋点的数量会越来越多,这些老旧埋点的维护工作也需要付出不小的努力。
3、代码埋点需要跟版本迭代,无法实现动态化的配置,如果在开发过程中忘记埋点,就只能等待下个版本再埋点。
所以,为了减少代码埋点的不便,减少开发人员介入,使运营或者用研的同学就可以随时动态化的调整需要上报的点,我们需要改变下当前的埋点方式,开发一个合适客户端埋点的上报模式。
二、可视化埋点
1、埋点的三种方式:
因为不同的app也会有自己的异化处理,埋点的方式也是根据特殊情况有这众多方案的,但是大体上现在主要流行的就是三种方案:
1、代码埋点:将收集数据的代码直接写在需要的地方,当用户点击某个控件或者打开某个页面时调用到该部分代码完成数据的收集。
- 优点:准确性高,收集数据和发送数据都能精确控制,同时能方便的设置自定义属性,自定义控件,自定义View等。
- 缺点:埋点工作量大,更新代价大,需要跟版本,不灵活。
2、可视化埋点:根据可视化界面进行配置然后上报后台,后台向终端下发配置文件,终端点击时获取当前点击的控件根据配置文件进行选择上报。
- 优点:数据量相对精确,覆盖范围大。
- 缺点:可视化平台搭建困难,控件树的元素的精确识别较为麻烦。
3、无埋点:与可视化埋点基本一致。不同点在于可视化埋点是根据配置文件收集数据,无埋点是预先收集所有的用户行为,然后根据配置文件来提取数据。
- 优点:数据覆盖全,开发人员工作量小。
- 缺点:数据量大,后端筛选分析工作量大,每个点都统计对性能有隐患。
2、为什么选择可视化的埋点
对于无埋点,和可视化埋点的方案很相似,区别在于无埋点需要将所以的点击都收集,后续在根据配置文件在分析想要的数据,这种方式对于客户端来说很省事,客户端也不用分析是哪个点,只要用户点击了就上报,但是对于后端的筛选和分析一定的影响,而且可能我们需要统计的点并不是很多时可能大多数上报的点都不是我们想要的,这时用无埋点的方式感觉有些得不偿失。
MTA也有一个可视化埋点的功能,但是咱们我们要上报的平台有很多,比如要上报到罗盘或者我们自己服务器的统计平台,仅仅使用MTA的sdk无法满足各种平台的上报,所以我们选择可视化埋点的方式统计用户的行为,并且在统一的模块进行各个平台的上报。
三、可视化埋点方案探讨与具体实现
1、方案介绍
主要的流程可以分为配置埋点模式,和用户模式。
上报的信息:
arduino
{
String ViewId; //当前View的唯一ID 由路径获得
String EventId; //View的事件ID 由产品相关人员配置
}
复制代码
在配置模式中,由产品同学或者运营同学配置需要上报的点击view,将点击view的唯一标识viewID和事先定好的用于分析的eventID绑定形成一个配置map表,配置结束后,将已经配置完成的map表上传至后台,完成配置模式。
[图片上传失败...(image-4aaf40-1649852259824)]
图1:可视化埋点流程
在配置模式中,由产品同学或者运营同学配置需要上报的点击view,将点击view的唯一标识viewID和事先定好的用于分析的eventID绑定形成一个配置map表,配置结束后,将已经配置完成的map表上传至后台,完成配置模式。
在用户模式下,就是当用户开启app时,会拉取到在产品或者运营同学在配置模式下配置的map表,然后当用户点击相应点时,可以通过某种方式与配置表中的点做对照,如果在配置表中,就走上报逻辑。
通过上述流程,可以总结出以下三个问题:
- 如何获取当前点击的view;
- 如何确定view的唯一标识viewID;
- 用户点击view的上报流程;
下面针对这三个问题进行分析和方案的确定。
2、获取当前点击的view
因为我门需要在一个统一的地方获取当前的点击事件,来做统一的配置或者筛选上报工作,这些工作不可能在每一个view的点击事件回调中完成,比较普遍的做法就是遍历当前activity下的viewtree,根据UI布局的特性和Android点击事件传递机制来找到当前的view。
主流的方案:通过位置遍历计算
让项目中主框架的BaseFragmentActivity基类重写Activity的dispatchTouchEvent方法,当touch button时,可以获取到按下(DOWN)和抬起(UP)时产生的MotionEvent对象。这个MotionEvent对象有两个方法,getRawX()和getRawY(),通过这两个方法我们可以获取到"点击位置"在界面中的坐标。通过rootview可以层层遍历其下的子view以及所有子View上的控件,这些View和控件在屏幕中的坐标和宽高我们是可以获取到的。然后搜索所有的子View或者控件的布局区域是否包含"点击位置",从而来判断哪个View或控件被点击。
csharp
/**
* 通过遍历的方式获取当前view
*/
private View searchClickView(View view, MotionEvent event,StringBuffer stringBuffer) {
..
..
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,stringBuffer);
if (clickView != null) return clickView;
}
}
}
..
..
}
复制代码
但是这种获取view的方式毕竟是靠遍历来获得的,难免对性能有一些影响,其实通过viewGroup的TouchTarget的类型可以有一种更好的方式去得到当前的view。
我们的方案:通过TouchTarget类型获取
ViewGroup中有一个TouchTarget 类型的变量 mFirstTouchTarget,表示消费当前触摸事件的控件列表。例如,点击屏幕上一个按钮,那么按钮所在ViewGroup的mFirstTouchTarget 变量就指向这个按钮。当ViewGroup派发触摸事件时,他会首先判断变量mFirstTouchTarget是否存在,如果变量存在,会循环遍历TouchTarget链表元素,找到能处理该事件的View并将MotionEvent 派发给该View。如果不存在TouchTarget,ViewGroup 会循环遍历所有child view,直到找到一个能处理该事件的View,并将该View作为first touch target 赋值给mFirstTouchTarget。 当用户触发Down事件时,会执行如下逻辑,寻找消费当前事件的TouchTarget。
ini
if (actionMasked == MotionEvent.ACTION_DOWN){
//如果是down事件,遍历child,找到TouchTarget
..
..
final View[] children = mChildren;
..
..
if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
// child 消费了触摸事件
..
..
// 根据消费了触摸事件的View创建TouchTarget
newTouchTarget = addTouchTarget(child, idBitsToAssign);
..
..
break;
}
}
复制代码
addTouchTarget就是获取子view的touchTarget,并将其加入到TouchTarget链的最顶部,因为是从获取到DOWN事件的view层层向上递归,所以TouchTarget链的尾端就是目标view。
ini
private TouchTarget addTouchTarget(View child, int pointerIdBits) {
TouchTarget target = TouchTarget.obtain(child, pointerIdBits);
target.next = mFirstTouchTarget;
mFirstTouchTarget = target;
return target;
}
复制代码
当触发Down事件并且找到TouchTarget,或者触发非Down事件时,执行如下处理逻辑。
ini
if (mFirstTouchTarget == null) {
// No touch targets so treat this as an ordinary view.
handled = dispatchTransformedTouchEvent(ev, canceled, null,TouchTarget.ALL_POINTER_IDS);
} else {
//Down事件发生时找到TouchTarget,或者非Down事件直接执行如下逻辑
// 将事件派发给TouchTarget表示的View
TouchTarget predecessor = null;
TouchTarget target = mFirstTouchTarget;
while (target != null) {
final TouchTarget next = target.next;
if (alreadyDispatchedToNewTouchTarget && target == newTouchTarget) {
handled = true;
} else {
final boolean cancelChild = resetCancelNextUpFlag(target.child)|| intercepted;
if (dispatchTransformedTouchEvent(ev, cancelChild,target.child,target.pointerIdBits)) {
//指定TouchTarget对应的View正确消费了事件
handled = true;
}
..
..
}
..
..
}
}
复制代码
因为根据view的点击事件可知,view点击时事件是从根节点开始向下进行传递,如果viewgroup存在TouchTarget,会从TouchTarget的成员变量中获取当前的处理事件的view或者viewgroup,如果该viewgroup存在TouchTarget,就继续向下查找,直到当前的viewgroup的TouchTarget为空时,就说明是此viewgroup消费了这个事件;或者直到传递到一个view而不是viewgroup时,此view就是当前用户点击的view。
因为mFirstTouchTarget是一个TouchTarget类型的私有成员变量,我们需要通过反射的方式去获取:
ini
/**
* 获取当前点击的view
*/
public final View getTouchTarget(final ViewGroup vg) {
..
..
while (currentTarget != null) {
Field fieldTouchTarget = ReflectHelper.getDeclaredField(ViewGroup.class, "mFirstTouchTarget");
Object touchTarget = ReflectHelper.getFieldValue(fieldTouchTarget, currentTarget);
if (touchTarget == null) {
break;
}
Field fieldChild = ReflectHelper.getDeclaredField(touchTarget.getClass(), "child");
View child = (View) ReflectHelper.getFieldValue(fieldChild, touchTarget);
if (child instanceof ViewGroup) {
preTarget = currentTarget;
currentTarget = child;
continue;
} else if (child instanceof View) {
currentTarget = child;
break;
}
}
..
..
return currentTarget;
}
复制代码
利用ViewGroup的这种事件处理机制,可以在activity的dispatchTouchEvent中添加处理逻辑,如果接收到down事件,就让其传递下去使mFirstTouchTarget被赋值,当接收到up事件时,做相应的获取当前view逻辑,通过rootview的mFirstTouchTarget获取其子view,如果子view的mFirstTouchTarget不为空,就通过这种TouchTarget的链式关系获得这次点击行为的最终view。
3、唯一的标识一个view
根据上述流程,用户模式下,当用户点击某个view时,需要一个唯一的viewID用来在配置表中进行查找,并且需要与其他的view做区分;在配置模式下也需要通过唯一标示的viewID作为key与作为value的eventID共同上报到配置表中,那么应该如何选取这个唯一的标示呢?
主流的方案:遍历viewTree生成路径
对于view来说,Android系统提供了一个ID,view.getId()即可获得一个int型的id用于区分View,但是这个ID因为以下两个原因却并不能满足我们的需要。
- 有相当一部分view是NO_ID,比如在布局文件中未指定id,或者直接在代码里面new出来view,view.getId()返回的全部都是NO_ID
- 这个ID是不稳定的,由于这个ID其实就是每次编译产生的R文件中的int常量,因此同一个按钮,两个版本编译出来的ID很可能是不一样的。
所以为了使viewID不受版本和手机的影响,通常的方式就是利用所属activity+viewtree的路径形式来构建viewID。
[图片上传失败...(image-23560b-1649852259824)]
图2:view层级示意图
通过遍历当前activity的viewTree的方式,得到当前view的父类、祖父类一直到根view,然后根据view的路径层级关系再来确定viewID。但是这又需要遍历,我们不应该每获得一个view都要进行遍历,这样也是比较繁琐的。
我们的方案:通过TouchTarget链获取
在第一个问题如果获取当前view时已经说过,可以通过viewGroup的TouchTarget链来获取当前的点击view,并且链上的层级关系就是从根view到父view再到子view的一个层级,所以在获取view的同时就可以顺便记录层级关系,来作为viewID。
可以在获取当前view的过程中,记录每一个节点TouchTarget的mFirstTouchTarget,这样就可以在得到点击view的同时,也得到相应的view的路径比如:DecorView-LinearLayout-FrameLayout-RelativeLayout-Button,再配置上view的class类名等信息,我们就可以同时确定目标控件的唯一标识,将viewID通过setTag进行设置,在需要的地方取出。所以在方案上我们还是用利用获取viewgroup的TouchTarget来得到当前点击的view,同时也可以根据这种链式关系来记录点击view的从父类到子view的路径,从而将此路径作为当前view的唯一标示viewID。
4、用户端的埋点上报流程
对于可视化埋点操作来说,在用户模式下,当用户开启app时进行配置表拉取的操作,然后当用户进行点击时,通过遍历当前activity下的viewtree获得点击的view,或者通过上述分析的查找viewtree根视图的TouchTarget链表的方式来获取当前的view,然后从配置表中查找出与点击view的viewID一致的数据,进行上报。
主流的方案:view代理监听的方式
还有一种比较常用可视化埋点方式就是view的代理监听的方式,ActivityLifecycleCallbacks,用来监听Activity生命周期,当activity被开启时,遍历当前activity下的所有view,如果view在配置表中,就设置当前view的setAccessibilityDelegate。
scala
@TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH)
public class VisualAnalysisManager extends View.AccessibilityDelegate{
..
..
view.setAccessibilityDelegate(this);
..
..
}
复制代码
当view产生了click或者long_click等点击事件时,分析View的源码在处理点击事件的回调时调用了 View.performClick 方法,内部调用了sendAccessibilityEvent,
会在响应原有的Listener方法后,发送消息给AccessibilityDelegate,然后在继承AccessibilityDelegate的类中重写sendAccessibilityEvent方法来上报自动埋点事件。
typescript
public boolean performClick() {
..
sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_CLICKED);
..
}
public void sendAccessibilityEvent(int eventType) {
if (mAccessibilityDelegate != null) {
mAccessibilityDelegate.sendAccessibilityEvent(this, eventType);
} else {
sendAccessibilityEventInternal(eventType);
}
}
复制代码
代理监听的方式就是在ActivityLifecycleCallbacks监听到activity开启时对viewtree进行遍历,如果遍历view的viewID在后台下发的配置表中,就设置setAccessibilityDelegate,这种方式比较常用,但也要求sdk版本大于14。
我们的方案:获取viewID与配置表对比
用户模式与配置模式都需要获取当前点击的view,所以除类用上述view的代理监听的方式进行上报,还可以与配置模式相同,用TouchTarget链表的方式来获取当前的view,再判断当前的点击view是否需要上报。本需求在用户模式下也是采用TouchTarget链表的方式获取当前view进行上报,与配置模式统一,方便开发也避免代理监听的方式在在sdk小于14无法使用的困扰。
在获取view中,需要获取当前的activity,以及我们可能需要在当前activity生命周期中去做一些设置以及初始化等操作,最简单的方式是通过ActivityLifecycleCallbacks来监听activity的生命周期。然后在回调中做相应的操作,可以实现代码解藕。
typescript
public class ActivityTrackUtils implements ActivityTracer.ActivityLifecycleCallbacks {
..
..
private ActivityTracer activityTracer;
public void start(Application app) {
activityTracer.install(app);
activityTracer.registerActivityLifecycleCallbacks(this);
}
@Override
public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
if (callback != null) {
callback.onActivityCreated(activity, savedInstanceState);
}
..
..
复制代码
四、实现中遇到的问题以及解决方式
1、兼容性问题上的优化
上面第四章也说明了,获取当前view是通过反射的方式获取viewGroup中的私有成员,这样就可能因为不同手机api版本不同,源码会有一些差异,可能在某个版本的api中就无法获得,虽然这样的概率很小,但是为了保证代码的稳定,提高鲁棒性,还是可以提供一个降级方案:
java
public final View getTouchTarget(final ViewGroup vg,final MotionEvent event) {
..
..
//方案A
targetView = mVisualAnalysisHelper.getTouchTarget(vg);
//方案B
//如果mFirstTouchTarget的方式查询不出来,走降级方案
if(targetView == null){
targetView = mVisualAnalysisHelper.getTouchTargetForPlanB(vg,event);
}
..
复制代码
可以将一个问题中的获取view的主流方法作为降级方案,通过遍历当前viewTree的形式,判断当前的坐标是否在view的范围之中,并循环遍历viewGroup中的子view,最终可以获取到当前的点击view。
2、特殊控件的处理
有些控件可能会提前截取点击事件单独做处理,这样事件无法下发,导致无法获取当前的view,TouchTarget为null,比如微云项目中有个大标题中的控件,在我查找了众多原因后发现其实就是它在onInterceptTouchEvent 方法中自己做了处理,阻止了事件的传递,对于这种特殊的问题,只能做一下单独的处理,在它处理之前,判断它的子view中是否有我们需要的view,如果需要就获取view做相应的处理。
还有一个问题就是dialog中的view是无法处理的,因为在Android中,dialog其实与activity都有自己的wondow,独立与activity的view层级,它与activity有各自的点击事件传递,所以要针对dialog的事件传递做单独的处理,在baseDialog的dispatchTouchEvent中做与activity相同的判断,根据模式的不同做不同的处理。
scss
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
..
..
/**
* 可视化埋点 在配置模式下就返回false,非配置模式下判断点是否在上报map中,有就上报
*/
VisualAnalysisManager visualAnalysisManager = VisualAnalysisManager.getInstance();
if(ev.getAction() == MotionEvent.ACTION_UP)
if (visualAnalysisManager.getIsInConfigVisual()) {
//配置模式
visualAnalysisManager.handleTouchEventInConfigMode(ev);
return false;
}else {
//用户模式
//截取up事件,让down事件传递下去,来获取当前点击的view
visualAnalysisManager.handleTouchEventInNormalMode(ev);
}
..
..
复制代码
五、待优化的viewID问题
viewID是根据view的层级来确定的,如果项目进行重构或者变更层级,相同view的viewID就会变化,导致很多view要重新配置,有一种约束ID的方案,就是单独生成一个view与viewID的对应表,但是这样在添加新view时又要做相应的对应,也会带来开发上的不便利,所以目前还是维持现有的生成viewID的方案,当遇到重构或者层级变动的问题时就只能将上报的点迁移并重新生成viewID再上报,这是一个待优化的方向,后续想到合适的方案时会将其优化。