当前位置: 首页 > article >正文

android_viewtracker 原理

一、说明

我们业务中大部分场景是用 RecyclerView 实现的列表,而 View 的曝光通常是直接写在 adapter 的 onBindViewHolder 中,这样就会导致 item 还没显示出来的时候就会触发曝光。最近业务提出需要实现根据 View 显示在屏幕上面积大于 80% 才算曝光。网上搜索后看到阿里天猫使用了这个库 android_viewtracker,也大概看了一下其实现原理,在此记录一下。

仓库链接:android_viewtracker

二、原理

初始化

private void initTracker() {
        /**
         * SDK的初始化
         *
         * @param mContext              全局的application
         * @param mTrackerOpen          是否开启无痕点击埋点
         * @param mTrackerExposureOpen  是否开启无痕曝光埋点
         * @param printLog              是否输出调试log
         */
        TrackerManager.getInstance().init(mApplication, true, true, true);
        TrackerManager.getInstance().setCommit(new IDataCommit() {

            @Override
            public void commitClickEvent(HashMap<String, Object> commonInfo, String viewName, HashMap<String, Object> viewData) {
                Log.e("aa", ">>>>>>>>>>>>> commitExposureEvent " + viewName);
            }

            @Override
            public void commitExposureEvent(HashMap<String, Object> commonInfo, String viewName, HashMap<String, Object> viewData, long exposureData, HashMap<String, Object> exposureIndex) {
                Log.e("aa", ">>>>>>>>>>>>> commitExposureEvent " + viewName + " viewData = " + viewData + " exposureData = " + exposureData + " exposureIndex = " + exposureIndex);
            }
        });
        JSONObject exposureConfig = new JSONObject();
        try {
            exposureConfig.put("timeThreshold", 500); // 曝光时间阈值
            exposureConfig.put("dimThreshold", 0.8); // 曝光比例
            exposureConfig.put("masterSwitch", true); // 是否打开无痕点击事件上报
            exposureConfig.put("batchOpen", false); // 是否打开批量上报,即页面离开时,所有view上报一次曝光总时长
            exposureConfig.put("exposureSampling", 100); // 曝光采样率
        } catch (Exception e) {
            // 如果发生put操作异常,走默认值
        }
        Intent intent = new Intent(ConfigReceiver.ACTION_CONFIG_CHANGED);
        intent.putExtra(ConfigReceiver.VIEWTRACKER_EXPOSURE_CONFIG_KEY, exposureConfig.toString());
        mApplication.sendBroadcast(intent);
    }

走进初始化代码

    /**
     * initiate viewtracker SDK
     *
     * @param application         global application context
     * @param trackerOpen         whether or not track click event
     * @param trackerExposureOpen whether or not track exposure event
     * @param logOpen             whether or not print the log
     */
    public void init(Application application, boolean trackerOpen, boolean trackerExposureOpen, boolean logOpen) {
        GlobalsContext.mApplication = application;
        GlobalsContext.trackerOpen = trackerOpen;
        GlobalsContext.trackerExposureOpen = trackerExposureOpen;
        GlobalsContext.logOpen = logOpen;

        if (GlobalsContext.trackerOpen || GlobalsContext.trackerExposureOpen) {
            mActivityLifecycle = new ActivityLifecycleForTracker();
            application.registerActivityLifecycleCallbacks(mActivityLifecycle);
        }
    }

这儿做了 2 件事:

  1. 把一些配置信息传入给 SDK 保存起来。
  2. 注册了 Activity 生命周期监听,并由 ActivityLifecycleForTracker 管理。

生命周期监听

private class ActivityLifecycleForTracker implements Application.ActivityLifecycleCallbacks {
        @Override
        public void onActivityCreated(Activity activity, Bundle bundle) {

        }

        @Override
        public void onActivityStarted(Activity activity) {

        }

        @Override
        public void onActivityResumed(Activity activity) {
            TrackerLog.d("onActivityResumed activity " + activity.toString());
            attachTrackerFrameLayout(activity);
        }

        @Override
        public void onActivityPaused(Activity activity) {
            if (GlobalsContext.trackerExposureOpen) {
                TrackerLog.d("onActivityPaused activity " + activity.toString());
                if (GlobalsContext.batchOpen) {
                    batchReport();
                }
            }
        }

        @Override
        public void onActivityStopped(Activity activity) {

        }

        @Override
        public void onActivityDestroyed(Activity activity) {
            TrackerLog.d("onActivityDestroyed activity " + activity.toString());
            detachTrackerFrameLayout(activity);
        }

        @Override
        public void onActivitySaveInstanceState(Activity activity, Bundle bundle) {

        }
    }

