神策可视化埋点实现细节

埋点总体思路

普通界面中的打点,不包过dialog等通过windowManager直接add的view。

通过在Application中监听acitivty的生命周期,在resumed方法中,遍历Activity视图中所有的view,给View设置AccessibilityDelegate,而当View 产生了click、long_click 等事件的时候,会在响应原有的Listener方法后发送消息给AccessibilityDelegate,然后在sendAccessibilityEvent方法下做打点操作。

客户端如何搜集app界面数据?

源码:ViewSnapshot

1. 控件树的数据结构:json

rootView开始递归解析,每个view对应一个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()", // 返回此视图的向左滚动位置
    "scrollY": "view.getScrollY()",// 返回此视图的滚动顶部位置。 
    "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的下一级"
    ], 
    "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的方案,通过一个配置文件来定义收集哪些对象的哪些属性信息:config示例

layoutRules的详细解释

View 的几个重要属性:


android:background
关联方法: getBackground()、setBackground(ColorDrawable)、setBackgroundResource(int) 
属性说明: 视图背景

android:alpha
关联方法: getAlpha()、setAlpha(float) 
属性说明: 视图透明度,值在0-1之间。0为完全透明,1为完全不透明。

android:clickable
关联方法: isClickable()、setClickable(boolean) 
属性说明: 视图是否可点击

android:importantForAccessibility
关联方法: isImportantForAccessibility() 、setImportantForAccessibility(int) 
属性说明: 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
关联方法: getVisibility()、setVisibility(int) 
属性说明: "view的可见性。有3个取值: gone——不可见,同时不占用view的空间; invisible——不可见,但占用view的空间; visible——可见"

Android 中可以对点击事件和文本编辑事件埋点:

点击事件

继承于 android.view.View 的控件,且 .clickable() 属性为 true 的控件,点击后触发事件

文本编辑事件

继承于 android.widget.EditText 的控件,编辑完成后触发事件

用户在管理界面中选择控件进行埋点时,系统会自动判定需要埋点的事件类型。

2. 截屏数据结构:json

一个liveActivitie对应一个RootViewInfo实例:

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;
}

RootViewInfo实例进行处理后构造json数据:

{
    "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"
}

截屏数据结构示例

如何标识唯一控件?有些控件监测不到该如何解决?

(1) 如何表示View的path

树形结构每一个view节点用PathElement表示,每个view节点的绝对路径由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 中的内部类 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 ,以供后续使用。

(3) index的含义是什么?

index赋值规则:每个ViewGroup下的所有View先按照Class分类,再确认是否有Resource Id,如果存在,则index = 0,否则index = 该Class类型下的子view序号(从0开始编号)。

对应源码: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) 将当前child与兄弟节点brother逐一比较;

(2) 若brother是child的子类或同类型,则进行(3);否则,回到(1);

(3) 若当前child存在childIdName(即id)且与brother的brotherIdName不相同,则回到(1);否则,进行(4);

(4) 若当前child == brother,则查找成功,否则,index++,回到(1)

分析上述算法,可知index取值有如下规律:

(1) 当child存在id时,child与树形结构左侧brother的匹配都会失败,执行continue,期间不会执行到index++,直到成功匹配返回index = 0;

(2) 当child的id不存在时,遍历树形结构左侧brother,若brother是child的子类或同类型,则index++,直到匹配成功。可以认为index为左侧brothers中child的子类或同类型的数目。因此,兄弟节点的排列顺序也会影响index的取值。

注意:兄弟节点的排列顺序也会影响index的取值

web配置页面返回什么样的配置信息?

下发配置样例

app界面数据如何传输到web配置页面?

websocket

安卓端使用的WebSocketClient为Java-WebSocket

具体使用时,实现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配置页面如何解析app界面数据?

自己总结的一个思路:

深度优先遍历控件树,输出每个View的绝对位置和path、clickable,以及下列属性:

{
    "prefix": null,
    "view_class": "com.android.internal.policy.PhoneWindow.DecorView",
    "index": "-1",
    "id": "-1",
    "sa_id_name": null
}

