埋點(diǎn)總體思路
普通界面中的打點(diǎn)抱环,不包過(guò)dialog等通過(guò)windowManager直接add的view油宜。
通過(guò)在Application中監(jiān)聽(tīng)acitivty的生命周期,在resumed方法中,遍歷Activity視圖中所有的view宪睹,給View設(shè)置AccessibilityDelegate,而當(dāng)View 產(chǎn)生了click蚕钦、long_click 等事件的時(shí)候亭病,會(huì)在響應(yīng)原有的Listener方法后發(fā)送消息給AccessibilityDelegate,然后在sendAccessibilityEvent方法下做打點(diǎn)操作嘶居。
客戶端如何搜集app界面數(shù)據(jù)?
源碼:ViewSnapshot
1. 控件樹(shù)的數(shù)據(jù)結(jié)構(gòu):json
從rootView
開(kāi)始遞歸解析罪帖,每個(gè)view對(duì)應(yīng)一個(gè)json字符串,格式如下:
{
"hashCode": "view.hashCode()",
"id": "view.getId()",
"index": "getChildIndex(view.getParent(), view)",
"sa_id_name": "getResName(view)",
"top": "view.getTop()", // Top position of this view relative to its parent
"left": "view.getLeft()", // Left position of this view relative to its parent
"width": "view.getWidth()", // Return the width of the your view
"height": "view.getHeight()", // Return the height of your view
"scrollX": "view.getScrollX()", // 返回此視圖的向左滾動(dòng)位置
"scrollY": "view.getScrollY()",// 返回此視圖的滾動(dòng)頂部位置邮屁。
"visibility": "view.getVisibility()",
"translationX": "view.getTranslationX()", // The horizontal location of this view relative to its left position. view的偏移量
"translationY": "view.getTranslationY()", // The vertical location of this view relative to its top position
"classes": [
"view.getClass()",
"view.getSuperclass()",
"view.getSuperSuperclass()",
"...直到Object.class的下一級(jí)"
],
"importantForAccessibility": true,
"clickable": false,
"alpha": 1,
"hidden": 0,
"background": {
"classes": [
"android.graphics.drawable.ColorDrawable",
"android.graphics.drawable.Drawable"
],
"dimensions": {
"left": 0,
"right": 1200,
"top": 0,
"bottom": 1920
},
"color": -328966
}
"layoutRules": [], // RelativeLayout子view屬性
"subviews": [
"child1.hashCode()",
"child2.hashCode()",
"..."
]
}
不同類型的View需要搜集的屬性有所不同整袁,神策采用mixpanel的方案,通過(guò)一個(gè)配置文件來(lái)定義收集哪些對(duì)象的哪些屬性信息:config示例
View 的幾個(gè)重要屬性:
android:background
關(guān)聯(lián)方法: getBackground()佑吝、setBackground(ColorDrawable)坐昙、setBackgroundResource(int)
屬性說(shuō)明: 視圖背景
android:alpha
關(guān)聯(lián)方法: getAlpha()、setAlpha(float)
屬性說(shuō)明: 視圖透明度芋忿,值在0-1之間炸客。0為完全透明疾棵,1為完全不透明。
android:clickable
關(guān)聯(lián)方法: isClickable()痹仙、setClickable(boolean)
屬性說(shuō)明: 視圖是否可點(diǎn)擊
android:importantForAccessibility
關(guān)聯(lián)方法: isImportantForAccessibility() 是尔、setImportantForAccessibility(int)
屬性說(shuō)明: Describes whether or not this view is important for accessibility. If it is important, the view fires accessibility events and is reported to accessibility services that query the screen. Note: While not recommended, an accessibility service may decide to ignore this attribute and operate on all views in the view tree.
android:visibility
關(guān)聯(lián)方法: getVisibility()、setVisibility(int)
屬性說(shuō)明: "view的可見(jiàn)性蝶溶。有3個(gè)取值: gone——不可見(jiàn)嗜历,同時(shí)不占用view的空間; invisible——不可見(jiàn)抖所,但占用view的空間梨州; visible——可見(jiàn)"
Android 中可以對(duì)點(diǎn)擊事件和文本編輯事件埋點(diǎn):
點(diǎn)擊事件
繼承于 android.view.View 的控件,且 .clickable() 屬性為 true 的控件田轧,點(diǎn)擊后觸發(fā)事件
文本編輯事件
繼承于 android.widget.EditText 的控件暴匠,編輯完成后觸發(fā)事件
用戶在管理界面中選擇控件進(jìn)行埋點(diǎn)時(shí),系統(tǒng)會(huì)自動(dòng)判定需要埋點(diǎn)的事件類型傻粘。
2. 截屏數(shù)據(jù)結(jié)構(gòu):json
一個(gè)liveActivitie
對(duì)應(yīng)一個(gè)RootViewInfo
實(shí)例:
private static class RootViewInfo {
public RootViewInfo(String activityName, View rootView) {
this.activityName = activityName;
this.rootView = rootView;
this.screenshot = null;
this.scale = 1.0f;
}
public final String activityName;
public final View rootView;
public CachedBitmap screenshot;
public float scale;
}
對(duì)RootViewInfo
實(shí)例進(jìn)行處理后構(gòu)造json數(shù)據(jù):
{
"activity": "info.activityName",
"scale": "info.scale",
"serialized_objects": {
"rootObject": "info.rootView.hashCode()",
"objects": [
"view json 1",
"view json 2",
"..."
]
},
"image_hash": "info.screenshot.getImageHash",
"screenshot": "info.screenshot.writeBitmapJSON"
}
如何標(biāo)識(shí)唯一控件每窖?有些控件監(jiān)測(cè)不到該如何解決?
(1) 如何表示View的path
樹(shù)形結(jié)構(gòu)每一個(gè)view節(jié)點(diǎn)用PathElement表示弦悉,每個(gè)view節(jié)點(diǎn)的絕對(duì)路徑由List< Pathfinder.PathElement >表示
path示例:
"path": [
{
"prefix": null,
"view_class": "com.android.internal.policy.PhoneWindow.DecorView",
"index": "-1",
"id": "-1",
"sa_id_name": null
},
{
"prefix": "shortest",
"view_class": "com.android.internal.widget.ActionBarOverlayLayout",
"index": "0",
"id": "16909220",
"sa_id_name": null
},
{
"prefix": "shortest",
"view_class": "android.widget.FrameLayout",
"index": "0",
"id": "16908290",
"sa_id_name": "android: content"
},
{
"prefix": "shortest",
"view_class": "android.widget.LinearLayout",
"index": "0",
"id": "-1",
"sa_id_name": null
},
{
"prefix": "shortest",
"view_class": "android.widget.Button",
"index": "0",
"id": "2131558506",
"sa_id_name": "btn"
}
]
(2) 反射R文件得到View的id
ResourceReader 獲取 android.R.id.class 文件以及 mResourcePackageName.R.class 中的內(nèi)部類 id 中的所有static int 變量窒典,類似:
public static final class id {
...
public static final int btnAddAlarm=0x7f0d0055;
public static final int btnPause=0x7f0d005c;
public static final int btnReset=0x7f0d005e;
public static final int btnResume=0x7f0d005d;
...
}
將 static int 變量的變量名和值組織成 Map< String, Integer > mIdNameToId 和 SparseArray< String > mIdToIdName ,以供后續(xù)使用稽莉。
(3) index的含義是什么瀑志?
index賦值規(guī)則:每個(gè)ViewGroup下的所有View先按照Class分類,再確認(rèn)是否有Resource Id污秆,如果存在劈猪,則index = 0,否則index = 該Class類型下的子view序號(hào)(從0開(kāi)始編號(hào))良拼。
對(duì)應(yīng)源碼:ViewSnapshot
private int getChildIndex(ViewParent parent, View child) {
if (parent == null || !(parent instanceof ViewGroup)) {
return -1;
}
ViewGroup _parent = (ViewGroup) parent;
final String childIdName = getResName(child);
String childClassName = mClassnameCache.get(child.getClass());
int index = 0;
for (int i = 0; i < _parent.getChildCount(); i++) {
View brother = _parent.getChildAt(i);
if (!Pathfinder.hasClassName(brother, childClassName)) {
continue;
}
String brotherIdName = getResName(brother);
if (null != childIdName && !childIdName.equals(brotherIdName)) {
continue;
}
if (brother == child) {
return index;
}
index++;
}
return -1;
}
算法思路:(index初始值0)
(1) 將當(dāng)前child與兄弟節(jié)點(diǎn)brother逐一比較战得;
(2) 若brother是child的子類或同類型,則進(jìn)行(3)庸推;否則常侦,回到(1);
(3) 若當(dāng)前child存在childIdName(即id)且與brother的brotherIdName不相同贬媒,則回到(1)刮吧;否則,進(jìn)行(4)掖蛤;
(4) 若當(dāng)前child == brother杀捻,則查找成功,否則,index++致讥,回到(1)
分析上述算法仅仆,可知index取值有如下規(guī)律:
(1) 當(dāng)child存在id時(shí),child與樹(shù)形結(jié)構(gòu)左側(cè)brother的匹配都會(huì)失敗垢袱,執(zhí)行continue墓拜,期間不會(huì)執(zhí)行到index++,直到成功匹配返回index = 0;
(2) 當(dāng)child的id不存在時(shí)请契,遍歷樹(shù)形結(jié)構(gòu)左側(cè)brother咳榜,若brother是child的子類或同類型,則index++爽锥,直到匹配成功涌韩。可以認(rèn)為index為左側(cè)brothers中child的子類或同類型的數(shù)目氯夷。因此臣樱,兄弟節(jié)點(diǎn)的排列順序也會(huì)影響index的取值。
注意:兄弟節(jié)點(diǎn)的排列順序也會(huì)影響index的取值
web配置頁(yè)面返回什么樣的配置信息腮考?
app界面數(shù)據(jù)如何傳輸?shù)絯eb配置頁(yè)面雇毫?
websocket
安卓端使用的WebSocketClient為Java-WebSocket
具體使用時(shí),實(shí)現(xiàn)WebSocketClient
即可
/**
* EditorClient should handle all communication to and from the socket. It should be fairly naive and
* only know how to delegate messages to the ViewCrawlerHandler class.
*/
private class EditorClient extends WebSocketClient {
public EditorClient(URI uri, int connectTimeout) throws InterruptedException {
super(uri, new Draft_17(), null, connectTimeout);
}
@Override
public void onOpen(ServerHandshake handshakedata) {
if (SensorsDataAPI.ENABLE_LOG) {
Log.d(LOGTAG, "Websocket connected: " + handshakedata.getHttpStatus() + " " + handshakedata
.getHttpStatusMessage());
}
mService.onWebSocketOpen();
}
@Override
public void onMessage(String message) {
// Log.d(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(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);
}
}
@Override
public void onClose(int code, String reason, boolean remote) {
if (SensorsDataAPI.ENABLE_LOG) {
Log.d(LOGTAG, "WebSocket closed. Code: " + code + ", reason: " + reason + "\nURI: " + mURI);
}
mService.cleanup();
mService.onWebSocketClose(code);
}
@Override
public void onError(Exception ex) {
if (ex != null && ex.getMessage() != null) {
Log.e(LOGTAG, "Websocket Error: " + ex.getMessage());
} else {
Log.e(LOGTAG, "Unknown websocket error occurred");
}
}
}
web配置頁(yè)面如何解析app界面數(shù)據(jù)踩蔚?
自己總結(jié)的一個(gè)思路:
深度優(yōu)先遍歷控件樹(shù)棚放,輸出每個(gè)View的絕對(duì)位置和path、clickable馅闽,以及下列屬性:
{
"prefix": null,
"view_class": "com.android.internal.policy.PhoneWindow.DecorView",
"index": "-1",
"id": "-1",
"sa_id_name": null
}
點(diǎn)擊事件發(fā)生時(shí)飘蚯,獲取點(diǎn)擊位置坐標(biāo),然后遍歷Activity界面中所有的View(控件也都是View)捞蛋,判斷哪個(gè)View區(qū)域包含點(diǎn)擊位置,從而判斷哪個(gè)View被點(diǎn)擊了柬姚。
為了縮小檢索范圍拟杉,可以只搜索clickable的View。
Application.ActivityLifecycleCallbacks的具體實(shí)現(xiàn)在哪里量承?
源碼:ViewCrawler$LifecycleCallbacks
在ViewCrawler的構(gòu)造函數(shù)中registerActivityLifecycleCallbacks:
public ViewCrawler(Context context, String resourcePackageName) {
...
mLifecycleCallbacks = new LifecycleCallbacks();
final Application app = (Application) context.getApplicationContext();
app.registerActivityLifecycleCallbacks(mLifecycleCallbacks);
...
}
實(shí)際實(shí)現(xiàn)見(jiàn)LifecycleCallbacks:
private class LifecycleCallbacks
implements Application.ActivityLifecycleCallbacks {
public LifecycleCallbacks() {
}
void enableConnector() {
mEnableConnector = true;
mEmulatorConnector.start();
}
void disableConnector() {
mEnableConnector = false;
mEmulatorConnector.stop();
}
@Override
public void onActivityCreated(Activity activity, Bundle bundle) {
}
@Override
public void onActivityStarted(Activity activity) {
}
@Override
public void onActivityResumed(Activity activity) {
if (mEnableConnector) {
mEmulatorConnector.start();
}
mStartedActivities.add(activity);
if (mStartedActivities.size() == 1) {// app從后臺(tái)恢復(fù)
SensorsDataAPI.sharedInstance(mContext).appBecomeActive();
}
for (String className : mDisabledActivity) {// 在忽略監(jiān)測(cè)的Activities列表中檢索當(dāng)前activity
if (className.equals(activity.getClass().getCanonicalName())) {
return;
}
}
mEditState.add(activity);
}
@Override
public void onActivityPaused(Activity activity) {
mStartedActivities.remove(activity);
mEditState.remove(activity);
if (mEditState.isEmpty()) {
mEmulatorConnector.stop();
}
}
@Override
public void onActivityStopped(Activity activity) {
if (mStartedActivities.size() == 0) {
SensorsDataAPI.sharedInstance(mContext).appEnterBackground();
}
}
@Override
public void onActivitySaveInstanceState(Activity activity, Bundle bundle) {
}
@Override
public void onActivityDestroyed(Activity activity) {
}
private final EmulatorConnector mEmulatorConnector = new EmulatorConnector();
private boolean mEnableConnector = false;
}
ViewTreeObserver.OnGlobalLayoutListener的具體實(shí)現(xiàn)在哪里搬设?
源碼:EditState$EditBinding
【如何應(yīng)對(duì)界面動(dòng)態(tài)布局】
為了應(yīng)對(duì)頁(yè)面的動(dòng)態(tài)布局,我們需要在單一線程中實(shí)現(xiàn)事件監(jiān)測(cè)撕捍,通過(guò)循環(huán)操作拿穴,使每個(gè)事件都對(duì)當(dāng)前頁(yè)面的所有view進(jìn)行匹配。經(jīng)過(guò)實(shí)測(cè)忧风,也沒(méi)有發(fā)現(xiàn)對(duì)應(yīng)用交互有可感知的影響默色。
ViewTreeObserver.OnGlobalLayoutListener:當(dāng)在一個(gè)視圖樹(shù)中全局布局發(fā)生改變或者視圖樹(shù)中的某個(gè)視圖的可視狀態(tài)發(fā)生改變時(shí),所要調(diào)用的回調(diào)函數(shù)的接口類
private static class EditBinding implements ViewTreeObserver.OnGlobalLayoutListener, Runnable {
public EditBinding(View viewRoot, ViewVisitor edit, Handler uiThreadHandler) {
mEdit = edit;
mViewRoot = new WeakReference<View>(viewRoot);
mHandler = uiThreadHandler;
mAlive = true;
mDying = false;
final ViewTreeObserver observer = viewRoot.getViewTreeObserver();
if (observer.isAlive()) {
observer.addOnGlobalLayoutListener(this);
}
run();
}
@Override
public void onGlobalLayout() {
run();
}
@Override
public void run() {
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);
}
...
}
Web配置頁(yè)面的配置是如何得到執(zhí)行的狮腿?即如何自動(dòng)埋點(diǎn)腿宰?
原理:通過(guò)在Application中監(jiān)聽(tīng)acitivty的生命周期呕诉,在resumed方法中,遍歷Activity視圖中所有的view吃度,給View設(shè)置AccessibilityDelegate甩挫,而當(dāng)View 產(chǎn)生了click、long_click 等事件的時(shí)候椿每,會(huì)在響應(yīng)原有的Listener方法后發(fā)送消息給AccessibilityDelegate伊者,然后在sendAccessibilityEvent方法下做打點(diǎn)操作。
ViewCrawler$LifecycleCallbacks.onActivityResumed(activity) ->
mEditState.add(activity) ->
EditState.applyEditsOnActivity(activity) ->
EditState.applyChangesFromList(activity,rootView,List<ViewVisitor> changes)
核心語(yǔ)句final EditBinding binding = new EditBinding(rootView, visitor, mUiThreadHandler)
// Must be called on UI Thread
private void applyChangesFromList(final Activity activity, final View rootView,
final List<ViewVisitor> changes) {
synchronized (mCurrentEdits) {
if (!mCurrentEdits.containsKey(activity)) {
mCurrentEdits.put(activity, new HashSet<EditBinding>());
}
final int size = changes.size();
for (int i = 0; i < size; i++) {
final ViewVisitor visitor = changes.get(i);
final EditBinding binding = new EditBinding(rootView, visitor, mUiThreadHandler);
mCurrentEdits.get(activity).add(binding);
}
}
}
值得注意的是 EditBinding 是一個(gè) Runnable 對(duì)象
/* The binding between a bunch of edits and a view. Should be instantiated and live on the UI thread */
private static class EditBinding implements ViewTreeObserver.OnGlobalLayoutListener, Runnable {
public EditBinding(View viewRoot, ViewVisitor edit, Handler uiThreadHandler) {
mEdit = edit;
mViewRoot = new WeakReference<View>(viewRoot);
mHandler = uiThreadHandler;
mAlive = true;
mDying = false;
final ViewTreeObserver observer = viewRoot.getViewTreeObserver();
if (observer.isAlive()) {
observer.addOnGlobalLayoutListener(this);
}
run();
}
@Override
public void onGlobalLayout() {
run();
}
@Override
public void run() {
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);
}
...
}
核心語(yǔ)句run()中的mEdit.visit(viewRoot);
public void visit(View rootView) {
mPathfinder.findTargetsInRoot(rootView, mPath, this);
}
匹配到view后執(zhí)行 ViewVisitor.accumulate(viewfound)
@Override
public void accumulate(View found) {
...
// 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);
}
設(shè)置 View 的AccessibilityDelegate
為TrackingAccessibilityDelegate
后间护,當(dāng) View 產(chǎn)生了click,long_click 等事件的時(shí)候亦渗,會(huì)在響應(yīng)原有的Listener方法后發(fā)送消息給AccessibilityDelegate,然后在AccessibilityDelegate.sendAccessibilityEvent()方法下做打點(diǎn)操作
/**
* 點(diǎn)擊事件監(jiān)聽(tīng)器
*/
public static class AddAccessibilityEventVisitor extends EventTriggeringVisitor {
private class TrackingAccessibilityDelegate extends View.AccessibilityDelegate {
public TrackingAccessibilityDelegate(View.AccessibilityDelegate realDelegate) {
mRealDelegate = realDelegate;
}
...
@Override
public void sendAccessibilityEvent(View host, int eventType) {
if (eventType == mEventType) {
fireEvent(host);// 埋點(diǎn)操作
}
if (null != mRealDelegate) {
mRealDelegate.sendAccessibilityEvent(host, eventType);
}
}
private View.AccessibilityDelegate mRealDelegate;
}
...
}
fireEvent實(shí)際調(diào)用了DynamicEventTracker.OnEvent
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);
}
// 對(duì)于Clicked事件兑牡,事件發(fā)生時(shí)即調(diào)用track記錄事件央碟;對(duì)于Edited事件,由于多次Edit時(shí)會(huì)觸發(fā)多次Edited均函,
// 所以我們?cè)黾右粋€(gè)計(jì)時(shí)器亿虽,延遲發(fā)送Edited事件
if (debounce) {
final Signature eventSignature = new Signature(v, eventInfo);
final UnsentEvent event = new UnsentEvent(eventInfo, properties, moment);
// No scheduling mTask without holding a lock on mDebouncedEvents,
// so that we don't have a rogue thread spinning away when no events
// are coming in.
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);
}
}
}
最終執(zhí)行track,與代碼打點(diǎn)殊途同歸
SensorsDataAPI.sharedInstance(mContext).track(eventInfo.mEventName, properties);
Activity生命周期調(diào)用有版本要求
要求API 14+ (Android 4.0+)