客户端埋点是数据收集的最基本手段,但由于业务迭代速度很快,手动埋点方案虽然灵活多变,但是极大的增加了客户端开发人员的工作量。开发完成业务功能需要花费很大的精力处理埋点事宜,而且随着迭代版本,埋点的数量会越来越多,这些老旧埋点的维护工作也需要付出不小的努力。并且,手动埋点的正确性同样是个极度考验开发人员的耐性和认真程度的问题,在所难免会出现这样那样的问题。所以,如果能够研发出一款不需要或者很少需要开发人员介入就能实现根据不同业务场景埋点的功能sdk对于提高版本迭代速度和开发人员的幸福感绝对是一件非常有价值的事情。
更大的价值还在于,不需要开发人员介入,运营或者用研的同学就可以随时动态调整数据收集方案。
纵观目前比较成熟的无埋点方案,存在着如下问题:
问题1:通过XPath定位控件,理论上可行,但实践表明这个方案的复杂度非常高,尤其对于处理像GridView,ListView,RecyclerView的控件更是捉襟见肘。不仅如此,生成xpath的过程本身就是一个及其耗费性能的行为,它需要遍历view tree,存储非常多的路径信息到view上。
问题2:获取控件对应的数据是通过 data path的方式解决,每次添加新埋点时,如果需要上报数据,那用研人员需要和开发人员逐一确认控件数据的path,这极大的限制了客户端开发的自由度,即使简单的重构也会使得之前配置的埋点信息失效。
针对如上问题,我们经过深挖内在逻辑关系及对比优劣,总结出了一套更灵活,更合理的无埋点方案,下面分三个部分逐一介绍实现考量及内部机制。
一、定位与用户产生交互行为的目标控件
关于定位交互控件,我们也考虑过xpath的方案,但是考虑到其实现的复杂度,不灵活和各种潜在的问题,我们抛弃了这种方案。通过反复的阅读View的touch事件处理相关的源码,我们终于发现了解决问题的更好的方式。
ViewGroup中有一个TouchTarget 类型的变量 mFirstTouchTarget,表示消费当前触摸事件的控件列表。例如,点击屏幕上一个按钮,那么按钮所在ViewGroup的mFirstTouchTarget 变量就指向这个按钮。当ViewGroup派发触摸事件时,他会首先判断变量mFirstTouchTarget是否存在,如果变量存在,会循环遍历TouchTarget链表元素,找到能处理该事件的View并将MotionEvent 派发给该View。如果不存在TouchTarget,ViewGroup 会循环遍历所有child view,直到找到一个能处理该事件的View,并将该View作为first touch target 赋值给mFirstTouchTarget。
当用户触发Down事件时,会执行如下逻辑,寻找消费当前事件的TouchTarget。
if (actionMasked == MotionEvent.ACTION_DOWN){
//如果是down事件,遍历child,找到TouchTarget
..
..
final View[] children = mChildren;
for (int i = childrenCount - 1; i >= 0; i--) {
final int childIndex = getAndVerifyPreorderedIndexchildrenCount, i, customOrder);
final View child = getAndVerifyPreorderedView(preorderedList, children, childIndex);
..
..
if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
// child 消费了触摸事件
..
..
// 根据消费了触摸事件的View创建TouchTarget
newTouchTarget = addTouchTarget(child, idBitsToAssign);
..
..
break;
}
}
当触发Down事件并且找到TouchTarget,或者触发非Down事件时,执行如下处理逻辑。
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;
}
..
..
}
..
..
}
}
提示:由于消费触摸事件的控件可能为多个(splitting touch events),所以需要遍历TouchTarget链表。引用官方原文:
This behavior is enabled by default for applications that target an SDK version of 11 (Honeycomb) or newer. On earlier platform versions this feature was not supported and this method is a no-op.
MotionEvents may be split and dispatched to different child views depending on where each pointer initially went down. This allows for user interactions such as scrolling two panes of content independently, chording of buttons, and performing independent gestures on different pieces of content.
利用ViewGroup的这种事件处理机制,我们通过在Activity的window上调用window.setCallback() 接管窗口的事件派发,并在dispatchTouchEvent处理函数中添加analyzeMotionEvent()方法。如果接收到up事件,执行处理逻辑,通过ViewGroup TouchTarget链表,找到本次交互行为的目标控件。拿到控件后,通过 Activity的类名+控件所在的layout文件名+控件id对应的资源名,我们就可以确定目标控件的唯一标识。
dispatchTouchEvent源码如下:
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
if (!AutoPointer.isAutoPointEnable()) {
return super.dispatchTouchEvent(ev);
}
int actionMasked = ev.getActionMasked();
if (actionMasked != MotionEvent.ACTION_UP) {
return super.dispatchTouchEvent(ev);
}
long t = System.currentTimeMillis();
analyzeMotionEvent();
//非线上版本,打印执行时间
if (!AutoPointer.isOnlineEnv()) {
long time = System.currentTimeMillis() - t;
DDLogger.d(TAG, String.format(Locale.CHINA, "处理时间:%d 毫秒", time));
}
return super.dispatchTouchEvent(ev);
}
analyzeMotionEvent源码如下:
/**
* 分析用户的点击行为
*/
private void analyzeMotionEvent() {
if (mViewRef == null || mViewRef.get() == null) {
DDLogger.e(TAG, "window is null");
return;
}
ViewGroup decorView = (ViewGroup) mViewRef.get();
int content_id = android.R.id.content;
ViewGroup content = (ViewGroup) decorView.findViewById(content_id);
if (content == null) {
content = decorView; //对于非Activity DecorView 的情况处理
}
Pair<View, Object> targets = findActionTargets(content);
if (targets == null) {
DDLogger.e(TAG, "has no action targets!!!");
return;
}
//发送任务在单线程池中
int hashcode = targets.first.hashCode();
if (mIgnoreViews.contains(hashcode)) return;
PointerExecutor.getHandler().post(PointPostAction.create(targets.first, targets.second));
}
二、获取与目标控件对应的业务数据
对于获取控件数据,为了最大化获取速度,我们在系统中配置了多个数据获取策略。如果目标控件是AbsListView或者RecyclerView 的child view及child view 的chid,那我们可以通过child view在adapter中的位置获取到我们想要的数据。这种方式能够处理大多数页面控件数据的获取问题。系统配置策略的方式如下:
private static Map<String, DataStrategy> mStrategies = new HashMap<>();
static {
//configure RecyclerView and subclass's search strategy
DataStrategy recyclerViewStrategy = new RecyclerViewStrategy();
mStrategies.put("RecyclerView", recyclerViewStrategy);
mStrategies.put("DDCollectionView", recyclerViewStrategy);
//ExpandableListView
DataStrategy EListViewStrategy = new ExpandableListViewStrategy();
mStrategies.put("ExpandableListView", EListViewStrategy);
mStrategies.put("DDExpandableListView", EListViewStrategy);
DataStrategy adapterViewStrategy = new AdapterViewStrategy();
//ListView
mStrategies.put("ListView", adapterViewStrategy);
mStrategies.put("DDListView", adapterViewStrategy);
mStrategies.put("ListViewCompat", adapterViewStrategy);
//GridView
mStrategies.put("GridView", adapterViewStrategy);
mStrategies.put("DDGridView", adapterViewStrategy);
//ViewPager
DataStrategy viewPagerStrategy = new ViewPagerStrategy();
mStrategies.put("ViewPager", viewPagerStrategy);
//TabLayout
DataStrategy tabLayoutStrategy = new TabLayoutStrategy();
mStrategies.put("TabLayout", tabLayoutStrategy);
}
对于那些完全自定义布局绘制的页面,例如个人中心等页面,业务开发人员需要通过框架api建立一个控件树到数据的映射关系,这样框架在需要获取数据时,通过这个关系就可以非常容易的获取到想要的数据。
/**
* 配制自定义布局的数据绑定关系,自定义布局内的任何
* 控件发生点击行为时,发送的埋点都会携带改数据
*
* @param id
* @param object
* @return
*/
@NonNull
@Override
public DataConfigureImp configLayoutData(@IdRes int id, @NonNull Object object) {
Preconditions.checkNotNull(object);
mDataLayout.put(id, object);
return this;
}
根据TouchTarget找到数据获取策略或者数据映射关系,我们可以非常简单的获取到绑定的数据,获取数据的算法如下:
if (strategyView != null) {
Object data = strategy.fetchTargetData(strategyView);
return Pair.create(touchTarget, data);
}
if (configDataView != null) {
return Pair.create(touchTarget, mDataLayout.get(configId));
}
//解决自定义布局的数据绑定问题
if (dataAdapter != null) {
return Pair.create(touchTarget, dataAdapter.getData());
}
三、实现埋点的动态可配置
在测试环境下,用研人员会通过手动模拟点击的方式获取sdk上报的控件唯一id和数据信息,在确认id,和数据的正确性之后,需要手动配置id和埋点事件的对应关系,及上报的数据字段,并存储到配置仓库。在线上环境,当用户启动app会拉取配置信息并加载到内存。这样,当用户触发点击行为时,会根据第一步获取的id信息查询配置,如果在配置中查到对应的条目,会将对应的事件及数据上报到服务器。
为了处理配置下拉失败无法发送埋点的情况,我们需要将同样的配置放在主项目的assets目录下,每次启动app请求配置接口判断配置信息是否发生变化,如果配置没有变化,直接使用assets中的配置文件,否则,下拉最新配置,使用最新的埋点配置信息。
四、无痕埋点方案对现有项目的约束
使用无埋点sdk需要遵循一定的开发规范,关于具体的开发规范请查看工程README。为了确保项目编码的规范性,我们开发了一系列lint检查规则来帮助发现错误。
lint 工程代码 https://github.com/jessie345/CustomLintRules.git
集成lint功能 https://github.com/jessie345/CustomLintsUsage.git
五、继续优化
目前,集成这个无埋点方案有一些使用约束并且需要在主项目中添加一些特定的配置函数。下一步需要做的就是解耦。通过javasist技术,尽量将所有约束迁移到用动态技术保证,而不是通过lint规范,将其侵入性降到最低。
至此,无埋点sdk的核心运作机制已经全部梳理清楚。