点击事件发生时,获取点击位置坐标,然后遍历Activity界面中所有的View(控件也都是View),判断哪个View区域包含点击位置,从而判断哪个View被点击了。

为了缩小检索范围,可以只搜索clickable的View。

Application.ActivityLifecycleCallbacks的具体实现在哪里?

源码:ViewCrawler$LifecycleCallbacks

在ViewCrawler的构造函数中registerActivityLifecycleCallbacks:

public ViewCrawler(Context context, String resourcePackageName) {
        ...
        
        mLifecycleCallbacks = new LifecycleCallbacks();
        final Application app = (Application) context.getApplicationContext();
        app.registerActivityLifecycleCallbacks(mLifecycleCallbacks);

        ...
    }

实际实现见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从后台恢复
            SensorsDataAPI.sharedInstance(mContext).appBecomeActive();
        }

        for (String className : mDisabledActivity) {// 在忽略监测的Activities列表中检索当前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的具体实现在哪里?

源码:EditState$EditBinding

【如何应对界面动态布局】
为了应对页面的动态布局,我们需要在单一线程中实现事件监测,通过循环操作,使每个事件都对当前页面的所有view进行匹配。经过实测,也没有发现对应用交互有可感知的影响。

ViewTreeObserver.OnGlobalLayoutListener:当在一个视图树中全局布局发生改变或者视图树中的某个视图的可视状态发生改变时,所要调用的回调函数的接口类

private static class EditBinding implements ViewTreeObserver.OnGlobalLayoutListener, Runnable {
    public EditBinding(View viewRoot, ViewVisitor edit, Handler uiThreadHandler) {
        mEdit = edit;
        mViewRoot = new WeakReference(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配置页面的配置是如何得到执行的?即如何自动埋点?

原理:通过在Application中监听acitivty的生命周期,在resumed方法中,遍历Activity视图中所有的view,给View设置AccessibilityDelegate,而当View 产生了click、long_click 等事件的时候,会在响应原有的Listener方法后发送消息给AccessibilityDelegate,然后在sendAccessibilityEvent方法下做打点操作。

ViewCrawler$LifecycleCallbacks.onActivityResumed(activity) -> 
mEditState.add(activity) -> 
EditState.applyEditsOnActivity(activity) -> 
EditState.applyChangesFromList(activity,rootView,List changes)

核心语句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 changes) {
    synchronized (mCurrentEdits) {
        if (!mCurrentEdits.containsKey(activity)) {
            mCurrentEdits.put(activity, new HashSet());
        }

        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 是一个 Runnable 对象

/* 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(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);
    }
    ...
}

核心语句run()中的mEdit.visit(viewRoot);

public void visit(View rootView) {
    mPathfinder.findTargetsInRoot(rootView, mPath, this);
}

匹配到view后执行 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);
}

设置 View 的AccessibilityDelegateTrackingAccessibilityDelegate后,当 View 产生了click,long_click 等事件的时候,会在响应原有的Listener方法后发送消息给AccessibilityDelegate,然后在AccessibilityDelegate.sendAccessibilityEvent()方法下做打点操作

/**
* 点击事件监听器
*/
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);// 埋点操作
            }
    
            if (null != mRealDelegate) {
                mRealDelegate.sendAccessibilityEvent(host, eventType);
            }
        }
    
        private View.AccessibilityDelegate mRealDelegate;
    }
    
    ...

    
}

fireEvent实际调用了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);
        }

        // 对于Clicked事件,事件发生时即调用track记录事件;对于Edited事件,由于多次Edit时会触发多次Edited,
        // 所以我们增加一个计时器,延迟发送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);
            }
        }
    }

最终执行track,与代码打点殊途同归

SensorsDataAPI.sharedInstance(mContext).track(eventInfo.mEventName, properties);

Activity生命周期调用有版本要求

要求API 14+ (Android 4.0+)

你可能感兴趣的:(神策可视化埋点实现细节)