Android 列表曝光数据统计全面解析
列表曝光统计
开发越往后走,越发觉察到数据的宝贵,所谓量变产生质变,即便是一些平时看上去无足轻重的数据一旦量上去了加以分析也会是一比巨大的财富。
列表可以说是当下互联网产品中最最最常见的呈现形式了,几乎所有内容都可以用列表的方式进行展示,同时也是最好的方式没有之一。
当一个产品规模到达一定量级后为了进一步提升用户体验往往产品或者项目 leader 会提出这样一个需求:统计列表曝光数据。这也就是今天这篇文章的主题,希望可以通过本文为有同样需求的童鞋一些思路和节省重复造轮子的时间。
需求整理
顾名思义,列表曝光统计核心需求可分为"曝光"和"统计",以下分别对两个核心需求进行整理。
曝光
曝光的定义根据需求可能会有所不同,这里按照广义上的定义:
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.* <p>* @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.* <p>* 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() {}@NonNullpublic 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 {@Overridepublic void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {/*** newState* 0:完全停止滚动* 1: 手指点击* 2:惯性滑动中*/if (newState == 0) {onView();}}@Overridepublic 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);}@Overridepublic 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.** <p>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%.</p>** <p>It is possible to iterate over the items in this container using* {@link #keyAt(int)} and {@link #valueAt(int)}. Iterating over the keys using* <code>keyAt(int)</code> 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 <code>valueAt(int)</code>.</p>*/ 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);}@Overridepublic void reset() {templateCheck();mHandler.removeCallbacksAndMessages(null);mReportData.clear();mOldFirstComPt = -1;mOldLastComPt = -1;mLastResumeTime = 0;mLastTouchTime = 0;}@Overridepublic void release() {templateCheck();mIsRelease = true;mRecyclerView.removeOnScrollListener(mScrollListener);mHandler.getLooper().quit();mHandlerThread.quit();mReportData.clear();mExposeCallback = null;mRecyclerView = null;}@Overridepublic boolean isReleased() {return mIsRelease;}@Overridepublic SparseIntArray getData() {templateCheck();return mReportData;}@Overridepublic void setOnExposeCallback(OnExposeCallback exposeCallback) {this.mExposeCallback = exposeCallback;}@Overridepublic void onResume() {templateCheck();mLastResumeTime = templateTimeCtrl(mLastResumeTime, mIntervalResume, WHAT_RESUME);}@Overridepublic void setResumeInterval(long interval) {templateCheck();this.mIntervalResume = interval;}@Overridepublic void setTouchInterval(long interval) {templateCheck();this.mIntervalTouch = interval;}
}/*** 模板代码* 统一处理非法调用*/protected void templateCheck() {if (mIsRelease) {throw new RuntimeException("this is released");}}
结语
以上就是一个相对完整的列表曝光统计的分析、设计以及实现。
本项目已在 GitHub 上开源,有需要的点击这里,如果对你有帮助记得点赞加关注哦~
Android 列表曝光数据统计全面解析相关推荐
- Android之用 ExpandableListView使用解析(三级列表的实现)
Android之用 ExpandableListView使用解析(三级列表的实现) 下载地址如下:http://download.csdn.net/download/u011068702/983 ...
- Android Fragment 真正的完全解析
转载请标明出处:http://blog.csdn.net/lmj623565791/article/details/37992017 Hongyang 目录视图 摘要视图 订阅 有奖征资源,博文分享有 ...
- android tun0 流量统计,Android应用流量统计——NetworkStatsManager使用-Go语言中文社区...
在没有Root的情况下,Android应用流量统计在6.0之前一直没有太好的办法,官方虽然提供了TrafficStats,但其主要功能是设备启动以来流量的统计信息,和时间信息无法很好的配合.最近再看T ...
- android面试题(深度解析)
1.activity的生命周期. 方法 描述 可被杀死 下一个 onCreate() 在activity第一次被创建的时候调用.这里是你做所有初始化设置的地方──创建视图.设置布局.绑定数据至列表等. ...
- android解析json异常处理,Android的JSON异常而解析
我试图分析我的Android应用程序中的JSON(进一步我将填补与内容列表视图),所以我做了这个类:Android的JSON异常而解析 public class MainActivity extend ...
- Android通知系统源码解析
Android通知系统源码解析 1. 概述 2. 流程图 2.1. 发送通知流程图 3. 源码解析 3.1. 使用通知--APP进程 3.1.1. 创建通知: 3.1.2. 发送(更新)通知: 3.1 ...
- Android ListView工作原理完全解析,带你从源码的角度彻底理解
转载请注明出处:http://blog.csdn.net/guolin_blog/article/details/44996879 在Android所有常用的原生控件当中,用法最复杂的应该就是List ...
- Android 水果机游戏实例解析
[Cocos2d-x相关教程来源于红孩儿的游戏编程之路 CSDN博客地址:http://blog.csdn.net/honghaier] Android 水果机游戏实例解析 近段时 ...
- 转载自android 开发--抓取网页解析网页内容的若干方法(网络爬虫)(正则表达式)
转载自http://blog.csdn.net/sac761/article/details/48379173 android 开发--抓取网页解析网页内容的若干方法(网络爬虫)(正则表达式) 标签: ...
最新文章
- 004_Maven构建生命周期
- php证书格式,常用的证书格式转换 - niceguy_php的个人空间 - OSCHINA - 中文开源技术交流社区...
- TensorFlow RNN tutorial解读
- 我是如何使用laydate日历插件更换掉老项目不好用的日历插件datepicker的
- NewCode----求数列的和
- 作为后端开发如何设计数据库系列文章 设计SaaS系统表结构
- 实战 | Element UI 父子组件传值与事件绑定(逆向)
- 学校运动会广播稿计算机,学校运动会广播稿【五篇】
- win10系统计算机物理地址,Win10电脑mac地址如何查看 win10系统查看mac地址的方法...
- SIM868——通过NTP获取本地时间的方法
- 四、随机变量及其分布函数的基本定义和性质 random variables and distribution
- 程序员不要总想着四两拨千斤
- SpringSecurity实现自定义登录界面
- 如何批量给pdf文件加密?
- 用Excel获取数据——不仅仅只是打开表格
- 【原创】《矩阵的史诗级玩法》连载十六:二元二次方程一般式和圆锥曲线的关系(下)
- closing entry怎么做_牛排三分熟怎么说?刀叉如何摆放?来看看这些西餐知识你知道多少?另有热门院校大盘点...
- Learning both Weights and Connections for Efficient Neural Networks
- 大学可以这样读——我的心路历程和一点思考
- socket接收消息 字符串长度