这儿分别监听了 Activity 的 3 个生命周期,并做了一些事,根据方法名称我们猜测:

  1. onResume:关联 TrackerFrameLayout。
  2. onPause:批量上报。
  3. onDestroy:分离 TrackerFrameLayout。

关联 TrackerFrameLayout

public void attachTrackerFrameLayout(Activity activity) {
        // this is a problem: several activity exist in the TabActivity
        if (activity == null || activity instanceof TabActivity) {
            return;
        }


        // exist android.R.id.content not found crash
        try {
            ViewGroup container = (ViewGroup) activity.findViewById(android.R.id.content);

            if (container == null) {
                return;
            }

            if (container.getChildCount() > 0) {
                View root = container.getChildAt(0);
                if (root instanceof TrackerFrameLayout) {
                    TrackerLog.d("no attachTrackerFrameLayout " + activity.toString());
                } else {
                    TrackerFrameLayout trackerFrameLayout = new TrackerFrameLayout(activity);

                    while (container.getChildCount() > 0) {
                        View view = container.getChildAt(0);
                        container.removeViewAt(0);
                        trackerFrameLayout.addView(view, view.getLayoutParams());
                    }

                    container.addView(trackerFrameLayout, new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
                }
            }
        } catch (Exception e) {
            TrackerLog.e(e.toString());
        }
    }

这儿做了几个事:

  1. 创建 TrackerFrameLayout。
  2. 将 Activity 下 id 为 android.R.id.content 的 View 中的子 View 移除。
  3. 将所有移除的子 View 添加到 TrackerFrameLayout。

总得来说就是中间插了一个 TrackerFrameLayout。

批量上报

    /**
     * commit the data for exposure event in batch
     */
    private void batchReport() {
        long time = System.currentTimeMillis();

        Handler handler = ExposureManager.getInstance().getExposureHandler();
        Message message = handler.obtainMessage();
        message.what = ExposureManager.BATCH_COMMIT_EXPOSURE;
        handler.sendMessage(message);

        TrackerLog.v("batch report exposure views " + (System.currentTimeMillis() - time) + "ms");
    }
private ExposureManager() {
        HandlerThread exposureThread = new HandlerThread("ViewTracker_exposure");
        exposureThread.start();

        exposureHandler = new Handler(exposureThread.getLooper(), new Handler.Callback() {
            @Override
            public boolean handleMessage(Message msg) {
                switch (msg.what) {
                    ...

                    case BATCH_COMMIT_EXPOSURE:
                        for (CommitLog commitLog : commitLogs.values()) {
                            // the exposure times inside page
                            commitLog.argsInfo.put("exposureTimes", String.valueOf(commitLog.exposureTimes));
                            // Scene 3 (switch back and forth when press Home button) is excluded.
                            TrackerUtil.commitExtendEvent(commitLog.pageName, 2201, commitLog.viewName, null, String.valueOf(commitLog.totalDuration), commitLog.argsInfo);
                            TrackerLog.v("onActivityPaused batch commit " + "pageName=" + commitLog.pageName + ",viewName=" + commitLog.viewName
                                    + ",totalDuration=" + commitLog.totalDuration + ",args=" + commitLog.argsInfo.toString());
                        }

                        // clear after committed.
                        commitLogs.clear();
                        break;
                    default:
                        break;
                }
                return false;
            }
        });
    }

可以看到这儿只是对之前积累的数据做了一次批量上报。

分离 TrackerFrameLayout

private void detachTrackerFrameLayout(Activity activity) {
        if (activity == null || activity instanceof TabActivity) {
            return;
        }

        try {
            ViewGroup container = (ViewGroup) activity.findViewById(android.R.id.content);

            if (container == null) {
                return;
            }

            if (container.getChildAt(0) instanceof TrackerFrameLayout) {
                container.removeViewAt(0);
            }
        } catch (Exception e) {
            TrackerLog.e(e.toString());
        }

    }

这个比较简单,就是将 TrackerFrameLayout 给移除掉。

TrackerFrameLayout 解析

TrackerFrameLayout 虽然代码量不大,但却是最核心的一个类,所有的监听都是通过它来的,我们着重看一下这块儿。

/**
 * the parent layout of content view inside Activity
 * Created by mengliu on 16/6/14.
 */
public class TrackerFrameLayout extends FrameLayout implements GestureDetector.OnGestureListener {

    /**
     * Custom threshold is used to determine whether it is a click event,
     * When the user moves more than 20 pixels in screen, it is considered as the scrolling event instead of a click.
     */
    private static final float CLICK_LIMIT = 20;

    /**
     * the X Position
     */
    private float mOriX;

    /**
     * the Y Position
     */
    private float mOriY;

    private GestureDetector mGestureDetector;

    private ReuseLayoutHook mReuseLayoutHook;

    /**
     * common info attached with the view inside page
     */
    public HashMap<String, Object> commonInfo = new HashMap<String, Object>();

    /**
     * all the visible views inside page, key is viewName
     */
    private Map<String, ExposureModel> lastVisibleViewMap = new ArrayMap<String, ExposureModel>();

    private long lastOnLayoutSystemTimeMillis = 0;

    private int focusChangeCount = 0;

    public TrackerFrameLayout(Context context) {
        super(context);
        this.mGestureDetector = new GestureDetector(context, this);
        this.mReuseLayoutHook = new ReuseLayoutHook(this, commonInfo);
        // after the onActivityResumed
        CommonHelper.addCommonArgsInfo(this);
    }

    public TrackerFrameLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        mGestureDetector.onTouchEvent(ev);

        if (getContext() != null && getContext() instanceof Activity) {
            // trigger the click event
            ClickManager.getInstance().eventAspect((Activity) getContext(), ev, commonInfo);
        }

        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                mOriX = ev.getX();
                mOriY = ev.getY();
                break;
            case MotionEvent.ACTION_MOVE:
                if ((Math.abs(ev.getX() - mOriX) > CLICK_LIMIT) || (Math.abs(ev.getY() - mOriY) > CLICK_LIMIT)) {
                    // Scene 1: Scroll beginning
                    long time = System.currentTimeMillis();
                    TrackerLog.v("dispatchTouchEvent triggerViewCalculate begin ");
                    ExposureManager.getInstance().triggerViewCalculate(TrackerInternalConstants.TRIGGER_VIEW_CHANGED, this, commonInfo, lastVisibleViewMap);
                    TrackerLog.v("dispatchTouchEvent triggerViewCalculate end costTime=" + (System.currentTimeMillis() - time));
                } else {
                    TrackerLog.d("dispatchTouchEvent ACTION_MOVE but not in click limit");
                }
                break;
            case MotionEvent.ACTION_UP:
                break;
        }

        return super.dispatchTouchEvent(ev);
    }

    public Map<String, ExposureModel> getLastVisibleViewMap() {
        return lastVisibleViewMap;
    }

    /**
     * all the state change of view trigger the exposure event
     *
     * @param changed
     * @param left
     * @param top
     * @param right
     * @param bottom
     */
    @Override
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        TrackerLog.v("onLayout traverseViewTree begin");
        // duplicate message in 1s
        long time = System.currentTimeMillis();
        if (time - lastOnLayoutSystemTimeMillis > 1000) {
            lastOnLayoutSystemTimeMillis = time;

            CommonHelper.addCommonArgsInfo(this);
            TrackerLog.v("onLayout addCommonArgsInfo");
            ExposureManager.getInstance().traverseViewTree(this, mReuseLayoutHook);
        }
