這篇文章是《聊實用功能設計系列》的第一期,聊一聊我們線上項目信息流卡片曝光/消費的設計思路。
系列會拆分多期講解線上項目中重要功能的設計過程。側重點在于讓讀者理解功能模塊的設計意義及整體搭建思路,大部分細節實現各項目自行實現即可。
背景
市場上大部分 APP 都采用了信息流的設計形式來承載信息內容展示,不同特征產品所設計的信息流形式不盡相同。
比如圖片瀑布流,商品列表流或資訊Feed流等。
(淘寶商品流)
(懂車帝視頻流)
盡管展示方式不同,但內容在列表中的流向是一致的,也都以列表滑動的交互來曝光更多內容。
而列表內容的展示效果可通過統計列表項的曝光次數/點擊次數/曝光時刻/滑動時刻等數據進行多維度計算衡量。
如常見的點擊率,消費時長。
點擊率=點擊次數/曝光次數,點擊率越高則表示用戶對內容進一步了解的意向越高。
消費時長=t(滑動時刻)-t(上一次曝光時刻),消費時長越長則表示用戶對內容興趣越高。
當然,不同列表內容的計算規則有所差異,考察內容展示效果的側重點也不應相同。
如資訊類 Feed 流中內容項更多是以圖文混排的展示形式來吸引用戶進行點擊消費,更關注內容的點擊率。
而對于視頻流內短視頻快消類內容項,一般支持在流上直接播放,更關注內容的觀看消費時長。
所以信息流列表項的曝光信息/消費數據對于衡量流投放效果至關重要,那么如何獲取這些信息呢?
設計思路
以我們線上項目為例子。應用內信息流頁面非常多。
有類微博的動態卡片流。
類資訊類卡片流。
雙列視頻流。
上述僅是 3 個常見場景,如果針對所有場景列表做設計,顯然可維護性及擴展性非常差,所以必須考慮做成通用性設計。
我們約定幾個高頻詞匯含義,后續描述都采用其代替表述。
- 卡片,任意一個信息流列表項。
- 曝光,卡片的曝光行為。
- 消費,卡片的消費行為。
- 有效面積,卡片暴露在界面的可見面積。
- 有效面積率,卡片有效面積與卡片視圖總面積的比率,一般可選 1/2 ~ 4/5。
- 有效卡片,卡片的有效面積率高于某個閾值時該卡片視為有效卡片,常見為 2/3,3/5。
- 最小有效消費時長,當有效卡片的消費時長小于該時長時則認為用戶并無消費行為,一般可選再 100~200 毫秒。
有效面積是用于計算卡片在界面的可視大小,當某個卡片可視區域太小了,哪怕是長期停留,我們都認為用戶對其內容并無感知。
有效卡片則是用戶對卡片內容有感知能力,這些卡片有機會得到曝光及消費。
有了上述約定,我們再定義一種通用的卡片曝光/消費規則:
- 當列表靜止且用戶與界面無接觸時,列表內的有效卡片記一次曝光行為;
- 已靜止列表重新開始滑動時,原靜止列表內的有效卡片記一次消費行為。
則按照規則,卡片一次曝光消費的邏輯如下:
- 在列表靜止且用戶與界面無接觸時,收集可見的所有卡片;
- 過濾有效面積率小于 3/5 的卡片得到有效卡片集進行緩存,得到緩存集 list[當前有效卡片];
- 針對有效卡片集進行曝光并記錄曝光時刻點 t[exposure],實際上這個時間也是開始消費的時間 t[startConsume]。
- 當列表開始滑動時,獲取當前時間戳減去上一次曝光時間得到該次消費時長,若消費時長不短于 100毫秒,則list[當前有效卡片] 進行消費記錄。
- 擴增強擴展。預留上述流程支持關鍵參數:比如是否動態調整有效面積率,最小有效消費時長等。
另外,還可控制是否同一個界面內同一個卡片是否允許多次曝光,或控制卡片曝光的間隙等,按需實現即可。
具體實現
定義一個接口 CardLogFeed 描述卡片。
public interface CardLogFeed {
//業務定義的卡片信息類,自定擴展修改
@Nullable
CardLogInfo buildCardLogInfo();
//觸發曝光回調
void logExposure();
//觸發消費回調
void logConsume(long time);
//是否是有效卡片
boolean isVaildLogUnit();
}
每一個卡片曝光/消費的回調行為不盡相同,開放給業務方實現。
CardLogInfo 類記錄每個卡片應上報的日志信息,一般情況下曝光/消費都需要上報這些信息。
isVaildLogUnit 方法要求業務方告知當前卡片是否為有效卡片。該方法實現了設計思路章節涉及的有效卡片的判定邏輯。
另外,由于不同卡片樣式上可能存在差異,如長方形,正方形,圓形,甚至不規則圖形,實現思路并不局限在比較可視面積占比上,業務實現方自由選擇。
定義一個類CradLogScrollLisener類,繼承 RecyclerView.OnScrollListener 并實現列表相關的邏輯。
首先,重載 onScrollStateChanged 方法獲取列表狀態并根據不同狀態進行處理。
//記錄當前列表狀態
private int currentNewState = SCROLL_STATE_IDLE;
@Override
public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
super.onScrollStateChanged(recyclerView, newState);
switch (newState) {
//嘗試曝光
case SCROLL_STATE_IDLE: {
exposureCardLog();
break;
}
//嘗試消費
case SCROLL_STATE_DRAGGING: {
if (currentNewState != SCROLL_STATE_SETTLING) {
consumeCardLog(startConsumeTime);
}
break;
}
default:
break;
}
currentNewState = newState;
}
通過捕獲SCROLL_STATE_IDLE狀態嘗試曝光,exposureCardLog 方法內實現有效卡片的收集流程及曝光,下面為核心邏輯(只列核心邏輯)。
private void exposureCardLog() {
//記錄曝光時刻
startConsumeTime = System.nanoTime();
//收集有效卡片
currentValidVisibleFeeds.currentValidVisibleFeeds();
if (currentValidVisibleFeeds.isEmpty()) {
return;
}
//上傳曝光數據
for (CardLogFeed logFeed : currentValidVisibleFeeds) {
logFeed.logExposure();
}
}
最后一步把收集到的 CardLogFeed 對象(有效卡片)進行曝光,而這些收集的邏輯在 currentValidVisibleFeeds方法內實現。
public List<CardLogFeed> getCurrentValidVisibleFeeds() {
List<CardLogFeed> currentValidVisibleFeeds = new ArrayList<>();
//獲取layoutManager
RecyclerView.LayoutManager layoutManager = mRecyclerView.getLayoutManager();
//針對線性排版
if (layoutManager instanceof LinearLayoutManager) {
int firstVisibleIndex = ((LinearLayoutManager) layoutManager).findFirstVisibleItemPosition();
int lastVisibleIndex = ((LinearLayoutManager) layoutManager).findLastVisibleItemPosition();
for (int i = firstVisibleIndex; i <= lastVisibleIndex; i++) {
RecyclerView.ViewHolder viewHolder = mRecyclerView.findViewHolderForAdapterPosition(i);
if (viewHolder instanceof CardLogFeed && ((CardLogFeed) viewHolder).isVailLogUnit()) {
currentValidVisibleFeeds.add((CardLogFeed) viewHolder);
}
}
}
//針對瀑布流排版
if (layoutManager instanceof StaggeredGridLayoutManager) {
//...
}
//其他排版
return currentValidVisibleFeeds;
}
以線性排版為例子,獲取界面所有可見的 Holder 對象并判斷每一個對象是否為 CardLogFeed 類型且滿足有效卡片的判定條件,最終得到有效卡片集。
說完曝光,回到列表滑動時觸發的 consumeCardLog 消費方法。
//1毫秒=1000000納秒
private long DURATION_STEP = 1000000;
//定義最小有效消費時長 100 ms
private long MIN_CONSUME_DURATION = 100;
private void consumeCardLog(long startConsumeTime) {
//獲取時間間隔
long consumeTime = (System.nanoTime() - startConsumeTime)/DURATION_STEP;
if (consumeTime < MIN_CONSUME_DURATION) {
return;
}
if (currentValidVisibleFeeds.isEmpty()) {
return;
}
//卡片消費
for (CardLogFeed logFeed : currentValidVisibleFeeds) {
logFeed.logConsume(consumeTime);
}
}
列表開始滑動時,就拿上一次曝光緩存的有效卡片做一次消費記錄。
至此,完成一次完整的曝光/消費邏輯實現,核心流程不變,內部細節可自由發揮調整。
最后做個演示,當有效卡片曝光時設置成灰色,消費之后恢復原狀。
后話
信息流卡片曝光/消費數據反饋對應用內容的投放策略有很大的指導意義。這篇文章僅作為探討整體的程序設計思路,讓更多開發者在未來遇到相似的場景時可參考比較。
如果有更好的思路或意見建議,歡迎討論。
就開發者而言,有時候接到一個需求看似簡單的需求,實際上在開發過程中才遇到各種妖魔鬼怪,養成良好的設計模式對每一個程序開發者都極其重要。
記得有一次,策劃讓我們做信息流的視頻卡片的自動播放,說 “列表停下來后滿足自動播放條件的視頻卡片就播放,這應該挺簡單吧?”。
我:“....”
那系列下期就聊聊信息流常見下,富媒體項的選擇思路,包括 Gif 播放/視頻播放常見。
歡迎關注追更。
歡迎關注 「Android之禪」公眾號,和你分享有價值有思考的技術文章。
可添加微信 「Ming_Lyan」備注 “進群” 加入技術交流群,討論技術問題嚴禁一切廣告灌水。
如有 Android 領域有遇到技術難題亦或對未來職業規劃有疑惑,一起討論交流。
歡迎來擾。