这是一个代码阅读笔记,而不是实现分析,想要深入学习亲自阅读源码才是最好的。
Sensors Analytics是一款sdk端开源的统计工具,并在各语言各平台上有相应的SDK。本文学习的是Android版本。由于对可视化埋点的实现感兴趣,于是写个笔记记录下阅读过程。
功能包括两部分:
- 非代码埋点 允许手机连接应用后台管理界面,通过可视化操作设置埋点。
- 无需更新 服务端配置动态配置埋点位置,可配置的埋点必须是view的特定事件,例如click。
setEventBindings()
用于设置埋点配置。ViewCrawler
持有一个EditState
实例。Pathfinder
委托处理遍历逻辑,并以List
形式存储查找路径。ViewVisitor
绑定。其自身会监听onGlobalLayout
并调用ViewVisitor.visit()
开始遍历。应用启动时加载本地缓存埋点配置,同时向服务端获取埋点配置。无论本地配置还是服务端配置,成功读取后,最终以Map
形式保存在EditState
。
同时,启动时加载应用的R.id.class,通过反射获取id名和id的对应关系。这使得服务端可以通过id名而不是id配置埋点,提高了可读性。
埋点数据格式
参数名 | 类型 | 说明 |
---|---|---|
target_activity | String | 埋点View所在的Activity |
event_name | String | 事件名称 |
event_type | String | 事件类型,如click |
trigger_id | int | 未知 |
deployed | boolean | 未知 |
path | Pathfinder.PathElement[] | 埋点View在ViewTree中的找寻路径,格式见”path数据格式” |
path数据格式
参数名 | 类型 | 说明 |
---|---|---|
prefix | String | 是否深度优先遍历 |
view_class | String | 埋点View的class名称 |
index | int | 选择第index+1个匹配View作为返回结果 |
id | int | 埋点View的id。如果该属性未给出,将通过sa_id_name查找 |
sa_id_name | String | 埋点View的id名,如R.id.tv_name |
EditBinding
自身是OnGlobalLayoutListener
,实例化时将自身添加到ViewTree观察者:
public EditBinding(View viewRoot, ViewVisitor edit, Handler uiThreadHandler) {
//...
final ViewTreeObserver observer = viewRoot.getViewTreeObserver();
if (observer.isAlive()) {
observer.addOnGlobalLayoutListener(this);
}
run();
}
在onGlobalLayout()
中第一次遍历ViewTree,并且此后每隔5秒遍历ViewTree,寻找埋点View:
if (!mAlive) {
return;
}
final View viewRoot = mViewRoot.get();
if (null == viewRoot || mDying) {
cleanUp();
return;
}
// ELSE View is alive and we are alive
mEdit.visit(viewRoot);
mHandler.removeCallbacks(this);
mHandler.postDelayed(this, 5000);
mEdit.visit(viewRoot);
即为遍历过程,最终调用自身的findTargetsInMatchedView()
:
private void findTargetsInMatchedView(View alreadyMatched, List remainingPath,
Accumulator accumulator) {
if (remainingPath.isEmpty()) {
// 已经匹配了View
accumulator.accumulate(alreadyMatched);
return;
}
//...嵌套匹配逻辑
}
findTargetsInMatchedView()
根据id、id名(如果有的话)、index、prefix查找View。
index的官方解释:
The index attribute, counting from root to leaf, and first child to last child, selects a particular matching view amongst all possible matches. Indexing starts at zero, like an array
index. So E.index == 2 means “Select the third possible match for this element”.
prefix的官方解释:
The prefix attribute refers to the position of the matched views in the hierarchy, relative to the current position of the path being searched.
prefix有两种值,影响View遍历顺序:ZERO_LENGTH_PREFIX
和SHORTEST_PREFIX
。其中SHORTEST_PREFIX
允许深度优先遍历。
以上引用的代码中Accumulator.accumulate()
用于处理遍历结果。由于ViewVisitor
继承于Accumulator
,所以最后alreadyMatched交给ViewVisitor
自身处理。accumulate()
的实现与该ViewVisitor
监听的事件有关。例如,如果监听onClick,则对view设置代理监听:
@Override
public void accumulate(View found) {
final View.AccessibilityDelegate realDelegate = getOldDelegate(found);
if (realDelegate instanceof TrackingAccessibilityDelegate) {
final TrackingAccessibilityDelegate currentTracker =
(TrackingAccessibilityDelegate) realDelegate;
if (currentTracker.willFireEvent(getEventName())) {
return; // Don't double track
}
}
if (SensorsDataAPI.ENABLE_LOG) {
Log.i(LOGTAG, String.format("ClickVisitor accumulated. View %s", found.toString()));
}
// We aren't already in the tracking call chain of the view
final TrackingAccessibilityDelegate newDelegate =
new TrackingAccessibilityDelegate(realDelegate);
found.setAccessibilityDelegate(newDelegate);
mWatching.put(found, newDelegate);
}
监听到事件后,在DynamicEventTracker
中处理事件。如果是click这样的单次事件,则立即发送报告;如果是edited事件,则缓存,延迟3秒发送。需要缓存的事件取eventName、triggerId、view三者hashCode的混合值作为唯一标识。后续异步更新这个事件时,通过唯一标识在缓存中查找。
@Override
public void OnEvent(View v, EventInfo eventInfo, boolean debounce) {
final long moment = System.currentTimeMillis();
final JSONObject properties = new JSONObject();
try {
properties.put("$from_vtrack", String.valueOf(eventInfo.mTriggerId));
properties.put("$binding_trigger_id", eventInfo.mTriggerId);
properties.put("$binding_path", eventInfo.mPath);
properties.put("$binding_depolyed", eventInfo.mIsDeployed);
} catch (JSONException e) {
Log.e(LOGTAG, "Can't format properties from view due to JSON issue", e);
}
// 对于Clicked事件,事件发生时即调用track记录事件;对于Edited事件,由于多次Edit时会触发多次Edited,
// 所以我们增加一个计时器,延迟发送Edited事件
if (debounce) {
final Signature eventSignature = new Signature(v, eventInfo);
final UnsentEvent event = new UnsentEvent(eventInfo, properties, moment);
synchronized (mDebouncedEvents) {
final boolean needsRestart = mDebouncedEvents.isEmpty();
mDebouncedEvents.put(eventSignature, event);
if (needsRestart) {
mHandler.postDelayed(mTask, DEBOUNCE_TIME_MILLIS);
}
}
} else {
try {
SensorsDataAPI.sharedInstance(mContext).track(eventInfo.mEventName, properties);
} catch (InvalidDataException e) {
Log.w("Unexpected exception", e);
}
}
}
通过enableEditingVTrack()
开启可视化埋点。
实现原理:监听ActivityLifecycleCallbacks
,每次在onResume()
中连接服务端,即握手过程。握手成功后,响应服务端需要的数据:
@Override
public void onMessage(String message) {
try {
final JSONObject messageJson = new JSONObject(message);
final String type = messageJson.getString("type");
if (type.equals("device_info_request")) {
mService.sendDeviceInfo(messageJson);
} else if (type.equals("snapshot_request")) {
mService.sendSnapshot(messageJson);
} else if (type.equals("event_binding_request")) {
mService.bindEvents(messageJson);
} else if (type.equals("disconnect")) {
mService.disconnect();
}
} catch (final JSONException e) {
Log.e(LOGTAG, "Bad JSON received:" + message, e);
}
}
大量socket封装用于实现可视化埋点,而一旦用户不需要这个功能,这些代码显得冗余。
这种统计SDK一般都会将信息存在本地数据库,并在合适的时机上传。然而本SDK的数据库实现过于简单,数据库操作不基于事务,容错差。