//        ExposureManager.getInstance().triggerViewCalculate(TrackerInternalConstants.TRIGGER_VIEW_CHANGED, this, commonInfo, lastVisibleViewMap);
        TrackerLog.v("onLayout traverseViewTree end costTime=" + (System.currentTimeMillis() - time));
        super.onLayout(changed, left, top, right, bottom);
    }

    @Override
    public boolean onDown(MotionEvent motionEvent) {
        TrackerLog.v("onDown");
        return false;
    }

    @Override
    public void onShowPress(MotionEvent motionEvent) {
        TrackerLog.v("onShowPress");
    }

    @Override
    public boolean onSingleTapUp(MotionEvent motionEvent) {
        TrackerLog.v("onSingleTapUp");
        return false;
    }

    @Override
    public void onLongPress(MotionEvent motionEvent) {
        TrackerLog.v("onLongPress");
    }

    @Override
    public boolean onScroll(MotionEvent motionEvent, MotionEvent motionEvent1, float v, float v1) {
        return false;
    }

    /**
     * Scene 2: Scroll ending
     *
     * @param motionEvent
     * @param motionEvent1
     * @param v
     * @param v1
     * @return
     */
    @Override
    public boolean onFling(MotionEvent motionEvent, MotionEvent motionEvent1, float v, float v1) {
        long time = System.currentTimeMillis();
        TrackerLog.v("onFling triggerViewCalculate begin");
        this.postDelayed(new Runnable() {
            @Override
            public void run() {
                ExposureManager.getInstance().triggerViewCalculate(TrackerInternalConstants.TRIGGER_VIEW_CHANGED, TrackerFrameLayout.this, commonInfo, lastVisibleViewMap);
            }
        }, 1000);
        TrackerLog.v("onFling triggerViewCalculate end costTime=" + (System.currentTimeMillis() - time));
        return false;
    }

    /**
     * the state change of window trigger the exposure event.
     * Scene 3: switch back and forth when press Home button.
     * Scene 4: enter into the next page
     * Scene 5: window replace
     *
     * @param hasFocus
     */
    @Override
    public void dispatchWindowFocusChanged(boolean hasFocus) {
        TrackerLog.v("dispatchWindowFocusChanged triggerViewCalculate begin");
        long ts = System.currentTimeMillis();
//        if (hasFocus && focusChangeCount > 0) {
//            Clog.d("setupExposeInit_window_focus_change::" + focusChangeCount);
//            ExposureManager.getInstance().setupExpose(this);
//        }
//        focusChangeCount++;
        TrackerLog.v("dispatchWindowFocusChanged triggerViewCalculate end costTime=" + (System.currentTimeMillis() - ts));
        super.dispatchWindowFocusChanged(hasFocus);
    }

    @Override
    protected void dispatchVisibilityChanged(View changedView, int visibility) {
        // Scene 6: switch page in the TabActivity
        if (visibility == View.GONE) {
            TrackerLog.v("dispatchVisibilityChanged triggerViewCalculate begin");
            long ts = System.currentTimeMillis();
            ExposureManager.getInstance().triggerViewCalculate(TrackerInternalConstants.TRIGGER_WINDOW_CHANGED, this, commonInfo, lastVisibleViewMap);
            TrackerLog.v("dispatchVisibilityChanged triggerViewCalculate end costTime=" + (System.currentTimeMillis() - ts));
        } else {
            TrackerLog.v("trigger dispatchVisibilityChanged, visibility =" + visibility);
        }
        super.dispatchVisibilityChanged(changedView, visibility);
    }

    /**
     * 主动触发一次曝光检测
     * */
    public void manualTriggerCalculate() {
        ExposureManager.getInstance().triggerViewCalculate(TrackerInternalConstants.TRIGGER_VIEW_CHANGED, this, commonInfo, lastVisibleViewMap);
    }
}

