Android 列表曝光数据统计全面解析

列表曝光统计

开发越往后走,越发觉察到数据的宝贵,所谓量变产生质变,即便是一些平时看上去无足轻重的数据一旦量上去了加以分析也会是一比巨大的财富。

列表可以说是当下互联网产品中最最最常见的呈现形式了,几乎所有内容都可以用列表的方式进行展示,同时也是最好的方式没有之一。

当一个产品规模到达一定量级后为了进一步提升用户体验往往产品或者项目 leader 会提出这样一个需求:统计列表曝光数据。这也就是今天这篇文章的主题,希望可以通过本文为有同样需求的童鞋一些思路和节省重复造轮子的时间。
Android 列表曝光数据统计全面解析_第1张图片
Android 列表曝光数据统计全面解析_第2张图片

需求整理

顾名思义,列表曝光统计核心需求可分为"曝光"和"统计",以下分别对两个核心需求进行整理。

曝光

曝光的定义根据需求可能会有所不同,这里按照广义上的定义:

  • Item 完全展示

  • 列表从后台重新恢复到前台并获取焦点

统计

  • 对一次曝光统计周期内的Item曝光次数累加
  • 相邻两次统计去除重复项
  • 相邻两次统计需要存在最小间隔时间

思路分析

列表

Android平台上,主流列表大多使用RecyclerView及其子类进行列表开发,因此这里也同样选择基于RecyclerView进行再封装,可以兼容大部分场景。

曝光项获取

首先需要拿到当前列表正在曝光的项,通过RecyclerView可以获取到其对应的LayoutManager,而其中最常见的单项列表LinearLayoutManager正好提供了可以获取当前显示 Item 位置的方法:

	// 第一个已显示item位置
	public int findFirstVisibleItemPosition()
    // 第一个完全显示item位置
    public int findFirstCompletelyVisibleItemPosition()
    // 最后一个已显示item位置
    public int findLastVisibleItemPosition()
    // 最后一个完全显示item位置
    public int findLastCompletelyVisibleItemPosition()

这些方法分为显示完全显示两种,单独来看可能会有误解,结合起来就很容易区分了,由于 item 是有一定高度的,因此就会存在显示时所有高度完全被显示部分高度没有显示两种情况。至于使用哪种就看具体的业务场景了,我这里因为考虑到用户关心的必定是完全展示的,所以采用的是前者。

既然拿到了当前曝光的首项和尾项那计算出所有的曝光项就很容易了。

曝光时机

某一个时刻的曝光项是可以拿到了,好像没有什么问题了,但是仔细想想,某一个时刻中的时刻还没搞定,对此需要结合可实现性模拟用户习惯来分析如何定义这个时刻

  • 可实现性

    说到要捕获用户滑动浏览的时机,立马会想到屏幕触控事件,通过监听ReyclerView滑动回调可以实现最低成本的获取用户每次滑动的时机。

        /**
         * An OnScrollListener can be added to a RecyclerView to receive messages when a scrolling event
         * has occurred on that RecyclerView.
         * 

    * @see RecyclerView#addOnScrollListener(OnScrollListener) * @see RecyclerView#clearOnChildAttachStateChangeListeners() * */ public abstract static class OnScrollListener { /** * Callback method to be invoked when RecyclerView's scroll state changes. * * @param recyclerView The RecyclerView whose scroll state has changed. * @param newState The updated scroll state. One of {@link #SCROLL_STATE_IDLE}, * {@link #SCROLL_STATE_DRAGGING} or {@link #SCROLL_STATE_SETTLING}. */ public void onScrollStateChanged(RecyclerView recyclerView, int newState){} /** * Callback method to be invoked when the RecyclerView has been scrolled. This will be * called after the scroll has completed. *

    * This callback will also be called if visible item range changes after a layout * calculation. In that case, dx and dy will be 0. * * @param recyclerView The RecyclerView which scrolled. * @param dx The amount of horizontal scroll. * @param dy The amount of vertical scroll. */ public void onScrolled(RecyclerView recyclerView, int dx, int dy){} }

    滑动回调共有两种:

    一是onScrollStateChanged,该方法在滑动状态改变时调用,传入两个参数,这里主要关心的就是第二个newState,该参数有以下几个预设值:

        /**
         * The RecyclerView is not currently scrolling.
         * @see #getScrollState()
         */
        public static final int SCROLL_STATE_IDLE = 0;
    
        /**
         * The RecyclerView is currently being dragged by outside input such as user touch input.
         * @see #getScrollState()
         */
        public static final int SCROLL_STATE_DRAGGING = 1;
    
        /**
         * The RecyclerView is currently animating to a final position while not under
         * outside control.
         * @see #getScrollState()
         */
        public static final int SCROLL_STATE_SETTLING = 2;
    

    注释写的也比较清晰,简而言之依次代表 停止滑动、拖拽滑动、惯性滑动。

    二是onScrolled,该方法就更好理解,在每次滑动时回调当前的 x、y 轴坐标。

  • 用户习惯

    试想站在用户角度,当对一个列表进行滑动时,面对无数的项,想要筛选出自己感兴趣的内容会怎么做?

    通常会先进行快速滑动,当看到自己感兴趣的内容时会停止滑动然后等待列表刚好停止在感兴趣的内容项完全显示的位置,但是由于滑动时存在一个惯性动画的,因此可能虽然停止滑动后并不能完全显示出预期的内容,这时候大概率会在惯性滑动停止之前重新手动将列表滑动或静止在预期的内容上。

    这就和上述滑动监听中的状态改变符合回调更加符合。

  • 结论

    结合可实现性调研用户习惯分析可得出结论:通过监听RecyclerView的滑动监听onScrollStateChanged方法,可更准确且低成本的捕获列表曝光的时机。

