电商app的首页,一般是可滑动列表,当用户上下滑动时,列表中的item可能会多次出现在屏幕上。某个item从出现到消失的过程大于某一时间(比如1s),就认为是一次曝光。数据分析同事对这些曝光数据的分析,可用于针对用户进行商品喜好的推荐。
那如何实现 列表(recyclerView)中item的曝光埋点呢?
首先,客户端要考虑的就是只管调用api上报:上报item可见、上报item不可见。至于是否是有效曝光,就是公共埋点SDK(中台提供)去计算了。
所以本文重点就是,滑动recyclerView时 item变为可见、变为不可见,什么时候、怎么样 上报。
如下淘宝首页,竖向列表中有很多模块item,聚划算、天天特卖、猜你喜欢等等。而每个模块内部又有多个子item,比如:可横向滑动的菜单模块内有两排菜单、聚划算内展示了两个商品。
这里先列出实现逻辑。
上报时机 | 回调实现 |
---|---|
刚进入页面时(可见且>50%:上报可见) | 第一次onScroll |
手指拖动滑动时( 不停的:不可见或<50%:上报消失、可见且>50%:上报可见 | onScroll、且SCROLL_STATE_TOUCH_SCROLL |
滑动停止时( <50%(之前上报过可见):上报消失;可见且>50%:上报可见 ) | onScrollStateChanged、SCROLL_STATE_IDLE |
FLING时 | onScrollStateChanged、SCROLL_STATE_FLINNG |
上报时机就对应recyclerView的滚动监听的两个方法,onScrollStateChanged、onScrolled。
列表item曝光逻辑 |
---|
item的曝光:下一次上报item时,看上次上报可见的 是否不可见了。 |
title“more”的曝光:根据模块可见就上报可见,模块不可见就上报不可见| |
无横(竖)滑的模块 的子view,根据模块可见性 全部子view都上报相同的可见性。 |
有横(竖)滑的模块 的子view:若模块可见,就上报 当前子列表中 的可见子模块 ;同时处理子列表滑动时的item可见性;模块不可见,那当前子列表的可见view上报不可见。 |
概念说明:
说明:本文说的 宽高>50%、可见都是 逻辑可见。
预备知识,view可见性的判断,https://www.jianshu.com/p/30b0ae304518
1、对recyclerView的滚动监听
滚动监听的目的:滑动中item是可能多次曝光的,在列表 静止、手指拖动、快速滑动时都要 监听item的可见性,然后把可见或不可见回调,然后根据position具体上报item信息。
/**
* 设置RecyclerView的item可见状态的监听
* @param recyclerView recyclerView
* @param onExposeListener 列表中的item可见性的回调
*/
public void setRecyclerItemExposeListener(RecyclerView recyclerView, OnItemExposeListener onExposeListener) {
mItemOnExposeListener = onExposeListener;
mRecyclerView = recyclerView;
if (mRecyclerView == null || mRecyclerView.getVisibility() != View.VISIBLE) {
return;
}
//检测recyclerView的滚动事件
mRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
//关注:SCROLL_STATE_IDLE:停止滚动; SCROLL_STATE_DRAGGING: 用户慢慢拖动
// 关注:SCROLL_STATE_SETTLING:惯性滚动
if (newState == RecyclerView.SCROLL_STATE_IDLE
|| newState == RecyclerView.SCROLL_STATE_DRAGGING
|| newState == RecyclerView.SCROLL_STATE_SETTLING) {
handleCurrentVisibleItems();
}
}
@Override
public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
super.onScrolled(recyclerView, dx, dy);
//包括刚进入列表时统计当前屏幕可见views
handleCurrentVisibleItems();
}
});
}
2、具体的处理逻辑在handleCurrentVisibleItems中,主要两点:1,判断recyclerView视觉可见,2、获取此时recyclerView中 第一个、最后一个 视觉可见item的position。
/**
* 处理 当前屏幕上mRecyclerView可见的item view
*/
public void handleCurrentVisibleItems() {
//1、View.getGlobalVisibleRect(new Rect()),true表示view视觉可见,无论可见多少。
if (mRecyclerView == null || mRecyclerView.getVisibility() != View.VISIBLE ||
!mRecyclerView.isShown() || !mRecyclerView.getGlobalVisibleRect(new Rect())) {
return;
}
//保险起见,为了不让统计影响正常业务,这里做下try-catch
try {
int[] range = new int[2];
int orientation = -1;
RecyclerView.LayoutManager manager = mRecyclerView.getLayoutManager();
if (manager instanceof LinearLayoutManager) {
LinearLayoutManager linearLayoutManager = (LinearLayoutManager) manager;
range = findRangeLinear(linearLayoutManager);
orientation = linearLayoutManager.getOrientation();
} else if (manager instanceof GridLayoutManager) {
GridLayoutManager gridLayoutManager = (GridLayoutManager) manager;
range = findRangeGrid(gridLayoutManager);
orientation = gridLayoutManager.getOrientation();
} else if (manager instanceof StaggeredGridLayoutManager) {
StaggeredGridLayoutManager staggeredGridLayoutManager = (StaggeredGridLayoutManager) manager;
range = findRangeStaggeredGrid(staggeredGridLayoutManager);
orientation = staggeredGridLayoutManager.getOrientation();
}
if (range == null || range.length < 2) {
return;
}
XLogUtil.d("屏幕内可见条目的起始位置:" + range[0] + "---" + range[1]);
//2 注意,这里 会处理此刻 滑动过程中 所有可见的view
for (int i = range[0]; i <= range[1]; i++) {
View view = manager.findViewByPosition(i);
setCallbackForLogicVisibleView(view, i, orientation);
}
} catch (Exception e) {
e.printStackTrace();
XLogUtil.d(e.getMessage());
}
}
3、然后交给setCallbackForLogicVisibleView处理每个视觉可见position,就是判断是否是 逻辑可见(宽或高大于50%),然后回调出去。注意,这里回调出去的的逻辑可见、逻辑不可见,都是 在视觉可见的基础上 判断 宽或高是否大于50% 。
/**
* 为 逻辑上可见的view设置 可见性回调
* 说明:逻辑上可见--可见且可见高度(宽度)>view高度(宽度)的50%
* @param view 可见item的view
* @param position 可见item的position
* @param orientation recyclerView的方向
*/
private void setCallbackForLogicVisibleView(View view, int position, int orientation) {
if (view == null || view.getVisibility() != View.VISIBLE ||
!view.isShown() || !view.getGlobalVisibleRect(new Rect())) {
return;
}
Rect rect = new Rect();
boolean cover = view.getGlobalVisibleRect(rect);
//item逻辑上可见:可见且可见高度(宽度)>view高度(宽度)50%才行
boolean visibleHeightEnough = orientation == OrientationHelper.VERTICAL && rect.height() > view.getMeasuredHeight() / 2;
boolean visibleWidthEnough = orientation == OrientationHelper.HORIZONTAL && rect.width() > view.getMeasuredWidth() / 2;
boolean isItemViewVisibleInLogic = visibleHeightEnough || visibleWidthEnough;
if (cover && mIsRecyclerViewVisibleInLogic && isItemViewVisibleInLogic) {
mItemOnExposeListener.onItemViewVisible(true, position);
}else {
mItemOnExposeListener.onItemViewVisible(false, position);
}
}
4、以上就是recyclerView item曝光的完整逻辑了。 如果item内部 是 可滑动的recyclerView,那么就item可见时 子列表也做滚定监听就可以了,即内部的recyclerView也是用setRecyclerItemExposeListener。
建议,调用setRecyclerItemExposeListener给recyclerView设置曝光监听的listener直接传adapter,在adapter实现回调方法,然后就可以根据回调的position调用埋点 sdk的可见、不可见api上报信息了。
5、完整代码如下
曝光监听接口:
public interface OnItemExposeListener {
/**
* item 可见性回调
* 回调此方法时 视觉上一定是可见的(无论可见多少)
* @param visible true,逻辑上可见,即宽/高 >50%
* @param position item在列表中的位置
*/
void onItemViewVisible(boolean visible, int position);
}
曝光(可见性) 监听工具,主要方法setRecyclerItemExposeListener:
public class HomePageExposeUtil {
private OnItemExposeListener mItemOnExposeListener;
/**
* 列表是否逻辑上可见
*
* 默认true:意思是 RecyclerView的可见性没有外部逻辑的判断
* false:例如,人气商品模块,横滑的商品RecyclerView,逻辑上是 人气商品模块 出现一半 时 商品RecyclerView才算可见。
* 所以一开始设置为false,人气商品模块 出现 大于一半时,设置为true。
*/
private boolean mIsRecyclerViewVisibleInLogic = true;
private RecyclerView mRecyclerView;
/**
* 一般使用这个即可
*/
public HomePageExposeUtil() {
mIsRecyclerViewVisibleInLogic = true;
}
/**
* 当RecyclerView本身的可见性 受外部逻辑控制时 使用,
* @param isRecyclerViewVisibleInLogic
*/
public HomePageExposeUtil(boolean isRecyclerViewVisibleInLogic) {
mIsRecyclerViewVisibleInLogic = isRecyclerViewVisibleInLogic;
}
/**
* 设置RecyclerView的item可见状态的监听
* @param recyclerView recyclerView
* @param onExposeListener 列表中的item可见性的回调
*/
public void setRecyclerItemExposeListener(RecyclerView recyclerView, OnItemExposeListener onExposeListener) {
mItemOnExposeListener = onExposeListener;
mRecyclerView = recyclerView;
if (mRecyclerView == null || mRecyclerView.getVisibility() != View.VISIBLE) {
return;
}
//检测recyclerView的滚动事件
mRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
//关注:SCROLL_STATE_IDLE:停止滚动; SCROLL_STATE_DRAGGING: 用户慢慢拖动
// 关注:SCROLL_STATE_SETTLING:惯性滚动
if (newState == RecyclerView.SCROLL_STATE_IDLE
|| newState == RecyclerView.SCROLL_STATE_DRAGGING
|| newState == RecyclerView.SCROLL_STATE_SETTLING) {
handleCurrentVisibleItems();
}
}
@Override
public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
super.onScrolled(recyclerView, dx, dy);
//包括刚进入列表时统计当前屏幕可见views
handleCurrentVisibleItems();
}
});
}
/**
* 处理 当前屏幕上mRecyclerView可见的item view
*/
public void handleCurrentVisibleItems() {
//View.getGlobalVisibleRect(new Rect()),true表示view视觉可见,无论可见多少。
if (mRecyclerView == null || mRecyclerView.getVisibility() != View.VISIBLE ||
!mRecyclerView.isShown() || !mRecyclerView.getGlobalVisibleRect(new Rect())) {
return;
}
//保险起见,为了不让统计影响正常业务,这里做下try-catch
try {
int[] range = new int[2];
int orientation = -1;
RecyclerView.LayoutManager manager = mRecyclerView.getLayoutManager();
if (manager instanceof LinearLayoutManager) {
LinearLayoutManager linearLayoutManager = (LinearLayoutManager) manager;
range = findRangeLinear(linearLayoutManager);
orientation = linearLayoutManager.getOrientation();
} else if (manager instanceof GridLayoutManager) {
GridLayoutManager gridLayoutManager = (GridLayoutManager) manager;
range = findRangeGrid(gridLayoutManager);
orientation = gridLayoutManager.getOrientation();
} else if (manager instanceof StaggeredGridLayoutManager) {
StaggeredGridLayoutManager staggeredGridLayoutManager = (StaggeredGridLayoutManager) manager;
range = findRangeStaggeredGrid(staggeredGridLayoutManager);
orientation = staggeredGridLayoutManager.getOrientation();
}
if (range == null || range.length < 2) {
return;
}
XLogUtil.d("屏幕内可见条目的起始位置:" + range[0] + "---" + range[1]);
// 注意,这里 会处理此刻 滑动过程中 所有可见的view
for (int i = range[0]; i <= range[1]; i++) {
View view = manager.findViewByPosition(i);
setCallbackForLogicVisibleView(view, i, orientation);
}
} catch (Exception e) {
e.printStackTrace();
XLogUtil.d(e.getMessage());
}
}
/**
* 为 逻辑上可见的view设置 可见性回调
* 说明:逻辑上可见--可见且可见高度(宽度)>view高度(宽度)的50%
* @param view 可见item的view
* @param position 可见item的position
* @param orientation recyclerView的方向
*/
private void setCallbackForLogicVisibleView(View view, int position, int orientation) {
if (view == null || view.getVisibility() != View.VISIBLE ||
!view.isShown() || !view.getGlobalVisibleRect(new Rect())) {
return;
}
Rect rect = new Rect();
boolean cover = view.getGlobalVisibleRect(rect);
//item逻辑上可见:可见且可见高度(宽度)>view高度(宽度)50%才行
boolean visibleHeightEnough = orientation == OrientationHelper.VERTICAL && rect.height() > view.getMeasuredHeight() / 2;
boolean visibleWidthEnough = orientation == OrientationHelper.HORIZONTAL && rect.width() > view.getMeasuredWidth() / 2;
boolean isItemViewVisibleInLogic = visibleHeightEnough || visibleWidthEnough;
if (cover && mIsRecyclerViewVisibleInLogic && isItemViewVisibleInLogic) {
mItemOnExposeListener.onItemViewVisible(true, position);
}else {
mItemOnExposeListener.onItemViewVisible(false, position);
}
}
private int[] findRangeLinear(LinearLayoutManager manager) {
int[] range = new int[2];
range[0] = manager.findFirstVisibleItemPosition();
range[1] = manager.findLastVisibleItemPosition();
return range;
}
private int[] findRangeGrid(GridLayoutManager manager) {
int[] range = new int[2];
range[0] = manager.findFirstVisibleItemPosition();
range[1] = manager.findLastVisibleItemPosition();
return range;
}
private int[] findRangeStaggeredGrid(StaggeredGridLayoutManager manager) {
int[] startPos = new int[manager.getSpanCount()];
int[] endPos = new int[manager.getSpanCount()];
manager.findFirstVisibleItemPositions(startPos);
manager.findLastVisibleItemPositions(endPos);
int[] range = findRange(startPos, endPos);
return range;
}
private int[] findRange(int[] startPos, int[] endPos) {
int start = startPos[0];
int end = endPos[0];
for (int i = 1; i < startPos.length; i++) {
if (start > startPos[i]) {
start = startPos[i];
}
}
for (int i = 1; i < endPos.length; i++) {
if (end < endPos[i]) {
end = endPos[i];
}
}
int[] res = new int[]{start, end};
return res;
}
public void setIsRecyclerViewVisibleInLogic(boolean mIsRecyclerViewVisibleInLogic) {
this.mIsRecyclerViewVisibleInLogic = mIsRecyclerViewVisibleInLogic;
}
}