列表曝光統計
開發越往後走,越發覺察到數據的寶貴,所謂量變產生質變,即便是一些平時看上去無足輕重的數據一旦量上去了加以分析也會是一比巨大的財富。
列表可以說是當下互聯網產品中最最最常見的呈現形式了,幾乎所有內容都可以用列表的方式進行展示,同時也是最好的方式沒有之一。
當一個產品規模到達一定量級後爲了進一步提升用戶體驗往往產品或者項目 leader 會提出這樣一個需求:統計列表曝光數據。這也就是今天這篇文章的主題,希望可以通過本文爲有同樣需求的童鞋一些思路和節省重複造輪子的時間。
需求整理
顧名思義,列表曝光統計核心需求就是**“曝光"和"統計”**。
曝光
曝光的定義根據需求可能會有所不同,這裏按照廣義上的定義:
-
用戶主動瀏覽過的
Item
記作一次曝光,項與項之間快速劃過未停留的不記作曝光。 -
每一次頁面切換算一次曝光
統計
對一次曝光統計週期內的所有Item
的曝光次數進行統計。
思路分析
列表控件
在Android
上,主流列表都是使用RecyclerView
及其子類進行開發,因此首選方案當然是基於RecyclerView
進行封裝。
曝光項獲取
首先需要拿到當前列表正在曝光的項,這裏LinearLayoutManager
提供了對應的相關方法:
// 第一個已顯示item位置
public int findFirstVisibleItemPosition()
// 第一個完全顯示item位置
public int findFirstCompletelyVisibleItemPosition()
// 最後一個已顯示item位置
public int findLastVisibleItemPosition()
// 最後一個完全顯示item位置
public int findLastCompletelyVisibleItemPosition()
這些方法都很好理解,分爲顯示和完全顯示兩種,單獨來看可能會有誤解,結合起來就很容易區分了,由於 item 是有一定高度的,因此就會存在顯示時所有高度完全被顯示和部分高度沒有顯示兩種情況。至於使用哪種就看具體的業務場景了,我這裏因爲考慮到用戶關心的必定是完全展示的,所以採用的是前者。
既然拿到了當前曝光的首項和尾項那計算出所有的曝光項就很容易了。
曝光時機
某一個時刻的曝光項是可以拿到了,好像沒有什麼問題了,但是仔細想想,某一個時刻中的時刻還沒搞定,對此需要結合可實現性和模擬用戶習慣來分析如何定義這個時刻。
-
可實現性
說到要捕獲用戶滑動瀏覽的時機,立馬會想到屏幕觸控事件,通過監聽
ReyclerView
滑動回調可以實現最低成本的獲取用戶每次滑動的時機。/** * An OnScrollListener can be added to a RecyclerView to receive messages when a scrolling event * has occurred on that RecyclerView. * <p> * @see RecyclerView#addOnScrollListener(OnScrollListener) * @see RecyclerView#clearOnChildAttachStateChangeListeners() * */ public abstract static class OnScrollListener { /** * Callback method to be invoked when RecyclerView's scroll state changes. * * @param recyclerView The RecyclerView whose scroll state has changed. * @param newState The updated scroll state. One of {@link #SCROLL_STATE_IDLE}, * {@link #SCROLL_STATE_DRAGGING} or {@link #SCROLL_STATE_SETTLING}. */ public void onScrollStateChanged(RecyclerView recyclerView, int newState){} /** * Callback method to be invoked when the RecyclerView has been scrolled. This will be * called after the scroll has completed. * <p> * This callback will also be called if visible item range changes after a layout * calculation. In that case, dx and dy will be 0. * * @param recyclerView The RecyclerView which scrolled. * @param dx The amount of horizontal scroll. * @param dy The amount of vertical scroll. */ public void onScrolled(RecyclerView recyclerView, int dx, int dy){} }
滑動回調共有兩種:
一是
onScrollStateChanged
,該方法在滑動狀態改變時調用,傳入兩個參數,這裏主要關心的就是第二個newState
,該參數有以下幾個預設值:/** * The RecyclerView is not currently scrolling. * @see #getScrollState() */ public static final int SCROLL_STATE_IDLE = 0; /** * The RecyclerView is currently being dragged by outside input such as user touch input. * @see #getScrollState() */ public static final int SCROLL_STATE_DRAGGING = 1; /** * The RecyclerView is currently animating to a final position while not under * outside control. * @see #getScrollState() */ public static final int SCROLL_STATE_SETTLING = 2;
註釋寫的也比較清晰,簡而言之依次代表 停止滑動、拖拽滑動、慣性滑動。
二是
onScrolled
,該方法就更好理解,在每次滑動時回調當前的 x、y 軸座標。 -
用戶習慣
試想站在用戶角度,當對一個列表進行滑動時,面對無數的項,想要篩選出自己感興趣的內容會怎麼做?
通常會先進行快速滑動,當看到自己感興趣的內容時會停止滑動然後等待列表剛好停止在感興趣的內容項完全顯示的位置,但是由於滑動時存在一個慣性動畫的,因此可能雖然停止滑動後並不能完全顯示出預期的內容,這時候大概率會在慣性滑動停止之前重新手動將列表滑動或靜止在預期的內容上。
這就和上述滑動監聽中的狀態改變符合回調更加符合。
-
結論
結合可實現性調研和用戶習慣分析可得出結論:通過監聽
RecyclerView
的滑動監聽onScrollStateChanged
方法,可更準確且低成本的捕獲列表曝光的時機。
架構設計
子曾經說過,好的架構設計是成功的一半。至於子是誰,這不重要~
一個好的架構需要注意以下幾點:高內聚低耦合、易拓展、繼承、封裝、多態。像本目標產品的定位其實是偏向於工具類的,所以還要儘量2B友好,簡單說就是調用簡單,對外暴露方法靈活簡潔。
爲了實現良好的封裝性和多態,將頂級函數聲明成一個接口:
/**
* Created by whr on 2018/12/26.
* 調用接口
* RecyclerView Item曝光數據統計
* 數據獲取分兩種方式:
* 1、通過getData獲得當前總曝光量
* 2、通過setOnExposeCallback監聽每次曝光事件
*/
public interface ItemViewReporterApi {
/**
* 重置data曝光量
*/
void reset();
/**
* 停止監聽並且釋放資源
*/
void release();
/**
* 獲得當前狀態
*/
boolean isReleased();
/**
* 得到曝光數據總集合
*/
SparseIntArray getData();
/**
* 設置曝光回調
*/
void setOnExposeCallback(OnExposeCallback exposeCallback);
/**
* 當RecyclerView所在頁面獲得焦點時統計一次曝光
*/
void onResume();
/**
* @param interval 曝光時間間隔,單位ms
*/
void setTouchInterval(long interval);
/**
* @param interval 曝光時間間隔,單位ms
* @see #onResume()
*/
void setResumeInterval(long interval);
}
爲了方便外部調用,採用工廠模式對工具類進行實例獲取,同時,爲了更好的封裝性以接口形式返回實現類,這樣做的好處是實現接口實現分離,減少調用方的學習成本,並且在一些特殊情況下減少調用方的工作量。
/**
* Created by whr on 2018/12/26.
* RecyclerView Item曝光數據統計
* 工廠類
*/
public class ItemViewReporterFactory {
private ItemViewReporterFactory() {
}
@NonNull
public static ItemViewReporterApi getItemReporter(RecyclerView recyclerView) throws IllegalArgumentException {
RecyclerView.LayoutManager manager = recyclerView.getLayoutManager();
if (manager instanceof LinearLayoutManager) {
return new ItemViewReporterImpl(recyclerView);
}
throw new IllegalArgumentException("LayoutManager must be LinearLayoutManager");
}
}
因爲需要用到LinearLayoutManager
的方法來得到當前曝光項,所以判斷當前 LayoutManager ,如果不是則拋出異常。
實現
由於是對列表進行操作,所以不可避免的需要用到列表的相關類實現,這裏以使用率最高的RecyclerView
爲例,整體採用裝飾者模式,在儘可能少入侵性的情況下完成對列表的曝光的監聽與整合。
實現分爲兩部分,內部實現與外部實現。
內部實現
-
初始化
實現對外接口
ItemViewReporterApi
,聲明爲抽象類abstract class
,對外調用方法不予實現。內部需要對曝光數據進行處理,在低性能機型上可能造成UI 線程阻塞,採用
Handler
模式進行異步執行。public abstract class ItemViewReporterBase implements ItemViewReporterApi { public ItemViewReporterBase(@NonNull RecyclerView recyclerView) { this.mRecyclerView = recyclerView; this.mLayoutManager = (LinearLayoutManager) recyclerView.getLayoutManager(); init(); } private void init() { mScrollListener = new MyScrollListener(); mRecyclerView.addOnScrollListener(mScrollListener); mReportData = new SparseIntArray(); mHandlerThread = new HandlerThread("ItemViewReporterSub"); mHandlerThread.start(); mHandler = new MyHandler(mHandlerThread.getLooper()); } }
-
滑動監聽
private class MyScrollListener extends RecyclerView.OnScrollListener { @Override public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) { /** * newState * 0:完全停止滾動 * 1: 手指點擊 * 2:慣性滑動中 */ if (newState == 0) { onView(); } } @Override public void onScrolled(RecyclerView recyclerView, int dx, int dy) { super.onScrolled(recyclerView, dx, dy); } }
-
優化策略
雖說採用滑動停止監聽可以有效獲取用戶停留的時機,但是部分特殊場景下可能導致短時間多次進行曝光采集的情況,而實際上對用戶來說這僅爲一次曝光事件。因此有必要添加多次曝光之間的有效時長間隔控制。
private void onView() { mLastTouchTime = templateTimeCtrl(mLastTouchTime, mIntervalTouch, WHAT_TOUCH); } /** * 模板代碼 * 控制曝光記錄間隔 * * @param lastTime 上次曝光時間 * @param interval 間隔時間 * @param what 對應事件 * @return 此次曝光時間 */ protected long templateTimeCtrl(long lastTime, long interval, int what) { if (SystemClock.elapsedRealtime() - lastTime < interval) { mHandler.removeMessages(what); } mHandler.sendEmptyMessageDelayed(what, interval); return SystemClock.elapsedRealtime(); } protected class MyHandler extends Handler { private MyHandler(Looper looper) { super(looper); } @Override public void handleMessage(Message msg) { switch (msg.what) { case WHAT_TOUCH: recordTouch(); break; case WHAT_RESUME: recordResume(); break; } } }
-
曝光統計
private Point findRangePosition() { int firstComPosition = -1; int lastComPosition = -1; try { firstComPosition = mLayoutManager.findFirstCompletelyVisibleItemPosition(); lastComPosition = mLayoutManager.findLastCompletelyVisibleItemPosition(); } catch (Exception e) { e.printStackTrace(); } if (firstComPosition == -1) { return null; } else { return new Point(firstComPosition, lastComPosition); } }
-
數據去重
有沒有想過這樣一個場景:當前用戶第一次瀏覽了 1、2、3、4、5、6、7、8、9 項,此時記錄第一次,當用戶看完後進行小範圍滑動,此時曝光項爲 7、8、9、10、11、12、13、14、15 項,如果再次全量記錄一次,相對於開始7、8、9 就曝光了 2 次,但實際上對用戶來說因爲壓根沒有滑出屏幕,所以其實只能算一次曝光。
因此就需要對數據進行去重操作。
private void recordTouch() { Point rangePosition = findRangePosition(); if (rangePosition == null) { return; } int firstComPosition = rangePosition.x; int lastComPosition = rangePosition.y; if (firstComPosition == mOldFirstComPt && lastComPosition == mOldLastComPt) { return; } List<Integer> positionList = new ArrayList<>(); List<View> viewList = new ArrayList<>(); //首次&不包含相同項 if (mOldLastComPt == -1 || firstComPosition > mOldLastComPt || lastComPosition < mOldFirstComPt) { for (int i = firstComPosition; i <= lastComPosition; i++) { templateAddData(i, positionList, viewList); } } else { //排除相同項 if (firstComPosition < mOldFirstComPt) { for (int i = firstComPosition; i < mOldFirstComPt; i++) { templateAddData(i, positionList, viewList); } } if (lastComPosition > mOldLastComPt) { for (int i = mOldLastComPt + 1; i <= lastComPosition; i++) { templateAddData(i, positionList, viewList); } } } if (mExposeCallback != null) { mExposeCallback.onExpose(positionList, viewList); } mOldFirstComPt = firstComPosition; mOldLastComPt = lastComPosition; }
-
數據記錄
拿到去重的曝光數據後,基本上一次曝光統計操作就步入尾聲了,現在需要做的就是將數據保存下來,並且回調給外部使用,這裏將每一次曝光數據提供給外部主要是爲了滿足不同場景下奇奇怪怪的產品需求,用專業術語來說算是提高可擴展性和靈活性吧。
說明一下,這裏之所以還記錄了每個 Item 對應的 View,是因爲有的業務方可能會用到 View,例如用來拿到 Tag。
private void templateAddData(int position, List<Integer> positionList, List<View> viewList) { View positionView = null; try { positionView = mLayoutManager.findViewByPosition(position); } catch (Exception e) { e.printStackTrace(); } if (null == positionView) { return; } if (positionView.getVisibility() == View.GONE) { return; } int count = mReportData.get(position); mReportData.put(position, count + 1); if (null != positionList && null != viewList) { positionList.add(position); viewList.add(positionView); } }
存儲集合的選擇上,由於是
key/value
模型,並且都是int
類型,這裏採用了SparseIntArray
進行存儲,該集合對雙int
類型有特殊優化,可以達到比普通HashMap
更快的存儲效率。/** * SparseIntArrays map integers to integers. Unlike a normal array of integers, * there can be gaps in the indices. It is intended to be more memory efficient * than using a HashMap to map Integers to Integers, both because it avoids * auto-boxing keys and values and its data structure doesn't rely on an extra entry object * for each mapping. * * <p>Note that this container keeps its mappings in an array data structure, * using a binary search to find keys. The implementation is not intended to be appropriate for * data structures * that may contain large numbers of items. It is generally slower than a traditional * HashMap, since lookups require a binary search and adds and removes require inserting * and deleting entries in the array. For containers holding up to hundreds of items, * the performance difference is not significant, less than 50%.</p> * * <p>It is possible to iterate over the items in this container using * {@link #keyAt(int)} and {@link #valueAt(int)}. Iterating over the keys using * <code>keyAt(int)</code> with ascending values of the index will return the * keys in ascending order, or the values corresponding to the keys in ascending * order in the case of <code>valueAt(int)</code>.</p> */ public class SparseIntArray implements Cloneable{}
外部實現
外部實現就很簡單了,唯一需要注意的是 release 檢查,直接上代碼:
/**
* Created by whr on 2018/12/27.
* RecyclerView Item曝光數據統計
* 外部實現
*/
class ItemViewReporterImpl extends ItemViewReporterBase {
ItemViewReporterImpl(@NonNull RecyclerView recyclerView) {
super(recyclerView);
}
@Override
public void reset() {
templateCheck();
mHandler.removeCallbacksAndMessages(null);
mReportData.clear();
mOldFirstComPt = -1;
mOldLastComPt = -1;
mLastResumeTime = 0;
mLastTouchTime = 0;
}
@Override
public void release() {
templateCheck();
mIsRelease = true;
mRecyclerView.removeOnScrollListener(mScrollListener);
mHandler.getLooper().quit();
mHandlerThread.quit();
mReportData.clear();
mExposeCallback = null;
mRecyclerView = null;
}
@Override
public boolean isReleased() {
return mIsRelease;
}
@Override
public SparseIntArray getData() {
templateCheck();
return mReportData;
}
@Override
public void setOnExposeCallback(OnExposeCallback exposeCallback) {
this.mExposeCallback = exposeCallback;
}
@Override
public void onResume() {
templateCheck();
mLastResumeTime = templateTimeCtrl(mLastResumeTime, mIntervalResume, WHAT_RESUME);
}
@Override
public void setResumeInterval(long interval) {
templateCheck();
this.mIntervalResume = interval;
}
@Override
public void setTouchInterval(long interval) {
templateCheck();
this.mIntervalTouch = interval;
}
}
/**
* 模板代碼
* 統一處理非法調用
*/
protected void templateCheck() {
if (mIsRelease) {
throw new RuntimeException("this is released");
}
}
結語
以上就是一個相對完整的列表曝光統計的分析、設計以及實現。
本項目已在 GitHub 上開源,有需要的點擊這裏,如果對你有幫助記得點贊加關注哦~