架构设计

子曾经说过,好的架构设计是成功的一半。至于子是谁,这不重要~

一个好的架构需要注意以下几点:高内聚低耦合易拓展、继承、封装、多态。像本目标产品的定位其实是偏向于工具类的,所以还要尽量2B友好,简单说就是调用简单对外暴露方法灵活简洁

为了实现良好的封装性和多态,将顶级函数声明成一个接口:

/**
 * Created by whr on 2018/12/26.
 * 调用接口
 * RecyclerView Item曝光数据统计
 * 数据获取分两种方式:
 * 1、通过getData获得当前总曝光量
 * 2、通过setOnExposeCallback监听每次曝光事件
 */
public interface ItemViewReporterApi {

    /**
     * 重置data曝光量
     */
    void reset();

    /**
     * 停止监听并且释放资源
     */
    void release();

    /**
     * 获得当前状态
     */
    boolean isReleased();

    /**
     * 得到曝光数据总集合
     */
    SparseIntArray getData();

    /**
     * 设置曝光回调
     */
    void setOnExposeCallback(OnExposeCallback exposeCallback);

    /**
     * 当RecyclerView所在页面获得焦点时统计一次曝光
     */
    void onResume();

    /**
     * @param interval 曝光时间间隔,单位ms
     */
    void setTouchInterval(long interval);

    /**
     * @param interval 曝光时间间隔,单位ms
     * @see #onResume()
     */
    void setResumeInterval(long interval);

}

为了方便外部调用,采用工厂模式对工具类进行实例获取,同时,为了更好的封装性以接口形式返回实现类,这样做的好处是实现接口实现分离,减少调用方的学习成本,并且在一些特殊情况下减少调用方的工作量

/**
 * Created by whr on 2018/12/26.
 * RecyclerView Item曝光数据统计
 * 工厂类
 */
public class ItemViewReporterFactory {

    private ItemViewReporterFactory() {
    }

    @NonNull
    public static ItemViewReporterApi getItemReporter(RecyclerView recyclerView) throws IllegalArgumentException {
        RecyclerView.LayoutManager manager = recyclerView.getLayoutManager();
        if (manager instanceof LinearLayoutManager) {
            return new ItemViewReporterImpl(recyclerView);
        }
        throw new IllegalArgumentException("LayoutManager must be LinearLayoutManager");
    }
}

因为需要用到LinearLayoutManager的方法来得到当前曝光项,所以判断当前 LayoutManager ,如果不是则抛出异常。

实现

由于是对列表进行操作,所以不可避免的需要用到列表的相关类实现,这里以使用率最高的RecyclerView为例,整体采用装饰者模式,在尽可能少入侵性的情况下完成对列表的曝光的监听与整合

实现分为两部分,内部实现外部实现