我们接着拆开来看一下:

public class TrackerFrameLayout extends FrameLayout implements GestureDetector.OnGestureListener {    
    private GestureDetector mGestureDetector;   

    public TrackerFrameLayout(Context context) {
        super(context);
        this.mGestureDetector = new GestureDetector(context, this);
        this.mReuseLayoutHook = new ReuseLayoutHook(this, commonInfo);
        // after the onActivityResumed
        CommonHelper.addCommonArgsInfo(this);
    } 
    
    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        mGestureDetector.onTouchEvent(ev);

        if (getContext() != null && getContext() instanceof Activity) {
            // trigger the click event
            ClickManager.getInstance().eventAspect((Activity) getContext(), ev, commonInfo);
        }

        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                mOriX = ev.getX();
                mOriY = ev.getY();
                break;
            case MotionEvent.ACTION_MOVE:
                if ((Math.abs(ev.getX() - mOriX) > CLICK_LIMIT) || (Math.abs(ev.getY() - mOriY) > CLICK_LIMIT)) {
                    // Scene 1: Scroll beginning
                    long time = System.currentTimeMillis();
                    TrackerLog.v("dispatchTouchEvent triggerViewCalculate begin ");
                    ExposureManager.getInstance().triggerViewCalculate(TrackerInternalConstants.TRIGGER_VIEW_CHANGED, this, commonInfo, lastVisibleViewMap);
                    TrackerLog.v("dispatchTouchEvent triggerViewCalculate end costTime=" + (System.currentTimeMillis() - time));
                } else {
                    TrackerLog.d("dispatchTouchEvent ACTION_MOVE but not in click limit");
                }
                break;
            case MotionEvent.ACTION_UP:
                break;
        }

        return super.dispatchTouchEvent(ev);
    }
}

触摸事件在到达你的 View 之前,都会先经过 TrackerFrameLayout 分发,在这儿监听并处理了手势事件,它通过手势识别器分析了手势类型,并将手势传回给 TrackerFrameLayout 进行处理。

我们先接着往下看这行代码做了什么:

ClickManager.getInstance().eventAspect((Activity) getContext(), ev, commonInfo);

    /**
     * find the clicked view, register the View.AccessibilityDelegate, commit data when trigger the click event.
     *
     * @param activity
     * @param event
     */
    public void eventAspect(Activity activity, MotionEvent event, HashMap<String, Object> commonInfo) {
        GlobalsContext.start = System.currentTimeMillis();
        if (!GlobalsContext.trackerOpen) {
            return;
        }
        if (activity == null) {
            return;
        }
        // sample not hit
        if (isSampleHit == null) {
            isSampleHit = CommonHelper.isSamplingHit(GlobalsContext.sampling);
        }
        if (!isSampleHit) {
            TrackerLog.d("click isSampleHit is false");
            return;
        }
        try {
            if (event.getAction() == MotionEvent.ACTION_DOWN) {
                handleViewClick(activity, event, commonInfo);
            }
        } catch (Throwable th) {
            TrackerLog.e(th.getMessage());
        }

    }

