目錄
電商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上報不可見。 |
1、item上報可見時,如果已經之前上報可見了,就不上報;上報不可見時,如果上次上報了可見,才上報。
2、模塊標題的曝光就是模塊的曝光
3、item內的元素是 不可滑動/可滑動列表,是不同處理方式。其中元素是不可滑動時處理得比較粗糙,可以再優化下。
概念說明 |
1、邏輯可見:可見寬/高>50% |
2、視覺可見:模塊視覺上可見,無論看見多少。 |
說明:本文說的 寬高>50%、可見都是 邏輯可見。
三、曝光邏輯代碼說明
預備知識:view可見性的判斷
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;
}
}