内部实现

  • 初始化

    实现对外接口ItemViewReporterApi,声明为抽象类abstract class,对外调用方法不予实现。

    内部需要对曝光数据进行处理,在低性能机型上可能造成UI 线程阻塞,采用Handler模式进行异步执行。

    public abstract class ItemViewReporterBase implements ItemViewReporterApi {
        public ItemViewReporterBase(@NonNull RecyclerView recyclerView) {
            this.mRecyclerView = recyclerView;
            this.mLayoutManager = (LinearLayoutManager) recyclerView.getLayoutManager();
            init();
        }
      
        private void init() {
            mScrollListener = new MyScrollListener();
            mRecyclerView.addOnScrollListener(mScrollListener);
            mReportData = new SparseIntArray();
            mHandlerThread = new HandlerThread("ItemViewReporterSub");
            mHandlerThread.start();
            mHandler = new MyHandler(mHandlerThread.getLooper());
        }
    }
    
  • 滑动监听

        private class MyScrollListener extends RecyclerView.OnScrollListener {
    
            @Override
            public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
                /**
                 * newState
                 * 0:完全停止滚动
                 * 1: 手指点击
                 * 2:惯性滑动中
                 */
                if (newState == 0) {
                    onView();
                }
            }
    
            @Override
            public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
                super.onScrolled(recyclerView, dx, dy);
            }
        }
    
  • 优化策略

    虽说采用滑动停止监听可以有效获取用户停留的时机,但是部分特殊场景下可能导致短时间多次进行曝光采集的情况,而实际上对用户来说这仅为一次曝光事件。因此有必要添加多次曝光之间的有效时长间隔控制。

        private void onView() {
            mLastTouchTime = templateTimeCtrl(mLastTouchTime, mIntervalTouch, WHAT_TOUCH);
        }
        
        /**
         * 模板代码
         * 控制曝光记录间隔
         *
         * @param lastTime 上次曝光时间
         * @param interval 间隔时间
         * @param what     对应事件
         * @return 此次曝光时间
         */
        protected long templateTimeCtrl(long lastTime, long interval, int what) {
            if (SystemClock.elapsedRealtime() - lastTime < interval) {
                mHandler.removeMessages(what);
            }
            mHandler.sendEmptyMessageDelayed(what, interval);
            return SystemClock.elapsedRealtime();
        }
    
        protected class MyHandler extends Handler {
    
            private MyHandler(Looper looper) {
                super(looper);
            }
    
            @Override
            public void handleMessage(Message msg) {
                switch (msg.what) {
                    case WHAT_TOUCH:
                        recordTouch();
                        break;
                    case WHAT_RESUME:
                        recordResume();
                        break;
                }
            }
        }
    
  • 曝光统计

        private Point findRangePosition() {
            int firstComPosition = -1;
            int lastComPosition = -1;
            try {
                firstComPosition = mLayoutManager.findFirstCompletelyVisibleItemPosition();
                lastComPosition = mLayoutManager.findLastCompletelyVisibleItemPosition();
            } catch (Exception e) {
                e.printStackTrace();
            }
            if (firstComPosition == -1) {
                return null;
            } else {
                return new Point(firstComPosition, lastComPosition);
            }
        }
    
  • 数据去重

    有没有想过这样一个场景:当前用户第一次浏览了 1、2、3、4、5、6、7、8、9 项,此时记录第一次,当用户看完后进行小范围滑动,此时曝光项为 7、8、9、10、11、12、13、14、15 项,如果再次全量记录一次,相对于开始7、8、9 就曝光了 2 次,但实际上对用户来说因为压根没有滑出屏幕,所以其实只能算一次曝光

    因此就需要对数据进行去重操作

        private void recordTouch() {
            Point rangePosition = findRangePosition();
            if (rangePosition == null) {
                return;
            }
            int firstComPosition = rangePosition.x;
            int lastComPosition = rangePosition.y;
            if (firstComPosition == mOldFirstComPt && lastComPosition == mOldLastComPt) {
                return;
            }
            List<Integer> positionList = new ArrayList<>();
            List<View> viewList = new ArrayList<>();
            //首次&不包含相同项
            if (mOldLastComPt == -1 || firstComPosition > mOldLastComPt || lastComPosition < mOldFirstComPt) {
                for (int i = firstComPosition; i <= lastComPosition; i++) {
                    templateAddData(i, positionList, viewList);
                }
            } else {
                //排除相同项
                if (firstComPosition < mOldFirstComPt) {
                    for (int i = firstComPosition; i < mOldFirstComPt; i++) {
                        templateAddData(i, positionList, viewList);
                    }
                }
                if (lastComPosition > mOldLastComPt) {
                    for (int i = mOldLastComPt + 1; i <= lastComPosition; i++) {
                        templateAddData(i, positionList, viewList);
                    }
                }
            }
            if (mExposeCallback != null) {
                mExposeCallback.onExpose(positionList, viewList);
            }
            mOldFirstComPt = firstComPosition;
            mOldLastComPt = lastComPosition;
        }
    
  • 数据记录

    拿到去重的曝光数据后,基本上一次曝光统计操作就步入尾声了,现在需要做的就是将数据保存下来,并且回调给外部使用,这里将每一次曝光数据提供给外部主要是为了满足不同场景下奇奇怪怪的产品需求,用专业术语来说算是提高可扩展性和灵活性吧。

    说明一下,这里之所以还记录了每个 Item 对应的 View,是因为有的业务方可能会用到 View,例如用来拿到 Tag

        private void templateAddData(int position, List<Integer> positionList, List<View> viewList) {
            View positionView = null;
            try {
                positionView = mLayoutManager.findViewByPosition(position);
            } catch (Exception e) {
                e.printStackTrace();
            }
            if (null == positionView) {
                return;
            }
            if (positionView.getVisibility() == View.GONE) {
                return;
            }
            int count = mReportData.get(position);
            mReportData.put(position, count + 1);
            if (null != positionList && null != viewList) {
                positionList.add(position);
                viewList.add(positionView);
            }
        }
    

    存储集合的选择上,由于是key/value模型,并且都是int类型,这里采用了SparseIntArray进行存储,该集合对双int类型有特殊优化,可以达到比普通HashMap更快的存储效率。

    /**
     * SparseIntArrays map integers to integers.  Unlike a normal array of integers,
     * there can be gaps in the indices.  It is intended to be more memory efficient
     * than using a HashMap to map Integers to Integers, both because it avoids
     * auto-boxing keys and values and its data structure doesn't rely on an extra entry object
     * for each mapping.
     *
     * 

    Note that this container keeps its mappings in an array data structure, * using a binary search to find keys. The implementation is not intended to be appropriate for * data structures * that may contain large numbers of items. It is generally slower than a traditional * HashMap, since lookups require a binary search and adds and removes require inserting * and deleting entries in the array. For containers holding up to hundreds of items, * the performance difference is not significant, less than 50%.

    * *

    It is possible to iterate over the items in this container using * {@link #keyAt(int)} and {@link #valueAt(int)}. Iterating over the keys using * keyAt(int) with ascending values of the index will return the * keys in ascending order, or the values corresponding to the keys in ascending * order in the case of valueAt(int).

    */
    public class SparseIntArray implements Cloneable{}

