MixPanel:Android 端埋點技術研究
前言
目前在 app 上通過記錄用戶操作(俗稱埋點),來分析用戶行為的做法,已經成了 app 必不可少的一部分。有關 app 的埋點技術,也在發展中。正好最近項目組研發了一個埋點的 sdk,所以把相關知識梳理下。
埋點方式
1、代碼埋點
這種方式主要是由程序猿們手動在代碼中的回調事件里加上埋點代碼。優點是高度定制,想怎么埋怎么埋,缺點是工作量大,而且易出錯,難維護。
2、可視化埋點
這種埋點方式分為兩種,一是使用后臺界面配置需要埋點的位置,app下載配置文件,將需要埋點的事件上傳(代表 MixPanel,百度,talkingdata等)。二是app把所有事件上傳,后臺自己選擇需要埋點的點(代表heap)。
兩種埋點方式各有優劣,但是由于技術目前還在發展中,并沒有形成完全統一的理論以及方式,因此現在大多是這兩種方式并存。
參考文獻: http://blog.csdn.net/vshuang/article/details/60361314
MixPanel源碼分析-Android
下面是分析 MixPanel 的源碼,這應該是唯一的開源的商業埋點實現(其他我沒找到),提供可視化埋點以及代碼埋點方式。開源地址: https://github.com/mixpanel ,我主要是研究 Android 的代碼。
本文的分析并不透徹,主要由于 mixpanel 的代碼比較復雜,很多是和服務器的交互,在不了解協議的情況下,我也是連蒙帶猜來看源碼的。想透徹分析的同學,可以在 mixpanel 的網站上注冊一個應用,再在應用里集成 mixpanel 的源碼,然后加日志或者 debug 來分析。由于時間有限,我并沒有這么做。請見諒。
首先是 mixpanel 的入口,MixPanelApi
該類中有大量的方法叫做 Tweak,這個是用來做 abtest 的,在服務器上做相應的配置,客戶端可以拉取配置實現不同的功能。本文不討論這個。
主要方法就是 track,
/**
* Track an event.
*
* <p>Every call to track eventually results in a data point sent to Mixpanel. These data points
* are what are measured, counted, and broken down to create your Mixpanel reports. Events
* have a string name, and an optional set of name/value pairs that describe the properties of
* that event.
*
* @param eventName The name of the event to send
* @param properties A JSONObject containing the key value pairs of the properties to include in this event.
* Pass null if no extra properties exist.
*/
public void track(String eventName, JSONObject properties) {
track(eventName, properties, false);
}
我們通過不停跟蹤代碼發現,這個方法會把埋點的 event,生成一個AnalyticsMessages.EventDescription 對象,然后通過 handler,發送到后臺線程中去處理,代碼如下
track(){
final AnalyticsMessages.EventDescription eventDescription =
new AnalyticsMessages.EventDescription(eventName, messageProps, mToken, isAutomaticEvent);
mMessages.eventsMessage(eventDescription);
}
// ...跳轉至eventsMessage
public void eventsMessage(final EventDescription eventDescription) {
final Message m = Message.obtain();
m.what = ENQUEUE_EVENTS;
m.obj = eventDescription;
mWorker.runMessage(m);
}
//消息處理
if (msg.what == ENQUEUE_EVENTS) {
final EventDescription eventDescription = (EventDescription) msg.obj;
try {
//省略部分代碼
returnCode = mDbAdapter.addJSON(message, token, MPDbAdapter.Table.EVENTS, eventDescription.isAutomatic());
} catch (final JSONException e) {
MPLog.e(LOGTAG, "Exception tracking event " + eventDescription.getEventName(), e);
}
}
可以看到,最終數據被存儲到了數據庫里,具體的數據庫表結構大家可以自行看源碼,我就不研究哦了。
那數據什么時候上傳呢,主要是在 activiyt 的 onPause 之后上傳。
@Override
public void onActivityPaused(final Activity activity) {
mPaused = true;
if (check != null) {
mHandler.removeCallbacks(check);
}
mHandler.postDelayed(check = new Runnable(){
@Override
public void run() {
if (mIsForeground && mPaused) {
mIsForeground = false;
try {
double sessionLength = System.currentTimeMillis() - sStartSessionTime;
if (sessionLength >= mConfig.getMinimumSessionDuration() && sessionLength < mConfig.getSessionTimeoutDuration()) {
DecimalFormat df = new DecimalFormat("#.0");
String sessionLengthString = df.format((System.currentTimeMillis() - sStartSessionTime) / 1000);
JSONObject sessionProperties = new JSONObject();
sessionProperties.put(AutomaticEvents.SESSION_LENGTH, sessionLengthString);
mMpInstance.track(AutomaticEvents.SESSION, sessionProperties, true);
}
} catch (JSONException e) {
e.printStackTrace();
}
mMpInstance.flush(); //上傳
}
}
}, CHECK_DELAY);
}
用戶也可以通過 MixPanelApi 的 flush 方法上傳
public void flush() {
mMessages.postToServer(new AnalyticsMessages.FlushDescription(mToken));
}
這就是事件埋點的基本流程,當然功能不止這些,還可以通過 activity 的生命周期,記錄頁面停留時長等,這些都是基于這個基本流程來處理的。
可視化埋點
我覺得埋點主要的難點就是在可視化埋點上,如何做到良好的用戶體驗以及性能呢。我們一起來看看 MixPanel 是怎么做的。
首先看一下官網的介紹 https://mixpanel.com/autotrack/
通過視頻可以看到,網頁后臺可以找到我們所有可以埋點的區域,該區域會高亮+邊框顯示出來,點擊該區域,就會顯示彈出一個對話框,就可以把這個區域射成一個埋點的位置。
這看起來是不是比代碼埋點好多啦。那服務器是怎么找到 app 中可以埋點的位置的呢。我們來看一下源碼,首先是連接上配置界面的地方,是通過 websocket 連接的,mixpanel 繼承了大量 websocket 的實現,這里我們就不管他了,感興趣的同學可以去自己研究下 websocket 的開源實現。具體處理協議的地方是 EditorClient 這個類
private class EditorClient extends WebSocketClient {
public EditorClient(URI uri, int connectTimeout, Socket sslSocket) throws InterruptedException {
super(uri, new Draft_17(), null, connectTimeout);
setSocket(sslSocket);
}
@Override
public void onOpen(ServerHandshake handshakedata) {
MPLog.v(LOGTAG, "Websocket connected");
}
@Override
public void onMessage(String message) {
MPLog.v(LOGTAG, "Received message from editor:\n" + message);
try {
final JSONObject messageJson = new JSONObject(message);
final String type = messageJson.getString("type");
if (type.equals("device_info_request")) {
mService.sendDeviceInfo();
} else if (type.equals("snapshot_request")) {
mService.sendSnapshot(messageJson);
} else if (type.equals("change_request")) {
mService.performEdit(messageJson);
} else if (type.equals("event_binding_request")) {
mService.bindEvents(messageJson);
} else if (type.equals("clear_request")) {
mService.clearEdits(messageJson);
} else if (type.equals("tweak_request")) {
mService.setTweaks(messageJson);
}
} catch (final JSONException e) {
MPLog.e(LOGTAG, "Bad JSON received:" + message, e);
}
}
可以看到 OnMessage 的地方有這么幾個接口。這些都是后臺 web 頁面發過來的消息,然后 app 端執行相應的操作。
device_info_request 這個就不說了,顯然是獲取一些設備的信息。
snapshot_request 這個就是關鍵的地方,這里是 app 端將當前展示的頁面的截圖,發送給后端,這樣后端就可以顯示出來了。我們通過代碼跟蹤,找到了實現在 ViewCrawler 里的 sendSnapshot 方法。
private void sendSnapshot(JSONObject message) {
final long startSnapshot = System.currentTimeMillis();
//...省略
try {
writer.write("{");
writer.write("\"type\": \"snapshot_response\",");
writer.write("\"payload\": {");
{
writer.write("\"activities\":");
writer.flush();
mSnapshot.snapshots(mEditState, out);
}
final long snapshotTime = System.currentTimeMillis() - startSnapshot;
writer.write(",\"snapshot_time_millis\": ");
writer.write(Long.toString(snapshotTime));
writer.write("}"); // } payload
writer.write("}"); // } whole message
} catch (final IOException e) {
MPLog.e(LOGTAG, "Can't write snapshot request to server", e);
} finally {
try {
writer.close();
} catch (final IOException e) {
MPLog.e(LOGTAG, "Can't close writer.", e);
}
}
}
關鍵代碼在 ViewSnapShot 里
/**
* Take a snapshot of each activity in liveActivities. The given UIThreadSet will be accessed
* on the main UI thread, and should contain a set with elements for every activity to be
* snapshotted. Given stream out will be written on the calling thread.
*/
public void snapshots(UIThreadSet<Activity> liveActivities, OutputStream out) throws IOException {
mRootViewFinder.findInActivities(liveActivities);
final FutureTask<List<RootViewInfo>> infoFuture = new FutureTask<List<RootViewInfo>>(mRootViewFinder);
mMainThreadHandler.post(infoFuture);
final OutputStreamWriter writer = new OutputStreamWriter(out);
List<RootViewInfo> infoList = Collections.<RootViewInfo>emptyList();
writer.write("[");
try {
infoList = infoFuture.get(1, TimeUnit.SECONDS);
} catch (final InterruptedException e) {
MPLog.d(LOGTAG, "Screenshot interrupted, no screenshot will be sent.", e);
} catch (final TimeoutException e) {
MPLog.i(LOGTAG, "Screenshot took more than 1 second to be scheduled and executed. No screenshot will be sent.", e);
} catch (final ExecutionException e) {
MPLog.e(LOGTAG, "Exception thrown during screenshot attempt", e);
}
RootViewInfo 是一個 Future,主要的方法是 taskSnapShot
private void takeScreenshot(final RootViewInfo info) {
final View rootView = info.rootView;
Bitmap rawBitmap = null;
try {
final Method createSnapshot = View.class.getDeclaredMethod("createSnapshot", Bitmap.Config.class, Integer.TYPE, Boolean.TYPE);
createSnapshot.setAccessible(true);
rawBitmap = (Bitmap) createSnapshot.invoke(rootView, Bitmap.Config.RGB_565, Color.WHITE, false);
} catch (final NoSuchMethodException e) {
MPLog.v(LOGTAG, "Can't call createSnapshot, will use drawCache", e);
} catch (final IllegalArgumentException e) {
MPLog.d(LOGTAG, "Can't call createSnapshot with arguments", e);
} catch (final InvocationTargetException e) {
MPLog.e(LOGTAG, "Exception when calling createSnapshot", e);
} catch (final IllegalAccessException e) {
MPLog.e(LOGTAG, "Can't access createSnapshot, using drawCache", e);
} catch (final ClassCastException e) {
MPLog.e(LOGTAG, "createSnapshot didn't return a bitmap?", e);
}
Boolean originalCacheState = null;
try {
if (null == rawBitmap) {
originalCacheState = rootView.isDrawingCacheEnabled();
rootView.setDrawingCacheEnabled(true);
rootView.buildDrawingCache(true);
rawBitmap = rootView.getDrawingCache();
}
} catch (final RuntimeException e) {
MPLog.v(LOGTAG, "Can't take a bitmap snapshot of view " + rootView + ", skipping for now.", e);
}
float scale = 1.0f;
if (null != rawBitmap) {
final int rawDensity = rawBitmap.getDensity();
if (rawDensity != Bitmap.DENSITY_NONE) {
scale = ((float) mClientDensity) / rawDensity;
}
final int rawWidth = rawBitmap.getWidth();
final int rawHeight = rawBitmap.getHeight();
final int destWidth = (int) ((rawBitmap.getWidth() * scale) + 0.5);
final int destHeight = (int) ((rawBitmap.getHeight() * scale) + 0.5);
if (rawWidth > 0 && rawHeight > 0 && destWidth > 0 && destHeight > 0) {
mCachedBitmap.recreate(destWidth, destHeight, mClientDensity, rawBitmap);
}
}
if (null != originalCacheState && !originalCacheState) {
rootView.setDrawingCacheEnabled(false);
}
info.scale = scale;
info.screenshot = mCachedBitmap;
}
這里就知道了,首先通過反射 view 的 createSnapshot 方法,嘗試獲取view的截圖,如果沒有成功,則調用截屏的api,來獲取Drawingcache。獲取到之后,根據當前手機屏幕的分辨率來縮放一次。保證跟手機的分辨率一致。再上傳給服務器,當然這里只是屏幕的截圖,只根據截圖,是無法知道控件點擊位置的。然后又做了什么呢,我們繼續看代碼:
private void snapshotView(JsonWriter j, View view)
throws IOException {
final int viewId = view.getId();
final String viewIdName;
if (-1 == viewId) {
viewIdName = null;
} else {
viewIdName = mResourceIds.nameForId(viewId);
}
j.beginObject();
j.name("hashCode").value(view.hashCode());
j.name("id").value(viewId);
j.name("mp_id_name").value(viewIdName);
final CharSequence description = view.getContentDescription();
if (null == description) {
j.name("contentDescription").nullValue();
} else {
j.name("contentDescription").value(description.toString());
}
final Object tag = view.getTag();
if (null == tag) {
j.name("tag").nullValue();
} else if (tag instanceof CharSequence) {
j.name("tag").value(tag.toString());
}
j.name("top").value(view.getTop());
j.name("left").value(view.getLeft());
j.name("width").value(view.getWidth());
j.name("height").value(view.getHeight());
j.name("scrollX").value(view.getScrollX());
j.name("scrollY").value(view.getScrollY());
j.name("visibility").value(view.getVisibility());
float translationX = 0;
float translationY = 0;
if (Build.VERSION.SDK_INT >= 11) {
translationX = view.getTranslationX();
translationY = view.getTranslationY();
}
j.name("translationX").value(translationX);
j.name("translationY").value(translationY);
j.name("classes");
j.beginArray();
// ..省略部分
if (view instanceof ViewGroup) {
final ViewGroup group = (ViewGroup) view;
final int childCount = group.getChildCount();
for (int i = 0; i < childCount; i++) {
final View child = group.getChildAt(i);
// child can be null when views are getting disposed.
if (null != child) {
snapshotView(j, child);
}
}
}
}
這里就是關鍵,通過遍歷當前 view,將所有的 view 的信息,都傳給了后端,尤其是 top,left,width,height 這些信息,通過這些信息,后端就可以確定 view 的位置。這里我覺得是有優化空間的,完全可以只上傳可以被埋點的 view,例如 button 等,像一些純展示的無法點擊的view,其實沒必要上傳的。例如大部分 textview。這樣增加了后端的負擔。
當在后臺操作,選擇了一個點擊的 view 之后,app 端就會收到event_binding_request 消息,我們來看看如何處理這個消息的
final int size = mEditorEventBindings.size();
for (int i = 0; i < size; i++) {
final Pair<String, JSONObject> changeInfo = mEditorEventBindings.get(i);
try {
final ViewVisitor visitor = mProtocol.readEventBinding(changeInfo.second, mDynamicEventTracker);
newVisitors.add(new Pair<String, ViewVisitor>(changeInfo.first, visitor));
} catch (final EditProtocol.InapplicableInstructionsException e) {
MPLog.i(LOGTAG, e.getMessage());
} catch (final EditProtocol.BadInstructionsException e) {
MPLog.e(LOGTAG, "Bad editor event binding cannot be applied.", e);
}
}
首先調用 readEventBinding 讀取服務端發來的信息,然后將它存在一個ViewVisitor 里,想找到具體的 view,則需要識別 view 的一些基本的特征,之前的代碼已經看到了,view 的 id,tag,contentdescription 等特征,都已經被上傳至服務器,這時服務器又會把它下發回來,放在 path 參數里。
// Package access FOR TESTING ONLY
/* package */ List<Pathfinder.PathElement> readPath(JSONArray pathDesc, ResourceIds idNameToId) throws JSONException {
final List<Pathfinder.PathElement> path = new ArrayList<Pathfinder.PathElement>();
for (int i = 0; i < pathDesc.length(); i++) {
final JSONObject targetView = pathDesc.getJSONObject(i);
final String prefixCode = JSONUtils.optionalStringKey(targetView, "prefix");
final String targetViewClass = JSONUtils.optionalStringKey(targetView, "view_class");
final int targetIndex = targetView.optInt("index", -1);
final String targetDescription = JSONUtils.optionalStringKey(targetView, "contentDescription");
final int targetExplicitId = targetView.optInt("id", -1);
final String targetIdName = JSONUtils.optionalStringKey(targetView, "mp_id_name");
final String targetTag = JSONUtils.optionalStringKey(targetView, "tag");
final int prefix;
if ("shortest".equals(prefixCode)) {
prefix = Pathfinder.PathElement.SHORTEST_PREFIX;
} else if (null == prefixCode) {
prefix = Pathfinder.PathElement.ZERO_LENGTH_PREFIX;
} else {
MPLog.w(LOGTAG, "Unrecognized prefix type \"" + prefixCode + "\". No views will be matched");
return NEVER_MATCH_PATH;
}
final int targetId;
final Integer targetIdOrNull = reconcileIds(targetExplicitId, targetIdName, idNameToId);
if (null == targetIdOrNull) {
return NEVER_MATCH_PATH;
} else {
targetId = targetIdOrNull.intValue();
}
path.add(new Pathfinder.PathElement(prefix, targetViewClass, targetIndex, targetId, targetDescription, targetTag));
}
return path;
}
通過這些參數,就可以找個那個 view,繼而對 view 進行監聽,監聽的方式就比較簡單了,用谷歌提供的 Accessibility 相關 api 就可以做到。
if ("click".equals(eventType)) {
return new ViewVisitor.AddAccessibilityEventVisitor(
path,
AccessibilityEvent.TYPE_VIEW_CLICKED,
eventName,
listener
);
} else if ("selected".equals(eventType)) {
return new ViewVisitor.AddAccessibilityEventVisitor(
path,
AccessibilityEvent.TYPE_VIEW_SELECTED,
eventName,
listener
);
} else if ("text_changed".equals(eventType)) {
return new ViewVisitor.AddTextChangeListener(path, eventName, listener);
} else if ("detected".equals(eventType)) {
return new ViewVisitor.ViewDetectorVisitor(path, eventName, listener);
} else {
throw new BadInstructionsException("Mixpanel can't track event type \"" + eventType + "\"");
}
而匹配 view 的規則也很簡單,就是對比 class,id,contentDescription,tag 四個元素
private boolean matches(PathElement matchElement, View subject) {
if (null != matchElement.viewClassName &&
!hasClassName(subject, matchElement.viewClassName)) {
return false;
}
if (-1 != matchElement.viewId && subject.getId() != matchElement.viewId) {
return false;
}
if (null != matchElement.contentDescription &&
!matchElement.contentDescription.equals(subject.getContentDescription())) {
return false;
}
final String matchTag = matchElement.tag;
if (null != matchElement.tag) {
final Object subjectTag = subject.getTag();
if (null == subjectTag || !matchTag.equals(subject.getTag().toString())) {
return false;
}
}
return true;
}
這樣,在每次 app 的 activity 的 onresume 方法里,都會去做這個尋找匹配的 view 的過程,具體看 viewcrawler 的 onActivityResume 方法
@Override //ViewCrawler.class
public void onActivityResumed(Activity activity) {
installConnectionSensor(activity);
mEditState.add(activity);
}
//通過一系列處理,最終調用了visit方法
/**
* Scans the View hierarchy below rootView, applying it's operation to each matching child view.
*/
public void visit(View rootView) {
Log.d(LOGTAG, mPath.get(0).toString());
mPathfinder.findTargetsInRoot(rootView, mPath, this);
}
這種做法的效率暫且不停,到這里我們的流程就分析完了。大致的流程就是
App 端 上傳屏幕截圖和頁面布局信息-》服務端操作之后,下發需要埋點的 viewpath -》 app 端存儲這個 path,并在每個 activity 的 onResume 都去執行尋找 path 的任務-》注冊 Accessibility 監聽,上傳相應事件。
這樣就實現了可視化埋點,但是這種方式,應該是用于已經發布到線上的app 的埋點,而且不同版本不通用。。。因為 view 的 id 等信息是會隨著版本變化的。如果這里有錯誤,請大神指出。不勝感激,謝謝!!
來自:http://mp.weixin.qq.com/s/uzvzF2owmw_g5vP-Np7x5w