安卓实现Vendor Impression Tracker(浏览
参考内容:
内容准备:
- 了解LinkList和ArrayList的区别
- 了解回调的编程方式
- 对弱引用WeakReference有一定的了解
- 对Android的ViewTreeObserver有一定的了解
- 对Android的Handler有一定得了解
假如一个平台上有很多入驻的商家,现在平台的运营人员有一个要求,希望在APP中一个商家或商品被用户浏览了指定时间后记录用户行为,随后可以用这些数据去分析商家的受欢迎程度或者用户喜欢的内容,方便对用户进行推荐。
本文主要介绍从上面的参考资料中借鉴和整理出来一个Vendor Impression Tracker小框架。以监听一个RecyclerView中的商家Item为Demo。
先看效果,再看代码。
手机屏幕显示22到25商家并停留1秒 后台将会记录用户的行为
源码:GitHub地址
我们分析一下这个需求要实现的细节:
1. 通过追踪View来实现商家的追踪
我们把商家或店铺的信息存在一个View中,那么监听一个商家对应的View在屏幕的曝光时间等同于监听用户浏览这个商家的时间。所以,我们只要实现监听某一个View在屏幕上的可见时间即可实现需求。
2. 确定追踪的条件
监听一个View的曝光时间,需要设定一个View的可见区域百分比,用来判断它是否“被用户看到”。比如View的宽要全部显示在屏幕内,高在屏幕显示区域超过50%,才算这个View看到)。另外,需要设定这个View屏幕上曝光多久后被记录、追踪到用户的印象行为后要做什么事情、这个View是否已经被追踪过。这些对于不同的追踪对象有不同设定。我们把这些定义成一个接口,在使用时再根据需求实现。
商家“被用户看见”的条件样例
我们为此定义了ImpressionInterface接口,如下。
public interface ImpressionInterface {
int getImpressionHeightViewedPercentage(); // 设置高度(水平方向上)最小的可见性百分比
int getImpressionWidthViewedPercentage(); // 设置宽度(竖直方向上)最小的可见性百分比
Integer getImpressionMinVisiblePx(); // 设置最小的可见像素,这里暂时不用到
int getImpressionMinTimeViewed(); // 设置Impression追踪的最短时间(即超过这个时间将会记录行为)
void trackImpression(); // 追踪印象行为的工作,比如向服务器发送用户记录
boolean isImpressionRecorded(); // 得到当前的View是否已经被追踪记录过
void setImpressionRecorded(); // 记录当前的View已经被追踪记录过
}
随后,根据需求实现这个接口的商家实例(倘若日后有其他的需求,比如是追踪一个新闻文章,我们同样实现这个接口写Entity即可)。关于商家对ImpressionInterface接口的实现如下。
public class VendorCardImpressionEntity implements ImpressionInterface {
private static final int DELAY_MILLIS = 1000; // 我们设定View曝光1秒后将记录印象行为
protected static final int MIN_VIEWED_HEIGHT_PERCENT = 50; // 我们设定View最小的高度可见比例为50%,即竖直方向上显示比例不小于50%才认为View被用户见到
protected static final int MIN_VIEWED_WIDTH_PERCENT = 100; // 我们设定了View最小的宽度可见比例为100%,即水平方向上整个View要全部被看见才认为可见
private ImpressionTracker.OnTrackImpressionListener mOnTrackImpressionListener;
private boolean mImpressionRecorded;
public VendorCardImpressionEntity(ImpressionTracker.OnTrackImpressionListener onTrackImpressionListener) {
this.mOnTrackImpressionListener = onTrackImpressionListener;
}
@Override
public int getImpressionHeightViewedPercentage() {
return MIN_VIEWED_HEIGHT_PERCENT;
}
@Override
public int getImpressionWidthViewedPercentage() {
return MIN_VIEWED_WIDTH_PERCENT;
}
@Override
public Integer getImpressionMinVisiblePx() {
return null;
}
@Override
public int getImpressionMinTimeViewed() {
return DELAY_MILLIS;
}
@Override
public void trackImpression() {
if (mOnTrackImpressionListener != null) {
mOnTrackImpressionListener.trackImpressionEvent();
}
}
@Override
public boolean isImpressionRecorded() {
return mImpressionRecorded;
}
@Override
public void setImpressionRecorded() {
mImpressionRecorded = true;
}
}
可以看到在上面的构造方法中,我们传入了一个OnTrackImpressionListener 回调,用来实现追踪的工作(由外部代码实现并传入),用在上面的trackImpression()方法中。定义如下:
public interface OnTrackImpressionListener {
void trackImpressionEvent();
}
3. 判断View在屏幕上是否“被用户见到”
我们准备了两个方法,一个方法试判断View是否满足曝光时间,另外一个方法判断View是否被用户见到。
static class VisibilityChecker {
private final Rect mClipRect = new Rect();
/**
* 传入View开始出现在屏幕的时间和曝光时间阈值,判断一个View是否满足被记录追踪的条件
* @param startTimeMillis
* @param minTimeViewed
* @return
*/
boolean hasRequiredTimeElapsed(final long startTimeMillis, final int minTimeViewed) {
return SystemClock.uptimeMillis() - startTimeMillis >= minTimeViewed;
}
/**
* 判断一个View是否在屏幕上可见
* @param rootView
* @param view
* @param minVisiblePx 最小的可见像素个数,这里暂时不用
* @param minHeightViewed 最小的高度
* @param minWidthViewed 最小的宽度
* @return
*/
boolean isVisible(@Nullable final View rootView, @Nullable final View view,
@Nullable final Integer minVisiblePx, int minHeightViewed, int minWidthViewed) {
if (view == null || view.getVisibility() != View.VISIBLE || rootView.getParent() == null) {
return false;
}
if (!view.getGlobalVisibleRect(mClipRect)) {
return false;
}
int[] location = new int[2];
view.getLocationOnScreen(location);
int x1 = location[0];
int y1 = location[1];
int x2 = x1 + view.getWidth();
int y2 = y1 + view.getHeight();
if ((x1 == 0 && y1 == 0) || x1 > DeviceRelatedUtil.getInstance().getDisplayWidth()
|| x2 < 0 || y1 > DeviceRelatedUtil.getInstance().getDisplayHeight() || y2 < 0) {
return false;
}
final int visibleViewHeight = mClipRect.height() * 100;
final int visibleViewWidth = mClipRect.width() * 100;
final int totalViewHeight = view.getHeight() * minHeightViewed;
final int totalViewWidth = view.getWidth() * minWidthViewed;
return visibleViewHeight >= totalViewHeight && visibleViewWidth >= totalViewWidth;
}
}
做好了上面的准备工作之后,接下来我们设计追踪的算法。
我们把工作分为两类,一类对是可见性的追踪,另一类是判断曝光时长的追踪。分别用VisibilityTracker和ImpressionTracker两个类进行结合实现。
大概的流程逻辑图如下: VisibilityTracker和ImpressionTracker的调用关系也许看完流程图你大概知道是怎么回事了。
先说说VisibilityTracker的内容。
VisibiliyTracker中比较重要的成员变量为:
@NonNull
private final Map<View, TrackingInfo> mTrackedViews; // 将记录所有被add进来监听的View。
其中,TrackingInfo类是记录了View在被追踪时的一些必要信息。结构如下:
static class TrackingInfo {
int mHeightViewedPercentage; // 最小的高度可见百分比,这个值便是从ImpressionInterface传来的
int mWidthViewedPercentage; // 最小的宽度可见百分比,这个值便是从ImpressionInterface传来的
long mAccessOrder; // View的序号,增加一个View时,序号自增1。用于之后追踪的View过多时,去除掉序号较前的View
View mRootView;
@Nullable
Integer mMinVisiblePx; // 最少的可见像素点,这里暂时不用到
}
1.如何监听屏幕的变化?
用户在使用APP时,界面是不断的变化的,这也意味着商家的显示区域是不断在变化的。因此我们需要对整个屏幕的视图变化进行监听,从而实现动态地对商家的出现和消失进行监控。当检测到屏幕视图变化时,我们便开启一次轮询,更新屏幕内可见的商家。我们通过使用ViewTreeObserver来监听,它能够监听到View变化和重绘(这里暂不详细介绍ViewTreeObserver了)。
/**
* 对view的最顶层布局进行监听和设置回调,一旦整个布局出现重新绘画,马上回调(开启轮询操作)
* @param context
* @param view
*/
private void setViewTreeObserver(@Nullable final Context context, @Nullable final View view) {
final ViewTreeObserver originalViewTreeObserver = mWeakViewTreeObserver.get();
if (originalViewTreeObserver != null && originalViewTreeObserver.isAlive()) { // 设置过了就不设置了
return;
}
final View rootView = Views.getTopmostView(context, view);
if (rootView == null) {
Log.d("mopub", "Unable to set Visibility Tracker due to no available root view.");
return;
}
final ViewTreeObserver viewTreeObserver = rootView.getViewTreeObserver();
if (!viewTreeObserver.isAlive()) {
Log.w("mopub", "Visibility Tracker was unable to track views because the"
+ " root view tree observer was not alive");
return;
}
mWeakViewTreeObserver = new WeakReference<>(viewTreeObserver); // 这里用到了弱引用,避免内存泄漏
viewTreeObserver.addOnPreDrawListener(mOnPreDrawListener); // mOnPreDrawListener回调方法里做轮询操作
}
我们追踪每一个View时,都会先调用上面的方法对这个Veiw加入PreDraw的监听回调,回调的工作便是开始新的一轮可见性监测。这也就实现了,屏幕内容一旦发现变化,就会进行一次View可见性的监测。
2.如何实现View的可见性监测?
我们把可见性监测写在一个Runnable实类中。轮询时,用Handler调用这一个Runnable实例即可。实现如下。
class VisibilityRunnable implements Runnable {
@NonNull
private final ArrayList<View> mVisibleViews;
@NonNull
private final ArrayList<View> mInvisibleViews;
VisibilityRunnable() {
mInvisibleViews = new ArrayList<>();
mVisibleViews = new ArrayList<>();
}
/*
一个线程,对mTrackedViews进行遍历(第一次添加View或屏幕变化时将会执行一次)
*/
@Override
public void run() {
mIsVisibilityScheduled = false;
for (final Map.Entry<View, TrackingInfo> entry : mTrackedViews.entrySet()) {
final View view = entry.getKey();
// 得到View对应的追踪信息
final int minHeightViewed = entry.getValue().mHeightViewedPercentage;
final int minWidthViewed = entry.getValue().mWidthViewedPercentage;
final Integer minVisiblePx = entry.getValue().mMinVisiblePx;
final View rootView = entry.getValue().mRootView;
// 得到了所有加入的View中,显示在当前屏幕中的View集合mVisibleViews,没有显示在当前屏幕中的View集合mInvisibleViews
if (mVisibilityChecker.isVisible(rootView, view, minVisiblePx, minHeightViewed, minWidthViewed)) {
mVisibleViews.add(view);
} else if (!mVisibilityChecker.isVisible(rootView, view, null, minHeightViewed, minWidthViewed)) {
mInvisibleViews.add(view);
}
}
// 得到了当前可见和不可见的View集合后,将它们回送给ImpressionTracker类(mVisibilityTrackerListener这个回调在ImpressionTracker中实现)
if (mVisibilityTrackerListener != null) {
mVisibilityTrackerListener.onVisibilityChanged(mVisibleViews, mInvisibleViews);
}
mVisibleViews.clear();
mInvisibleViews.clear();
}
}
上面实现了对屏幕上的View进行地监听,把已经登记的正在追踪的View动态地划分为可见和不可见两个集合,随后把这两个集合交给ImpressionTracker。这就是VisibilityTracker所负责的工作了。
接下来我们看一下ImpressionTracker做了什么。
ImpressionTracker主要的成员变量:
@NonNull
private final Map<View, TimestampWrapper<ImpressionInterface>> mPollingViews; // 存View和时间包装器(存入了View注册的时间和ImpressionInterface回调)
@NonNull
private final Map<View, ImpressionInterface> mTrackedViews; // 存view和回调接口,这个变量只是用于判断View有没有对应的ImpressionInterface
TimestampWrapper是个时间包装器,内容如下。
class TimestampWrapper<T> {
@NonNull
final T mInstance;
long mCreatedTimestamp;
TimestampWrapper(@NonNull final T instance) {
mInstance = instance;
mCreatedTimestamp = SystemClock.uptimeMillis(); // 在new时自动记录当前的时间(被用户见到的时间)
}
}
1.ImpressionTracker得到VisibilityTracker传过来的屏幕上的View集合后怎么处理?
ImpressionTracker拿到VisibilityTracker的两个集合后,对变量mPollingViews进行了更新,随后开启Impression的追踪轮询。如下:
mVisibilityTrackerListener = (visibleViews, invisibleViews) -> {
for (final View view : visibleViews) { // 遍历可见的View,插入或更新
final ImpressionInterface impressionInterface = mTrackedViews.get(view);
if (impressionInterface == null) { // 由于某些原因,View存入的ImpressionInterface为空,说明这个View不需要追踪或无法追踪,可以移除
removeView(view);
continue;
}
final TimestampWrapper<ImpressionInterface> polling = mPollingViews.get(view);
if (polling != null && impressionInterface == polling.mInstance) {
continue; // 当前的View已经更新
}
// 这个View还没有加入mPollingViews,则加入
mPollingViews.put(view, new TimestampWrapper<>(impressionInterface));
}
for (final View view : invisibleViews) {
mPollingViews.remove(view); // 移除View
}
scheduleNextPoll(); // 开始轮询
};
2.ImpressionTracker的Impression追踪轮询中做了什么?
我们同样使用了Runnable的方式实现追踪。不同的是,我们用handler.postDelayed()方法实现了轮询任务的循环执行。直接发出停止信号前(执行Handler.removeMessages(0)),PollingRunnable将会一直循环执行(不断地判断mPollingViews中满足曝光时长的View并实现追踪)
class PollingRunnable implements Runnable {
@NonNull
private final ArrayList<View> mRemovedViews;
PollingRunnable() {
mRemovedViews = new ArrayList<>();
}
@Override
public void run() {
for (final Map.Entry<View, TimestampWrapper<ImpressionInterface>> entry : mPollingViews.entrySet()) {
final View view = entry.getKey();
final TimestampWrapper<ImpressionInterface> timestampWrapper = entry.getValue();
if (!mVisibilityChecker.hasRequiredTimeElapsed(
timestampWrapper.mCreatedTimestamp,
timestampWrapper.mInstance.getImpressionMinTimeViewed())) {
continue;
}
// 满足曝光时间后,追踪行为(我们的终极目的)
timestampWrapper.mInstance.trackImpression();
timestampWrapper.mInstance.setImpressionRecorded();
mRemovedViews.add(view);
}
for (View view : mRemovedViews) {
removeView(view); // 删除已经被追踪的View相关的记录
}
mRemovedViews.clear();
if (!mPollingViews.isEmpty()) {
scheduleNextPoll(); // 仍然存在注册的View,继续循环监听
}
}
}
这样就大概实现了Vendor Impression Tracker了。
注意:
记得之后要在Fragment的onDestroyView()或Activity的onDestroy()生命周期方法中停止轮询,避免内存泄露或性能浪费。调用ImpressionTracker的destroy()方法即可,它将会清除ImressionTracker和VisibilityTracker的数据且停止轮询任务。
public void destroy() {
clear();
mVisibilityTracker.destroy();
mVisibilityTrackerListener = null;
}
public void clear() {
mTrackedViews.clear();
mPollingViews.clear();
mVisibilityTracker.clear();
mPollHandler.removeMessages(0);
mImpressionTracked.clear();
}