天天看点

贝壳找房FEED流曝光策略

摘要:目前贝壳找房的曝光策略边界条件比较单一, 都是APP端写死的逻辑;对标一线公司解决方案, 是由API下发每种卡片/Feed的门限条件, 从而得到更精准的数据。

一、背景

目前贝壳找房APP端的曝光时机是写死的, 触发条件:卡片必须要完整展示在界面上; 在列表界面上下/左右滑动时单次/多次曝光同一个卡片。

现有方案的不足:

1、门限条件应改为API下发的; 2、缺少卡片在界面上显示的时长;

反例:

1、比如说列表有1000条记录,快速滑动列表到最后一条;用户并没有看清中间的900多条记录,这时要不要为这些记录做曝光埋点?

2、例如一个卡片高度为100px,实际上只显示了80px,是否要做一次曝光埋点。

贝壳找房FEED流曝光策略

当前问题:如果只滑动这个程度,目前app不会为“附近地图”做曝光埋点,但该卡片的主要信息都已经展示了

行业对标:

今日头条、手机百度的曝光埋点策略做的很细, 比如卡片划入、划出时间,卡片显示多少比例可以算曝光等等。

二、解决方案

参考今日头条、手机百度的做法,实现类似的曝光策略。

1、为每种卡片设置不同的曝光策略;

2、APP根据API下发的门限条件触发埋点;

3、记录卡片移入、移出屏幕的时间, 统计每个卡片真正显示的时长;

4、界面销毁、显示/隐藏是否触发曝光埋点。例如按home键时是否触发曝光埋点,再次进入是否触发埋点。 这些场景由API下发配置开关。

贝壳找房FEED流曝光策略

双向队列缓存当前RecyclerView显示的所有ViewHolder, 用于执行卡片的曝光埋点函数。

在监听RecyclerView滑动事件时得到第一个可见位置、最后一个可见位置,根据参数判断是上滑或下滑,通过判断ViewHolder的itemView top、bottom参数值得出刚刚移入屏幕的卡片显示比例, 并根据API下发的门限值(最低显示比例)记录开始时间,在卡片即将划出屏幕时(API下发的门限值)触发曝光埋点。 从而得出卡片的显示周期。

贝壳找房FEED流曝光策略

三、参考代码

滑动回调

public class CardExposureHelper extends RecyclerView.OnScrollListener {
 //缓存卡片的双向队列
  private Deque<BaseHomeCard> 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<recyclerView.getAdapter().getItemCount() && start<=end){
      if (isFirst){
        for (int i=end;i>=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<recyclerView.getAdapter().getItemCount() && start<=end){
      if (isFirst){
        for (int i=start;i<=end;i++){
          onVerticalItemSlideOut(i,isFirst);
        }
      }else {
        for (int i=end;i>=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;
 }
}