在日常的APP開發(fā)中,經(jīng)常會(huì)遇到列表Item曝光相關(guān)的埋點(diǎn)。我們通常是當(dāng)數(shù)據(jù)對(duì)應(yīng)的UI元素展示在屏幕上時(shí)才算作曝光并進(jìn)行記錄。所以不可避免地在記錄曝光時(shí)需要結(jié)合屏幕上的列表數(shù)據(jù)變化來進(jìn)行。
列表數(shù)據(jù)變化一般會(huì)由這幾種事件引起:
(1)列表數(shù)據(jù)刷新
(2)列表滑動(dòng)
(3)軟鍵盤彈出/收起
所以對(duì)應(yīng)的觀察時(shí)機(jī)為:
1、頁面在前臺(tái)時(shí)發(fā)生的以上事件
2、頁面切換到前臺(tái)時(shí)
列表數(shù)據(jù)刷新
通過注冊AdapterDataObserver來感知列表的刷新行為,并通過while-delay防抖,afterLatestMeasured來確保ui完成了刷新
private var checkJob: Job? = null
private var checkNeedDelay = false
/**
* 列表刷新感知,防抖
*/
private fun addAdapterDataObserver() {
mRecyclerView.adapter?.registerAdapterDataObserver(ListExpoAdapterDataObserver {
resetForScrollPosition()
checkNeedDelay = true
if (checkJob?.isActive != true) {
checkJob = AccountMainScope().launch {
while (checkNeedDelay) {
checkNeedDelay = false
delay(CHECK_DELAY)
}
mRecyclerView.afterLatestMeasured {
checkExpoItem()
}
}
}
})
}
class ListExpoAdapterDataObserver(
private val checkExpoCallBack: (() -> Unit),
) : RecyclerView.AdapterDataObserver() {
override fun onChanged() {
super.onChanged()
dealCallBack()
}
override fun onItemRangeChanged(positionStart: Int, itemCount: Int) {
super.onItemRangeChanged(positionStart, itemCount)
dealCallBack()
}
...
列表滑動(dòng)
在onScrolled時(shí)計(jì)算出曝光的范圍,在onScrollStateChanged的SCROLL_STATE_IDLE時(shí)根據(jù)曝光范圍進(jìn)行回調(diào),獲取曝光元素的相關(guān)數(shù)據(jù)信息。并清除緩存的曝光記錄
private var rvExpoFirstPositionForScroll = NO_POSITION
private var rvExpoLastPositionForScroll = NO_POSITION
/**
* 列表滑動(dòng)感知
*/
private fun addOnScrollListener() {
mRecyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
super.onScrollStateChanged(recyclerView, newState)
when (newState) {
RecyclerView.SCROLL_STATE_IDLE ->{
checkExpoItemForScroll()
resetForScrollPosition()
}
else -> {}
}
}
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
cacheExpoItemPositionForScroll()
}
})
}
/**
* 處理滑動(dòng)中的曝光行為
*/
private fun cacheExpoItemPositionForScroll(){
val (rvExpoFirstPosition, rvExpoLastPosition) = mRecyclerView.checkFirstAndLastItemPosition()
rvExpoFirstPositionForScroll = if(rvExpoFirstPositionForScroll == NO_POSITION){
rvExpoFirstPosition
}else{
rvExpoFirstPosition.coerceAtMost(rvExpoFirstPositionForScroll)
}
rvExpoLastPositionForScroll = if (rvExpoLastPositionForScroll == NO_POSITION) {
rvExpoLastPosition
} else {
rvExpoLastPosition.coerceAtLeast(rvExpoLastPositionForScroll)
}
}
軟鍵盤彈出/收起
private fun addKeyBoardListener() {
(mRecyclerView.context as? AppCompatActivity)?.let {
ListExpoKeyboardChangeListener(it, object : ListExpoKeyboardChangeListener.KeyboardHeightListener {
override fun onKeyboardHeightChanged(keyboardHeight: Int, keyboardOpen: Boolean, isLandscape: Boolean) {
checkExpoItem()
}
})
}
}
使用lifecycle進(jìn)行生命周期的感知,在頁面回到前臺(tái)時(shí)檢查當(dāng)前的item曝光情況,在頁面離開前臺(tái)時(shí)進(jìn)行數(shù)據(jù)上報(bào)操作
/**
* 頁面生命周期感知
*/
private fun addLifeCycleListener() {
mLifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onResume(owner: LifecycleOwner) {
super.onResume(owner)
checkExpoItem()
}
override fun onPause(owner: LifecycleOwner) {
super.onPause(owner)
sendEventAndReset()
}
})
}
至此,我們可以通過在合適的檢查時(shí)機(jī)使用layoutManager的findFirstVisibleItemPosition,findLastVisibleItemPosition方法,實(shí)現(xiàn)對(duì)RecyclerView.Adapter適配器中內(nèi)容的曝光上報(bào)需求。
但隨著ConcatAdapter的出現(xiàn)(順序組合多個(gè)RecyclerView.Adapter,并顯示在一個(gè)RecyclerView中),我們也需要對(duì)其進(jìn)行適配。
由于我們只能通過RecyclerView的LayoutManager來獲取可見item的坐標(biāo),無法通過RecyclerView.Adapter來獲取,所以我們需要通過ConcatAdapter順序組合的特性,根據(jù)RecycleView首末item的顯示位置、多個(gè)RecyclerView.Adapter的ItemCount,來計(jì)算出我們關(guān)注的Adapter的曝光情況。
/**
* 處理曝光數(shù)據(jù)獲取
*/
private fun dealDataProvided(listener: ListExpoListener<*>, rvExpoFirstPosition: Int, rvExpoLastPosition: Int) {
val expoAdapterItemCount = listener.getExpoAdapterItemCount()
if (rvExpoFirstPosition == NO_POSITION || rvExpoLastPosition == NO_POSITION || expoAdapterItemCount == 0) return
if (mRecyclerView.adapter is ConcatAdapter) {
var expoAdapterFirstIndex = 0
run loop@{
(mRecyclerView.adapter as ConcatAdapter).adapters.forEach { adapter ->
if (adapter == listener.expoAdapter) return@loop
expoAdapterFirstIndex += adapter.itemCount
}
}
val expoAdapterLastIndex = expoAdapterFirstIndex + expoAdapterItemCount - 1
val fromIndex = expoAdapterFirstIndex.coerceAtLeast(rvExpoFirstPosition)
val toIndex = expoAdapterLastIndex.coerceAtMost(rvExpoLastPosition)
if (toIndex >= fromIndex) {
listener.dealDataProvided(fromIndex - expoAdapterFirstIndex, toIndex - expoAdapterFirstIndex)
}
} else {
if (mRecyclerView.adapter == listener.expoAdapter) {
if (rvExpoLastPosition >= rvExpoFirstPosition) {
listener.dealDataProvided(rvExpoFirstPosition, rvExpoLastPosition)
}
}
}
}
至此,我們也實(shí)現(xiàn)了對(duì)在ConcatAdapter中的RecyclerView.Adapter曝光觀察。
但需求總是變化無常的,如果我們需要對(duì)ConcatAdapter中多個(gè)RecyclerView.Adapter進(jìn)行曝光觀察,又應(yīng)該如何處理呢?
于是我想到了addXXXListener的形式,將單個(gè)RecyclerView.Adapter的觀察和上報(bào)抽成一個(gè)ListExpoListener進(jìn)行封裝。考慮到應(yīng)對(duì)各種不同的數(shù)據(jù)類型以及使用便捷性,引入了泛型機(jī)制
class ListExpoListener<T>(
val expoAdapter: ExpoAdapterInterface,
private val dataProvided: ((expoInfo: ListExpoEntity<T>) -> Unit),
private val reportTrack: ((Map<String, T>) -> Unit) = { },
) {
private val reportDataMap: MutableMap<String, T> by lazy { mutableMapOf() }
fun getExpoAdapterItemCount() = expoAdapter.getItemCountForExpo()
fun dealDataProvided(fromIndex: Int, toIndex: Int) {
dataProvided.invoke(ListExpoEntity(reportDataMap, fromIndex, toIndex))
}
fun dealReportTrack() {
reportTrack.invoke(reportDataMap)
}
fun clearCache(){
reportDataMap.clear()
}
fun getCacheMap() = reportDataMap
/**
* 檢查列表曝光,過濾不可見狀態(tài)的
*/
private fun checkExpoItem() {
if (mLifecycle.currentState == Lifecycle.State.RESUMED && listenerList.isNotEmpty()) {
val (rvExpoFirstPosition, rvExpoLastPosition) = mRecyclerView.checkFirstAndLastItemPosition()
catch {
listenerList.forEach {
dealDataProvided(it, rvExpoFirstPosition, rvExpoLastPosition)
}
}
}
}
/**
* 上報(bào)緩存的埋點(diǎn)信息,并重置緩存數(shù)據(jù)
*/
private fun sendEventAndReset() {
listenerList.forEach {
it.dealReportTrack()
it.clearCache()
}
}
至此,可實(shí)現(xiàn)目前所接收到的大部分列表埋點(diǎn)需求