这儿做了 2 个事情:

  1. 判断采样率:通过随机数操作采样率,如果超出采样率则不进行处理。
  2. handleViewClick:处理点击事件。

接着往下看:

private void handleViewClick(Activity activity, MotionEvent event, HashMap<String, Object> commonInfo) {
        View view = activity.getWindow().getDecorView();
        View tagView = null;
        View clickView = getClickView(view, event, tagView);
        if (clickView != null) {
            if (mDelegate != null) {
                mDelegate.setCommonInfo(commonInfo);
            }
            clickView.setAccessibilityDelegate(mDelegate);
        }
    }

getClickView 是找到点击的 View。

setAccessibilityDelegate 是给 View 设置了代理,这样可以监听到 View 的事件,并做出一些处理。

public class ViewDelegate extends View.AccessibilityDelegate {
    private HashMap<String, Object> commonInfo = new HashMap<String, Object>();

    public void setCommonInfo(HashMap<String, Object> commonInfo) {
        this.commonInfo = commonInfo;
    }

    public void sendAccessibilityEvent(View clickView, int eventType) {
        TrackerLog.d("eventType: " + eventType);
        if (eventType == AccessibilityEvent.TYPE_VIEW_CLICKED) {
            TrackerLog.d("click: " + clickView);
            DataProcess.processClickParams(commonInfo, clickView);
        }
        super.sendAccessibilityEvent(clickView, eventType);
    }
}

我们可以看到这儿是监听了 View 的点击事件,并做了点击上报。

这儿巧妙的利用了 setAccessibilityDelegate 实现了 View 的点击监听。

我们再回到 dispatchTouchEvent 方法中接着往下看。

case MotionEvent.ACTION_MOVE:
    if ((Math.abs(ev.getX() - mOriX) > CLICK_LIMIT) || (Math.abs(ev.getY() - mOriY) > CLICK_LIMIT)) {
        // Scene 1: Scroll beginning
        long time = System.currentTimeMillis();
        TrackerLog.v("dispatchTouchEvent triggerViewCalculate begin ");
        ExposureManager.getInstance().triggerViewCalculate(TrackerInternalConstants.TRIGGER_VIEW_CHANGED, this, commonInfo, lastVisibleViewMap);
        TrackerLog.v("dispatchTouchEvent triggerViewCalculate end costTime=" + (System.currentTimeMillis() - time));
    } else {
        TrackerLog.d("dispatchTouchEvent ACTION_MOVE but not in click limit");
    }
    break;

我们接着进入到 triggerViewCalculate 方法:

    /**
     * for the exposure event
     *
     * @param view
     * @return
     */
    public void triggerViewCalculate(int triggerType, View view, HashMap<String, Object> commonInfo,
                                     Map<String, ExposureModel> lastVisibleViewMap) {
        ...

        Map<String, ExposureModel> currentVisibleViewMap = new ArrayMap<String, ExposureModel>();
        traverseViewTree(view, lastVisibleViewMap, currentVisibleViewMap);
        commitExposure(triggerType, commonInfo, lastVisibleViewMap, currentVisibleViewMap);
        TrackerLog.d("triggerViewCalculate");
    }

再进入到 traverseViewTress 方法:

    /**
     * find all the view that can be seen in screen.
     *
     * @param view
     */
    private void traverseViewTree(View view, Map<String, ExposureModel> lastVisibleViewMap,
                                  Map<String, ExposureModel> currentVisibleViewMap) {
        if (CommonHelper.isViewHasTag(view)) {
            wrapExposureCurrentView(view, lastVisibleViewMap, currentVisibleViewMap);
        }

        if (view instanceof ViewGroup) {
            ViewGroup group = (ViewGroup) view;
            int childCount = group.getChildCount();
            for (int i = 0; i < childCount; i++) {
                traverseViewTree(group.getChildAt(i), lastVisibleViewMap, currentVisibleViewMap);
            }
        }
    }

这儿主要是遍历所有子 view,找到是否有指定 tag 的 view,如果有的话代表这个 view 可能需要进行曝光埋点。

