安卓实现Vendor Impression Tracker(浏览记录追踪)

参考内容:

  1. Github上关于市场营销的SDK——mopub
  2. Medium博客《Android Impression Tracking》(可能需要翻墙)

内容准备:

  1. 了解LinkList和ArrayList的区别
  2. 了解回调的编程方式
  3. 对弱引用WeakReference有一定的了解
  4. 对Android的ViewTreeObserver有一定的了解
  5. 对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();
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值