外部实现

外部实现就很简单了,唯一需要注意的是 release 检查,直接上代码:

/**
 * Created by whr on 2018/12/27.
 * RecyclerView Item曝光数据统计
 * 外部实现
 */
class ItemViewReporterImpl extends ItemViewReporterBase {

    ItemViewReporterImpl(@NonNull RecyclerView recyclerView) {
        super(recyclerView);
    }

    @Override
    public void reset() {
        templateCheck();
        mHandler.removeCallbacksAndMessages(null);
        mReportData.clear();
        mOldFirstComPt = -1;
        mOldLastComPt = -1;
        mLastResumeTime = 0;
        mLastTouchTime = 0;
    }

    @Override
    public void release() {
        templateCheck();
        mIsRelease = true;
        mRecyclerView.removeOnScrollListener(mScrollListener);
        mHandler.getLooper().quit();
        mHandlerThread.quit();
        mReportData.clear();
        mExposeCallback = null;
        mRecyclerView = null;
    }

    @Override
    public boolean isReleased() {
        return mIsRelease;
    }

    @Override
    public SparseIntArray getData() {
        templateCheck();
        return mReportData;
    }

    @Override
    public void setOnExposeCallback(OnExposeCallback exposeCallback) {
        this.mExposeCallback = exposeCallback;
    }

    @Override
    public void onResume() {
        templateCheck();
        mLastResumeTime = templateTimeCtrl(mLastResumeTime, mIntervalResume, WHAT_RESUME);
    }

    @Override
    public void setResumeInterval(long interval) {
        templateCheck();
        this.mIntervalResume = interval;
    }

    @Override
    public void setTouchInterval(long interval) {
        templateCheck();
        this.mIntervalTouch = interval;
    }
}

    /**
     * 模板代码
     * 统一处理非法调用
     */
    protected void templateCheck() {
        if (mIsRelease) {
            throw new RuntimeException("this is released");
        }
    }

结语

以上就是一个相对完整的列表曝光统计的分析、设计以及实现

本项目已在 GitHub 上开源,有需要的点击这里,如果对你有帮助记得点赞加关注哦~

你可能感兴趣的:(Android,列表,曝光,统计,Android,RecyclerView)