接着再进入到这个方法 wrapExposureCurrentView:

    private void wrapExposureCurrentView(View view, Map<String, ExposureModel> lastVisibleViewMap,
                                         Map<String, ExposureModel> currentVisibleViewMap) {
        String viewTag = (String) view.getTag(TrackerConstants.VIEW_TAG_UNIQUE_NAME);
        HashMap<String, Object> params = (HashMap<String, Object>) view.getTag(TrackerConstants.VIEW_TAG_PARAM);

        boolean isWindowChange = view.hasWindowFocus();
        boolean exposureValid = checkExposureViewDimension(view);
        boolean needExposureProcess = isWindowChange && exposureValid;
        if (!needExposureProcess) {
            return;
        }

        // only add the visible view in screen
        if (lastVisibleViewMap.containsKey(viewTag)) {
            ExposureModel model = lastVisibleViewMap.get(viewTag);
            model.params = params;
            currentVisibleViewMap.put(viewTag, model);
        } else if (!currentVisibleViewMap.containsKey(viewTag)) {
            ExposureModel model = new ExposureModel();
            model.beginTime = System.currentTimeMillis();
            model.tag = viewTag;
            model.params = params;
            currentVisibleViewMap.put(viewTag, model);
        }
    }

这儿首先判断了 view 是否有 view 焦点,接着判断了曝光比例,同时符合这两个条件才会进行曝光处理,否则不处理。

曝光处理逻辑:

  • lastVisibleViewMap 包含指定 tag 的话将曝光数据放入 currentVisibleViewMap。
  • 如果 lastVisibleViewMap 和 currentVisibleViewMap 都不包含指定 tag 的话也将曝光数据放入 currentVisibleViewMap。

将数据放到指定 Map 中是用来处理曝光的。

这儿还有一个关键的方法:checkExposureViewDimension

    /**
     * check the visible width and height of the view, compared with the its original width and height.
     *
     * @param view
     * @return
     */
    private boolean checkExposureViewDimension(View view) {
        int width = view.getWidth();
        int height = view.getHeight();
        Rect GlobalVisibleRect = new Rect();
        boolean isVisibleRect = view.getGlobalVisibleRect(GlobalVisibleRect);
        if (isVisibleRect) {
            int visibleWidth = GlobalVisibleRect.width();
            int visibleHeight = GlobalVisibleRect.height();

            if ((visibleWidth * 1.00 / width > GlobalsContext.dimThreshold) && (visibleHeight * 1.00 / height > GlobalsContext.dimThreshold)) {
                return true;
            } else {
                return false;
            }
        } else {
            return false;
        }
    }

可以看到这个方法通过获取可视区域大小后对比该 view  的实际大小来判断曝光比例是否达到指定阈值,这样就实现了曝光比例的判断。

接下来我们再回到这个方法中:

    /**
     * for the exposure event
     *
     * @param view
     * @return
     */
    public void triggerViewCalculate(int triggerType, View view, HashMap<String, Object> commonInfo,
                                     Map<String, ExposureModel> lastVisibleViewMap) {
        ...

        Map<String, ExposureModel> currentVisibleViewMap = new ArrayMap<String, ExposureModel>();
        traverseViewTree(view, lastVisibleViewMap, currentVisibleViewMap);
        commitExposure(triggerType, commonInfo, lastVisibleViewMap, currentVisibleViewMap);
        TrackerLog.d("triggerViewCalculate");
    }

上面我们了解到 traverseViewTree 主要是遍历所有子 view,通过 tag 获取埋点数据并将数据存于 map 中。我们接着再看一下 commitExposure 干了什么。

    private void commitExposure(int triggerType, HashMap<String, Object> commonInfo,
                                Map<String, ExposureModel> lastVisibleViewMap, Map<String, ExposureModel> currentVisibleViewMap) {
        ExposureInner exposureInner = new ExposureInner();
        exposureInner.triggerType = triggerType;

        exposureInner.commonInfo = new HashMap<String, Object>();
        exposureInner.commonInfo.putAll(commonInfo);

        exposureInner.lastVisibleViewMap = new HashMap<String, ExposureModel>();
        for (Map.Entry<String, ExposureModel> entry : lastVisibleViewMap.entrySet()) {
            exposureInner.lastVisibleViewMap.put(entry.getKey(), (ExposureModel) entry.getValue().clone());
        }

        exposureInner.currentVisibleViewMap = new HashMap<String, ExposureModel>();
        for (Map.Entry<String, ExposureModel> entry : currentVisibleViewMap.entrySet()) {
            exposureInner.currentVisibleViewMap.put(entry.getKey(), (ExposureModel) entry.getValue().clone());
        }

        lastVisibleViewMap.clear();
        lastVisibleViewMap.putAll(currentVisibleViewMap);

        // transfer time-consuming operation to new thread.
        Message message = exposureHandler.obtainMessage();
        message.what = SINGLE_COMMIT_EXPOSURE;
        message.obj = exposureInner;
        exposureHandler.sendMessage(message);
    }

