一、说明
我们业务中大部分场景是用 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() {@Overridepublic void commitClickEvent(HashMap<String, Object> commonInfo, String viewName, HashMap<String, Object> viewData) {Log.e("aa", ">>>>>>>>>>>>> commitExposureEvent " + viewName);}@Overridepublic 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 件事:
- 把一些配置信息传入给 SDK 保存起来。
- 注册了 Activity 生命周期监听,并由 ActivityLifecycleForTracker 管理。
生命周期监听
private class ActivityLifecycleForTracker implements Application.ActivityLifecycleCallbacks {@Overridepublic void onActivityCreated(Activity activity, Bundle bundle) {}@Overridepublic void onActivityStarted(Activity activity) {}@Overridepublic void onActivityResumed(Activity activity) {TrackerLog.d("onActivityResumed activity " + activity.toString());attachTrackerFrameLayout(activity);}@Overridepublic void onActivityPaused(Activity activity) {if (GlobalsContext.trackerExposureOpen) {TrackerLog.d("onActivityPaused activity " + activity.toString());if (GlobalsContext.batchOpen) {batchReport();}}}@Overridepublic void onActivityStopped(Activity activity) {}@Overridepublic void onActivityDestroyed(Activity activity) {TrackerLog.d("onActivityDestroyed activity " + activity.toString());detachTrackerFrameLayout(activity);}@Overridepublic void onActivitySaveInstanceState(Activity activity, Bundle bundle) {}}
这儿分别监听了 Activity 的 3 个生命周期,并做了一些事,根据方法名称我们猜测:
- onResume:关联 TrackerFrameLayout。
- onPause:批量上报。
- onDestroy:分离 TrackerFrameLayout。
关联 TrackerFrameLayout
public void attachTrackerFrameLayout(Activity activity) {// this is a problem: several activity exist in the TabActivityif (activity == null || activity instanceof TabActivity) {return;}// exist android.R.id.content not found crashtry {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());}}
这儿做了几个事:
- 创建 TrackerFrameLayout。
- 将 Activity 下 id 为 android.R.id.content 的 View 中的子 View 移除。
- 将所有移除的子 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() {@Overridepublic boolean handleMessage(Message msg) {switch (msg.what) {...case BATCH_COMMIT_EXPOSURE:for (CommitLog commitLog : commitLogs.values()) {// the exposure times inside pagecommitLog.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 onActivityResumedCommonHelper.addCommonArgsInfo(this);}public TrackerFrameLayout(Context context, AttributeSet attrs) {super(context, attrs);}@Overridepublic boolean dispatchTouchEvent(MotionEvent ev) {mGestureDetector.onTouchEvent(ev);if (getContext() != null && getContext() instanceof Activity) {// trigger the click eventClickManager.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 beginninglong 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*/@Overrideprotected void onLayout(boolean changed, int left, int top, int right, int bottom) {TrackerLog.v("onLayout traverseViewTree begin");// duplicate message in 1slong 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);}@Overridepublic boolean onDown(MotionEvent motionEvent) {TrackerLog.v("onDown");return false;}@Overridepublic void onShowPress(MotionEvent motionEvent) {TrackerLog.v("onShowPress");}@Overridepublic boolean onSingleTapUp(MotionEvent motionEvent) {TrackerLog.v("onSingleTapUp");return false;}@Overridepublic void onLongPress(MotionEvent motionEvent) {TrackerLog.v("onLongPress");}@Overridepublic 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*/@Overridepublic boolean onFling(MotionEvent motionEvent, MotionEvent motionEvent1, float v, float v1) {long time = System.currentTimeMillis();TrackerLog.v("onFling triggerViewCalculate begin");this.postDelayed(new Runnable() {@Overridepublic 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*/@Overridepublic 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);}@Overrideprotected void dispatchVisibilityChanged(View changedView, int visibility) {// Scene 6: switch page in the TabActivityif (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 onActivityResumedCommonHelper.addCommonArgsInfo(this);} @Overridepublic boolean dispatchTouchEvent(MotionEvent ev) {mGestureDetector.onTouchEvent(ev);if (getContext() != null && getContext() instanceof Activity) {// trigger the click eventClickManager.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 beginninglong 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 hitif (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 个事情:
- 判断采样率:通过随机数操作采样率,如果超出采样率则不进行处理。
- 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 beginninglong 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 screenif (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() {@Overridepublic 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 firstlyGlobalsContext.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*/@Overridepublic boolean onFling(MotionEvent motionEvent, MotionEvent motionEvent1, float v, float v1) {long time = System.currentTimeMillis();TrackerLog.v("onFling triggerViewCalculate begin");this.postDelayed(new Runnable() {@Overridepublic 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*/@Overrideprotected void onLayout(boolean changed, int left, int top, int right, int bottom) {TrackerLog.v("onLayout traverseViewTree begin");// duplicate message in 1slong 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*/@Overridepublic 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);}@Overrideprotected void dispatchVisibilityChanged(View changedView, int visibility) {// Scene 6: switch page in the TabActivityif (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 直接无侵入做到点击事件监听。
这种方式是我之前所没有用过的,值得借鉴。