摘要:目前贝壳找房的曝光策略边界条件比较单一, 都是APP端写死的逻辑;对标一线公司解决方案, 是由API下发每种卡片/Feed的门限条件, 从而得到更精准的数据。
一、背景
目前贝壳找房APP端的曝光时机是写死的, 触发条件:卡片必须要完整展示在界面上; 在列表界面上下/左右滑动时单次/多次曝光同一个卡片。
现有方案的不足:
1、门限条件应改为API下发的; 2、缺少卡片在界面上显示的时长;
反例:
1、比如说列表有1000条记录,快速滑动列表到最后一条;用户并没有看清中间的900多条记录,这时要不要为这些记录做曝光埋点?
2、例如一个卡片高度为100px,实际上只显示了80px,是否要做一次曝光埋点。
行业对标:
今日头条、手机百度的曝光埋点策略做的很细, 比如卡片划入、划出时间,卡片显示多少比例可以算曝光等等。
二、解决方案
参考今日头条、手机百度的做法,实现类似的曝光策略。
1、为每种卡片设置不同的曝光策略;
2、APP根据API下发的门限条件触发埋点;
3、记录卡片移入、移出屏幕的时间, 统计每个卡片真正显示的时长;
4、界面销毁、显示/隐藏是否触发曝光埋点。例如按home键时是否触发曝光埋点,再次进入是否触发埋点。 这些场景由API下发配置开关。
在监听RecyclerView滑动事件时得到第一个可见位置、最后一个可见位置,根据参数判断是上滑或下滑,通过判断ViewHolder的itemView top、bottom参数值得出刚刚移入屏幕的卡片显示比例, 并根据API下发的门限值(最低显示比例)记录开始时间,在卡片即将划出屏幕时(API下发的门限值)触发曝光埋点。 从而得出卡片的显示周期。
三、参考代码
滑动回调
public class CardExposureHelper extends RecyclerView.OnScrollListener {
//缓存卡片的双向队列
private Deque deque;
//队列顶部Card的position
private int preFirstExposure;
//队列底部Card的position
private int preLastExposure;
/**
* 处理垂直方向卡片曝光
* @param manager
* @param isUp 是否向上滑动
*/
private void onVerticalExposure(LinearLayoutManager manager,boolean isUp) {
int firstVisiblePosition = manager.findFirstVisibleItemPosition();
int lastVisiblePosition = manager.findLastVisibleItemPosition();
//根据曝光比例判断第一个可见卡片是否需要曝光
firstVisiblePosition = isVerticalExposure(firstVisiblePosition)?firstVisiblePosition:firstVisiblePosition+1;
//根据曝光比例判断最后一个可见卡片是否需要曝光
lastVisiblePosition = isVerticalExposure(lastVisiblePosition)?lastVisiblePosition:lastVisiblePosition-1;
//第一次曝光,曝光所有符合曝光比例的Card
if (preFirstExposure==0&&preLastExposure==0){
offerVerticalVisibleQueue(firstVisiblePosition,lastVisiblePosition,true);
}else if (isUp){
//向上滑动,把顶部不可见Card从顶部出队,底部进入可曝光的卡片入队
popVerticalVisibleQueue(preFirstExposure,firstVisiblePosition-1,true);
offerVerticalVisibleQueue(preLastExposure+1,lastVisiblePosition,false);
}else {
//对应向下滑动的策略
popVerticalVisibleQueue(lastVisiblePosition+1,preLastExposure,false);
offerVerticalVisibleQueue(firstVisiblePosition,preFirstExposure-1,true);
}
//更新队列的顶部position和底部position
preFirstExposure = firstVisiblePosition;
preLastExposure = lastVisiblePosition;
}
/**
* 入队操作
* @param start
* @param end
* @param isFirst 是否从顶部入队
*/
private void offerVerticalVisibleQueue(int start,int end,boolean isFirst){
if (start>=0 && end=start;i--){
onVerticalItemSlideInto(i,true);
}
}else {
for (int i=start;i<=end;i++){
onVerticalItemSlideInto(i,false);
}
}
}
}
/**
* 出队操作
* @param start
* @param end
* @param isFirst 是否从顶部出队
*/
private void popVerticalVisibleQueue(int start,int end,boolean isFirst){
if (start>=0 && end=start;i--){
onVerticalItemSlideOut(i,isFirst);
}
}
}
}
/**
* 处理滑入(入队)可曝光的卡片
* @param position
* @param isFirst 是否从顶部滑入(入队)
*/
private void onVerticalItemSlideInto(int position,boolean isFirst){
BaseHomeCard card = getBaseHomeCard(position);
if (isFirst){
deque.offerFirst(card);
}else {
deque.offerLast(card);
}
//回调卡片开始曝光事件
callItemExposure(card,position);
}
/**
* 处理滑出(出队)停止曝光的卡片
* @param position
* @param isFirst 是否从顶部滑出(出队)
*/
private void onVerticalItemSlideOut(int position,boolean isFirst){
BaseHomeCard card;
if (isFirst){
card = deque.removeFirst();
}else {
card = deque.removeLast();
}
//回调卡片结束曝光事件
callItemEndExposure(card,position,isFirst);
}
四、FEED初次渲染时长
刷新FEED流埋点统计冷启动完成时间, 包括https/http时间、端上数据处理用时、measure/layout/draw用时, 参考今日头条的做法将第一条Feed绘制完第一帧认为是冷启动完成事件。
public class TimeActivity extends Activity implements FrameDrawCallBack {
private ListView listView;
private long beginTime; //界面启动开始时间
@Override protected void onCreate(@Nullable Bundle savedInstanceState) {
beginTime = System.currentTimeMillis();
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_timelist);
listView = (ListView) findViewById(R.id.listview);
final TimeAdapter adapter = new TimeAdapter(this, this);
new Handler().postDelayed(new Runnable() {
@Override public void run() {
listView.setAdapter(adapter);
}
}, 1000);
}
@Override public void onDrawComplet(long time) {
long diff = time - beginTime;
Log.d("brycegao", "列表首帧渲染完成用时:" + diff); //FEED流第一帧渲染完成时间,埋点上报
}
private class TimeAdapter extends BaseAdapter {
private Context mContext;
private FrameDrawCallBack mCallBack;
TimeAdapter(Context context, FrameDrawCallBack callBack) {
mContext = context;
mCallBack = callBack;
}
@Override public int getCount() {
return 30;
}
@Override public Object getItem(int position) {
return null;
}
@Override public long getItemId(int position) {
return position;
}
private void addFirstItemDraw(final View view) {
if (view == null) {
return;
}
view.getViewTreeObserver().addOnDrawListener(
new ViewTreeObserver.OnDrawListener() {
@Override public void onDraw() {
view.getViewTreeObserver().removeOnDrawListener(this);
mCallBack.onDrawComplet(System.currentTimeMillis());
}
});
}
@Override public View getView(int position, View convertView, ViewGroup parent) {
ViewHolder holder;
if (convertView == null) {
convertView = LayoutInflater.from(mContext).inflate(R.layout.item_testlistview_layout,
null, false);
holder = new ViewHolder();
holder.tvContent = (TextView) convertView.findViewById(R.id.tv_content);
convertView.setTag(holder);
if (position == 0) {
addFirstItemDraw(convertView);
}
} else {
holder = (ViewHolder) convertView.getTag();
}
holder.tvContent.setText("第" + position + "条记录");
return convertView;
}
}
private class ViewHolder {
TextView tvContent;
}
}