这儿做了以下几个事:

  • 将 lastMap 和 currentMap 中的数据放到 exposureInner 中,并发送到 handler 去处理曝光。
  • 将 lastMap 中的数据放到 currentMap。
  • 清空 lastMap。

我们接下来再看发到 handler 中做了什么?

    private ExposureManager() {
        exposureHandler = new Handler(exposureThread.getLooper(), new Handler.Callback() {
            @Override
            public boolean handleMessage(Message msg) {
                switch (msg.what) {
                    case SINGLE_COMMIT_EXPOSURE:
                        ExposureInner exposureInner = (ExposureInner) msg.obj;
                        switch (exposureInner.triggerType) {
                            case TrackerInternalConstants.TRIGGER_VIEW_CHANGED:
                                for (String controlName : exposureInner.lastVisibleViewMap.keySet()) {
                                    // If the view is visible in the last trigger timing, but invisible this time, then we commit the view as a exposure event.
                                    if (!exposureInner.currentVisibleViewMap.containsKey(controlName)) {
                                        ExposureModel model = exposureInner.lastVisibleViewMap.get(controlName);
                                        model.endTime = System.currentTimeMillis();
                                        reportExposureData(exposureInner.commonInfo, model, controlName);
                                    }
                                }
                                break;
                        }

                        break;
                    ...
                }
                return false;
            }
        });
    }

这儿判断了该 view 在上一次触发了曝光,而这一次没有触发曝光,则直接上报曝光事件。

    private void reportExposureData(HashMap<String, Object> commonInfo, ExposureModel model, String viewTag) {
        long duration = getExposureViewDuration(model);
        if (duration > 0) {
            TrackerLog.v("ExposureView report " + model.toString() + " exposure data " + duration);
            HashMap<String, Object> indexMap = new HashMap<String, Object>();
            if (!GlobalsContext.exposureIndex.containsKey(viewTag)) {
                // commit firstly
                GlobalsContext.exposureIndex.put(viewTag, 1);
                indexMap.put("exposureIndex", 1);
            } else {
                int index = GlobalsContext.exposureIndex.get(viewTag);
                GlobalsContext.exposureIndex.put(viewTag, index + 1);
                indexMap.put("exposureIndex", index + 1);
            }

            DataProcess.commitExposureParams(commonInfo, model.tag, model.params, duration, indexMap);
        }
    }

这儿做的几个事:

  • 判断曝光时间 > 0 的话才会上报(如果时间 < 设定时间也为 0)。
  • 记录该 view 的曝光次数。
  • 提交曝光数据。
    public static synchronized void commitExposureParams(HashMap<String, Object> commonInfo, String viewName, HashMap<String, Object> viewData, long exposureData, HashMap<String, Object> exposureIndex) {
        if (GlobalsContext.logOpen) {
            TrackerLog.v("commitExposureParams commonInfo=" + commonInfo.toString() + ",viewName=" + viewName + ",viewData=" + viewData + ",exposureData=" + exposureData + ",exposureIndex=" + exposureIndex);
        }
        IDataCommit commit = TrackerManager.getInstance().getTrackerCommit();
        commit.commitExposureEvent(commonInfo, viewName, viewData, exposureData, exposureIndex);
    }

这儿直接获取的是我们在做 SDK 初始化时设定的回调,曝光信息会将数据回调到我们设定的接口中。

我们上面说到的是用户手动滑动时触发的逻辑,还有 fling 时候也应该监听其曝光。

    /**
     * Scene 2: Scroll ending
     *
     * @param motionEvent
     * @param motionEvent1
     * @param v
     * @param v1
     * @return
     */
    @Override
    public boolean onFling(MotionEvent motionEvent, MotionEvent motionEvent1, float v, float v1) {
        long time = System.currentTimeMillis();
        TrackerLog.v("onFling triggerViewCalculate begin");
        this.postDelayed(new Runnable() {
            @Override
            public void run() {
                ExposureManager.getInstance().triggerViewCalculate(TrackerInternalConstants.TRIGGER_VIEW_CHANGED, TrackerFrameLayout.this, commonInfo, lastVisibleViewMap);
            }
        }, 1000);
        TrackerLog.v("onFling triggerViewCalculate end costTime=" + (System.currentTimeMillis() - time));
        return false;
    }

我们看到这儿与滑动基本类似,都是调用了 triggerViewCalculate 去处理曝光逻辑。

