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 件事:
- 把一些配置信息传入给 SDK 保存起来。
- 注册了 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 个生命周期,并做了一些事,根据方法名称我们猜测:
- onResume:关联 TrackerFrameLayout。
- onPause:批量上报。
- 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());
}
}
这儿做了几个事:
- 创建 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() {
@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 个事情:
- 判断采样率:通过随机数操作采样率,如果超出采样率则不进行处理。
- 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 直接无侵入做到点击事件监听。
这种方式是我之前所没有用过的,值得借鉴。