大数据时代互联网产品针对用户数据采集和分析是十分重要的一环,作为一个Android开发者一直以来对于埋点(特别是可视化埋点)十分感兴趣。最近了解【易观】数据统计开源了其Sdk源码在GitHub,通过理解其源码多少可以学到一部分关于埋点的技术原理。文末附易观开源SDK官方链接,在此我们只研究Android技术~
在Application中调用init方法,传入上下文(context) 和 配置参数(config)
/**
* 初始化方舟SDK相关API
*/
private void initAnalsysy() {
...
AnalysysConfig config = new AnalysysConfig();
// 设置key(目前使用电商demo的key)
config.setAppKey(APP_KEY);
...
// 初始化
AnalysysAgent.init(this, config);
}
调用到AgentProcess类中的init方法
public void init(final Context context, final AnalysysConfig config) {
...
//初始化工具类,注入上下文对象
AnalysysUtil.init(context);
//监听crash事件
CrashHandler.getInstance().setCallback(new CrashHandler.CrashCallBack() {
@Override
public void onAppCrash(Throwable e) {
//上报crash事件
CrashHandler.getInstance().reportException(context, e, CrashHandler.CrashType.crash_auto);
}
});
//监听生命周期事件
ActivityLifecycleUtils.initLifecycle();
//添加生命周期事件回调
ActivityLifecycleUtils.addCallback(new AutomaticAcquisition());
//初始化AThreadPool,设置key、channel、url等config参数
AThreadPool.initHighPriorityExecutor(new Callable() {
@Override
public Object call() throws Exception {
...
}
});
}
这里主要看一下AutomaticAcquisition类,它本质上是一个Application.ActivityLifecycleCallbacks 的实现类,回调每个activity生命周期事件,主动上报了页面、应用启动、应用结束等信息。
热图数据的采集逻辑,算是初始化过程中比较重要的一个环节。
// 热图数据采集(默认关闭)
config.setAutoHeatMap(true);
当我们开启了热图数据采集,在AutomaticAcquisition的onActivityCreated中会初始化一个视图树监听器
private void initHeatMap(final WeakReference wa) {
layoutListener = new ViewTreeObserver.OnGlobalLayoutListener() {
@Override
public void onGlobalLayout() {
...
final Activity activity = wa.get();
if (activity != null) {
...
//页面宽高分辨率等信息
HeatMap.getInstance().initPageInfo(activity);
....
//hook
HeatMap.getInstance()
.hookDecorViewClick(activity.getWindow().getDecorView());
}
}
};
}
这里重点看一下hookDecorViewClick方法的实现
/***
* 递归调用解析view
* @param decorView 根节点view
*/
public void hookDecorViewClick(View decorView) throws Exception {
if (decorView instanceof ViewGroup) {
hookViewClick(decorView);
int count = ((ViewGroup) decorView).getChildCount();
for (int i = 0; i < count; i++) {
if (((ViewGroup) decorView).getChildAt(i) instanceof ViewGroup) {
hookDecorViewClick(((ViewGroup) decorView).getChildAt(i));
} else {
hookViewClick(((ViewGroup) decorView).getChildAt(i));
}
}
} else {
hookViewClick(decorView);
}
}
/**
* 反射给View注册监听
*/
private void hookViewClick(View view) throws Exception {
...
Class viewClass = Class.forName("android.view.View");
Method getListenerInfoMethod = viewClass.getDeclaredMethod("getListenerInfo");
if (!getListenerInfoMethod.isAccessible()) {
getListenerInfoMethod.setAccessible(true);
}
Object listenerInfoObject = getListenerInfoMethod.invoke(view);
Class mListenerInfoClass = Class.forName("android.view.View$ListenerInfo");
Field mOnClickListenerField = mListenerInfoClass.getDeclaredField("mOnTouchListener");
mOnClickListenerField.setAccessible(true);
//这里通过一系列反射操作拿到了view的mOnTouchListener对象
Object touchListenerObj = mOnClickListenerField.get(listenerInfoObject);
if (!(touchListenerObj instanceof HookTouchListener)) {
HookTouchListener touchListenerProxy =
new HookTouchListener((View.OnTouchListener) touchListenerObj);
//设置成代理类(HookTouchListener)对象
mOnClickListenerField.set(listenerInfoObject, touchListenerProxy);
}
}
代理类HookTouchListener中
private class HookTouchListener implements View.OnTouchListener {
private View.OnTouchListener onTouchListener;
private HookTouchListener(View.OnTouchListener onTouchListener) {
this.onTouchListener = onTouchListener;
}
@Override
public boolean onTouch(final View v, final MotionEvent event) {
...
if (event.getAction() == MotionEvent.ACTION_DOWN) {
try {
// 黑白名单判断
if (isTackHeatMap(v)) {
//上报热图信息
setCoordinate(v, event);
}
} catch (Throwable ignore) {
ExceptionUtil.exceptionThrow(ignore);
}
}
...
}
}
private void setCoordinate(final View v, final MotionEvent event) {
final float rawX = event.getRawX();
final float rawY = event.getRawY();
if (isTouch(rawX, rawY)) {
x = event.getX();
y = event.getY();
final long currentTime = System.currentTimeMillis();
AThreadPool.asyncLowPriorityExecutor(new Runnable() {
@Override
public void run() {
try {
...
final String path = PathGeneral.getInstance().general(v);
boolean isAddPath = setPath(path);
if (isAddPath) {
rx = rawX;
ry = rawY;
//添加点击控件坐标
setClickCoordinate();
//添加点击控件类型及内容
setClickContent(v);
//添加页面数据
clickInfo.putAll(pageInfo);
//上报控件信息
AgentProcess.getInstance()
.pageTouchInfo(clickInfo,currentTime);
}
} catch (Throwable ignore) {
ExceptionUtil.exceptionThrow(ignore);
}
}
});
}
}
最后在onActivityResumed生命周期中绑定监听器
rootView.getViewTreeObserver().addOnGlobalLayoutListener(layoutListener);
当我们点击一个View时一旦触发ontouch事件,就会自动上报这个View的热图数据。
可视化埋点从操作上来看,首先,管理网站跟我们的应用建立一个连接,获取应用页面信息,添加埋点事件配置,然后将埋点事件下发到应用。应用接收到下发的埋点配置数据后,匹配并设置埋点事件、在触发事件时主动上报事件。
易观中,App应用作为Socket客户端与管理网站建立链接,具体实现步骤如下:
// 设置 WebSocket 连接 Url
AnalysysAgent.setVisitorDebugURL(this, SOCKET_URL);
/**
* AnalysysAgent
* 设置可视化websocket服务器地址
*/
public static void setVisitorDebugURL(Context context, String url) {
AgentProcess.getInstance().setVisitorDebugURL(url);
}
/**
* 设置可视化websocket服务器地址
*/
public void setVisitorDebugURL(final String url) {
AThreadPool.asyncHighPriorityExecutor(new Runnable() {
@Override
public void run() {
//设置链接地址,
//LifeCycleConfig读取了LifeCycleConfig.json
//LifeCycleConfig.visual.optString(START)映射到VisualAgent.setVisitorDebugURL方法
setVisualUrl(context,LifeCycleConfig.visual.optString(START), url);
}
});
}
//通过反射设置url
private void setVisualUrl(Context context, String path, String url) {
int index = path.lastIndexOf(".");
CommonUtils.reflexStaticMethod(
path.substring(0, index),
path.substring(index + 1),
new Class[]{Context.class, String.class},context, url);
}
/**
* 设置可视化websocket服务器地址
*/
public static void setVisitorDebugURL(Context context, String url) {
...
//初始化可视化埋点
initVisual(context);
...
}
/**
* 初始化可视化埋点功能
*/
private static void initVisual(final Context context) {
//创建VisualManager实例
VisualManager.getInstance(context);
}
/**
* VisualManager构造函数
*/
private VisualManager(Context context) {
//创建ViewCrawler 对象
viewCrawler = new ViewCrawler(context);
viewCrawler.startUpdates();
}
在ViewCrawler中创建了一个传感器监听SensorHelper,监听摇一摇和屏幕翻转事件,在其onFlipGesture回调中发送消息,尝试调用connectToEditor建立socket连接。(关于socket连接这里就不分析了,接下来我们主要关注其通过这个连接要做什么事情)
通过上一步建立了socket连接后,会生成一个EditorConnection对象,这里面有一个EditorClient持有了socket客户端对象。这里主要看下接受socket消息的方法,其中定义了三个事件。
@Override
public void onMessage(String message) {
...
final JSONObject messageJson = new JSONObject(message);
final String type = messageJson.getString("type");
...
if ("device_info_request".equals(type)) {
//请求设备的基本信息
mService.sendDeviceInfo();
} else if ("snapshot_request".equals(type)) {
//请求快照信息
// (具体为获取现在屏幕截图和详细组件信息)
mService.sendSnapshot(messageJson);
} else if ("event_binding_request".equals(type)) {
//可视化埋点绑定信息
mService.bindEvents(messageJson);
}
...
}
那么来看下接收到Socket服务下发的消息后,客户端怎么处理的呢?以event_binding_request事件为例,也就是在网页上添加了埋点配置,发送给应用,应用绑定配置的过程
首先,EditorConnection的mService是个Editor对象,接收到socket消息后,通过Editor包装成一个message对象发送给了ViewCrawlerHandler
private class Editor implements EditorConnection.Editor {
...
@Override
public void sendDeviceInfo() {
final Message msg = mMessageThreadHandler.obtainMessage(ViewCrawler.MESSAGE_SEND_DEVICE_INFO);
mMessageThreadHandler.sendMessage(msg);
}
...
}
在handleMessage方法中接收到MESSAGE_HANDLE_EDITOR_BINDINGS_RECEIVED事件后,调用handleEditorBindingsReceived方法解析并封装数据,再调用applyEventBindings方法绑定埋点事件。
private void handleEditorBindingsReceived(JSONObject message) {
...
//解析、封装、缓存数据
...
//绑定埋点事件
applyEventBindings();
}
private void applyEventBindings() {
final List> newVisitors = new ArrayList<>();
...
{
for (EgPair changeInfo : mEditorEventBindings.values(){
...
//生成BaseViewVisitor
final BaseViewVisitor visitor = mProtocol.readEventBinding(changeInfo.second, mDynamicEventTracker);
newVisitors.add(new EgPair<>(changeInfo.first,visitor));
...
}
}
final Map> editMap = new HashMap<>();
...
//
mEditState.setEdits(editMap);
}
public void setEdits(Map> newEdits) {
...
applyEditsOnUiThread();
...
}
private void applyEditsOnUiThread() {
if (Thread.currentThread() == mUiThreadHandler.getLooper().getThread()) {
applyIntendedEdits();
} else {
mUiThreadHandler.post(new Runnable() {
@Override
public void run() {
applyIntendedEdits();
}
});
}
}
private void applyIntendedEdits() {
...
applyChangesFromList(rootView, specificChanges);
...
}
private void applyChangesFromList(View rootView, List changes) {
synchronized (mCurrentEdits) {
final int size = changes.size();
for (int i = 0; i < size; i++) {
final BaseViewVisitor visitor = changes.get(i);
final EditBinding binding = new EditBinding(rootView, visitor, mUiThreadHandler);
mCurrentEdits.add(binding);
}
}
}
通过上述一系列传递调用,最终生成了EditBinding对象,我们分析这个类,看看事件具体是怎么绑定到View上的
private static class EditBinding implements ViewTreeObserver.OnGlobalLayoutListener, Runnable {
...
public EditBinding(View viewRoot, BaseViewVisitor edit, Handler uiThreadHandler) {
...
run();
}
...
@Override
public void run() {
...
//此处理回最终的事件回调,这个mEdit就是之前解析数据生成的BaseViewVisitor
mEdit.visit(viewRoot);
...
}
...
}
public void visit(View rootView) {
//这里就是根据事件path找到View的方法了
mPathfinder.findTargetsInRoot(rootView, mPath, this);
}
public void findTargetsInRoot(View givenRootView, List path,
Accumulator accumulator) {
...
//这里是根据path具体寻找view
final View rootView = findPrefixedMatch(rootPathElement, givenRootView, indexKey);
...
if (null != rootView) {
//找到view后需要绑定事件了
findTargetsInMatchedView(rootView, childPath, accumulator);
}
}
private void findTargetsInMatchedView(View alreadyMatched, List remainingPath,Accumulator accumulator) {
if (remainingPath.isEmpty()) {
//已经找到view了,那么直接设置埋点事件就可以了
accumulator.accumulate(alreadyMatched);
return;
}
//还没有找到对应的view,就继续找
...
}
通过上面几步,找到了埋点事件对应的View,那么accumulator是怎么埋点的?首先,我们需要知道这里的accumulator对象是前面通过mProtocol.readEventBinding生成的,埋点事件不同,生成的visitor也是不一样的,我们以点击事件为例,生成的就是AddAccessibilityEventVisitor对象了,那么我们看一下accumulate究竟做了什么?
@Override
public void accumulate(View found) {
final View.AccessibilityDelegate realDelegate = getOldDelegate(found);
...
//这里创建了一个AccessibilityDelegate的代理
final TrackingAccessibilityDelegate newDelegate = new TrackingAccessibilityDelegate(realDelegate);
//设置自定义的代理
found.setAccessibilityDelegate(newDelegate);
...
}
private class TrackingAccessibilityDelegate extends View.AccessibilityDelegate {
private View.AccessibilityDelegate mRealDelegate;
...
@Override
public void sendAccessibilityEvent(View host, int eventType) {
//当view触发事件的时候,判断是不是我们的埋点事件
if (eventType == mEventType) {
//命中埋点事件
fireEvent(mPreviousText, host);
}
if (null != mRealDelegate) {
mRealDelegate.sendAccessibilityEvent(host, eventType);
}
}
}
private static abstract class BaseEventTriggeringVisitor extends BaseViewVisitor {
...
protected void fireEvent(String previousText, View found) {
//通过listener回调事件
mListener.onEvent(found, mEventName, previousText, mMatchText, mDebounce);
}
...
}
class DynamicEventTracker implements BaseViewVisitor.OnEventListener {
...
@Override
public void onEvent(View v, String eventName, String previousText, String matchText,boolean debounce) {
// 触发可视化埋点事件的回调
...
EventSender.sendEventToSocketServer(v, eventName);
//终于看到上报事件了...
AnalysysAgent.track(v.getContext(), eventName);
...
}
}
看到这里有没有一种豁然开朗的感觉呢,简单来说可视化下发一个埋点配置(事件id、事件类型、事件路径等等信息),接收到下发的数据后,首先根据路径信息找到匹配的view,设置代理拦截View的sendAccessibilityEvent方法,这样就完成了配置。当view命中埋点事件时,通过自定义的listener触发数据上报给服务器,这样就完成了一个完整的埋点流程。那么这里为什么不像上面采集热图一样hook onTouch时间呢?
class EditProtocol {
...
public BaseViewVisitor readEventBinding(JSONObject source,BaseViewVisitor.OnEventListener listener)throws BadInstructionsException {
if ("click".equals(eventType)) {
return new BaseViewVisitor.AddAccessibilityEventVisitor(
path,
AccessibilityEvent.TYPE_VIEW_CLICKED,
eventID,
matchType,
listener
);
} else if ("selected".equals(eventType)) {
...
} else if ("text_changed".equals(eventType)) {
...
} else if ("detected".equals(eventType)) {
...
} else {
throw new BadInstructionsException("can't track event type \"" +
eventType + "\"");
}
...
}
看readEventBinding方法就可以理解了,我们不单单要支持点击,我们需要支持更多类型的埋点事件。
最后,我们可视化埋点都配置好了,那么用户app怎么获取到我们设置的埋点配置,又是怎么让埋点生效呢?
// 初始化时 设置配置下发 Url
AnalysysAgent.setVisitorConfigURL(this, CONFIG_URL);
/**
* AnalysysAgent
* 设置线上请求埋点配置的服务器地址
*/
public static void setVisitorConfigURL(Context context, String url) {
AgentProcess.getInstance().setVisitorConfigURL(url);
}
/**
* AgentProcess
* 设置线上请求埋点配置的服务器地址
*/
public void setVisitorConfigURL(final String url) {
AThreadPool.asyncHighPriorityExecutor(new Runnable() {
@Override
public void run() {
...
//读取LifeCycleConfig配置,反射调用VisualAgent.setVisitorConfigURL方法
setVisualUrl(context,LifeCycleConfig.visualConfig.optString(START),url);
...
}
});
}
/**
* VisualAgent
* 设置线上请求埋点配置的服务器地址
*/
public static void setVisitorConfigURL(Context context, String url) {
...
initConfig(context);
...
}
/**
* VisualAgent
* 初始化可视化
*/
public static synchronized void initConfig(Context context) {
StrategyGet.getInstance(context).getVisualBindingConfig();
...
}
/**
* StrategyGet
* http请求是否有可视化埋点协议,并应用
*/
public void getVisualBindingConfig() {
...
new Thread(new Runnable() {
@Override
public void run() {
new StrategyTask(mContext, url).run();
}
}).start();
...
}
public class StrategyTask implements Runnable {
...
@Override
public void run() {
...
//应用埋点配置
VisualManager.getInstance(mContext)
.applyEventBindingEvents(jsonObject.getJSONArray("data"));
...
}
}
/**
* VisualManager
* http请求是否有可视化埋点协议,并应用
*/
public void applyEventBindingEvents(JSONArray bindingEvents) {
viewCrawler.setEventBindings(bindingEvents);
}
/**
* viewCrawler
* 保存当前与历史绑定事件Presistent
*/
public void setEventBindings(JSONArray bindings) {
if (bindings != null) {
final Message msg =
mMessageThreadHandler.obtainMessage(ViewCrawler.MESSAGE_EVENT_BINDINGS_RECEIVED);
msg.obj = bindings;
mMessageThreadHandler.sendMessage(msg);
}
}
初始化的时候,当我们配置了对应的url之后,通过反射调用到下载配置的任务,获取到配置后通过message发送给ViewCrawler,这之后的逻辑就跟可视化编辑器下发配置后,解析、匹配、代理、监听、上报数据一致了。
埋点大致可以分为,代码埋点、全埋点、可视化埋点。在这里只是简单跟踪和了解一下可视化埋点的大致思路。其实无论是哪种埋点,都需要上报数据,这里面也有很多的细节,例如:
易观中使用contentProvider 统筹了以下三种存储方式:
① 数据库:待上传的事件数据
② sp: 存储一些配置信息(url、key、channel等),状态值(PV值、上个页面结束时间、应用启动时间)
③ 内存:网络上传策略和最大缓存数量
① 服务器策略处理
* 网络不通返回false,
* 判断服务器debug模式为1或2时,实时上传.
* 策略为 0:智能发送判断db内事件条数是否大于设置条数,间隔时间是否大于设置的间隔时间
* 策略为 1:实时发送,
* 策略为 2:间隔发送,② 用户策略处理
* 判断用户debug模式为1或2时,实时上传.
* 判断db内事件条数是否大于设置条数
* 当前时间减去上次发送时间是否大于设置的间隔时间③ 默认策略
* 实时上传
这块主要适用于全埋点
官方链接:
易观开源SDK-开发者_易观方舟|智能用户运营易观方舟提供:体验版(免费试用30天)、商业版(云端托管,适用中小团队)、企业版(私有化部署,适用于中大型团队),拨打:4006 - 010 – 231选择合适方案。https://www.analysysdata.com/developer-sdk.htmlhttps://github.com/analysys/ans-android-sdkhttps://github.com/analysys/ans-android-sdk