还有 onLayout 的时候也进行了曝光判断:

    /**
     * all the state change of view trigger the exposure event
     *
     * @param changed
     * @param left
     * @param top
     * @param right
     * @param bottom
     */
    @Override
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        TrackerLog.v("onLayout traverseViewTree begin");
        // duplicate message in 1s
        long time = System.currentTimeMillis();
        if (time - lastOnLayoutSystemTimeMillis > 1000) {
            lastOnLayoutSystemTimeMillis = time;

            CommonHelper.addCommonArgsInfo(this);
            TrackerLog.v("onLayout addCommonArgsInfo");
            ExposureManager.getInstance().traverseViewTree(this, mReuseLayoutHook);
        }
        //ExposureManager.getInstance().triggerViewCalculate(TrackerInternalConstants.TRIGGER_VIEW_CHANGED, this, commonInfo, lastVisibleViewMap);
        TrackerLog.v("onLayout traverseViewTree end costTime=" + (System.currentTimeMillis() - time));
        super.onLayout(changed, left, top, right, bottom);
    }

同样的在 view 的以下两个生命周期也分别做了曝光判断:

/**
     * the state change of window trigger the exposure event.
     * Scene 3: switch back and forth when press Home button.
     * Scene 4: enter into the next page
     * Scene 5: window replace
     *
     * @param hasFocus
     */
    @Override
    public void dispatchWindowFocusChanged(boolean hasFocus) {
        TrackerLog.v("dispatchWindowFocusChanged triggerViewCalculate begin");
        long ts = System.currentTimeMillis();
        ExposureManager.getInstance().triggerViewCalculate(TrackerInternalConstants.TRIGGER_WINDOW_CHANGED, this, commonInfo, lastVisibleViewMap);
        TrackerLog.v("dispatchWindowFocusChanged triggerViewCalculate end costTime=" + (System.currentTimeMillis() - ts));
        super.dispatchWindowFocusChanged(hasFocus);
    }

    @Override
    protected void dispatchVisibilityChanged(View changedView, int visibility) {
        // Scene 6: switch page in the TabActivity
        if (visibility == View.GONE) {
            TrackerLog.v("dispatchVisibilityChanged triggerViewCalculate begin");
            long ts = System.currentTimeMillis();
            ExposureManager.getInstance().triggerViewCalculate(TrackerInternalConstants.TRIGGER_WINDOW_CHANGED, this, commonInfo, lastVisibleViewMap);
            TrackerLog.v("dispatchVisibilityChanged triggerViewCalculate end costTime=" + (System.currentTimeMillis() - ts));
        } else {
            TrackerLog.v("trigger dispatchVisibilityChanged, visibility =" + visibility);
        }
        super.dispatchVisibilityChanged(changedView, visibility);
    }

三、总结

该库通过全局监听 Activity 的生命周期,又动态添加自定义 View,再遍历 view tree 中所有 view 是否有指定 tag 的方式,可以全局检测到 Activity 中 View 的曝光和手势操作,通过无侵入业务代码的方式动态管理了曝光和点击事件。

它分别在以下时机做了曝光监听:

  • onLayout:view 确定大小和位置后,绘制前。
  • 滑动:手势滑动。
  • fling:松手后的惯性滑动。
  • dispatchWindowFocusChanged:窗口焦点变化,比如:前后台切换、页面切换。
  • dispatchVisibilityChanged:view 可见性变化。

值得学习的点:

通过遍历 view 树和设置 view.setAccessibilityDelegate 直接无侵入做到点击事件监听。

这种方式是我之前所没有用过的,值得借鉴。


http://www.kler.cn/a/572408.html

相关文章:

  • 【cuda学习日记】5.4 常量内存
  • leetcode383 赎金信
  • 【详解 | 辨析】“单跳多跳,单天线多天线,单信道多信道” 之间的对比
  • Git-cherry pick
  • 迷你世界脚本世界UI接口:UI
  • c++面试常见问题:虚表指针存在于内存哪个分区
  • Node.js学习分享(上)
  • python爬虫数据库概述
  • 【Java】IO流
  • Linux·数据库INSERT优化
  • PyTorch 与 NVIDIA GPU 的适配版本及安装
  • NO.23十六届蓝桥杯备战|二维数组|创建|初始化|遍历|memset(C++)
  • Kconfig与CMake初步模块化工程3
  • 刷题日记——部分二分算法题目分享
  • 我如何从 Java 和 Python 转向 Golang 的脚本和 GUI 工具开发
  • ThreadLocal解析
  • CTF 中的 XSS 攻击:原理、技巧与实战案例
  • 【Web前端开发】---HTML标签及标签属性
  • 【练习】【链表】力扣热题100 206. 反转链表
  • 将 SSH 密钥添加到 macOS 的钥匙串中