摘要:目前貝殼找房的曝光政策邊界條件比較單一, 都是APP端寫死的邏輯;對标一線公司解決方案, 是由API下發每種卡片/Feed的門限條件, 進而得到更精準的資料。
一、背景
目前貝殼找房APP端的曝光時機是寫死的, 觸發條件:卡片必須要完整展示在界面上; 在清單界面上下/左右滑動時單次/多次曝光同一個卡片。
現有方案的不足:
1、門限條件應改為API下發的; 2、缺少卡片在界面上顯示的時長;
反例:
1、比如說清單有1000條記錄,快速滑動清單到最後一條;使用者并沒有看清中間的900多條記錄,這時要不要為這些記錄做曝光埋點?
2、例如一個卡片高度為100px,實際上隻顯示了80px,是否要做一次曝光埋點。
目前問題:如果隻滑動這個程度,目前app不會為“附近地圖”做曝光埋點,但該卡片的主要資訊都已經展示了
行業對标:
今日頭條、手機百度的曝光埋點政策做的很細, 比如卡片劃入、劃出時間,卡片顯示多少比例可以算曝光等等。
二、解決方案
參考今日頭條、手機百度的做法,實作類似的曝光政策。
1、為每種卡片設定不同的曝光政策;
2、APP根據API下發的門限條件觸發埋點;
3、記錄卡片移入、移出螢幕的時間, 統計每個卡片真正顯示的時長;
4、界面銷毀、顯示/隐藏是否觸發曝光埋點。例如按home鍵時是否觸發曝光埋點,再次進入是否觸發埋點。 這些場景由API下發配置開關。
雙向隊列緩存目前RecyclerView顯示的所有ViewHolder, 用于執行卡片的曝光埋點函數。
在監聽RecyclerView滑動事件時得到第一個可見位置、最後一個可見位置,根據參數判斷是上滑或下滑,通過判斷ViewHolder的itemView top、bottom參數值得出剛剛移入螢幕的卡片顯示比例, 并根據API下發的門限值(最低顯示比例)記錄開始時間,在卡片即将劃出螢幕時(API下發的門限值)觸發曝光埋點。 進而得出卡片的顯示周期。
三、參考代碼
滑動回調
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;
}
}