在日常的APP开发中,经常会遇到列表Item曝光相关的埋点。我们通常是当数据对应的UI元素展示在屏幕上时才算作曝光并进行记录。所以不可避免地在记录曝光时需要结合屏幕上的列表数据变化来进行。
列表数据变化一般会由这几种事件引起:
(1)列表数据刷新
(2)列表滑动
(3)软键盘弹出/收起
所以对应的观察时机为:
1、页面在前台时发生的以上事件
2、页面切换到前台时
列表数据刷新
通过注册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()
}
...
列表滑动
在onScrolled时计算出曝光的范围,在onScrollStateChanged的SCROLL_STATE_IDLE时根据曝光范围进行回调,获取曝光元素的相关数据信息。并清除缓存的曝光记录
private var rvExpoFirstPositionForScroll = NO_POSITION
private var rvExpoLastPositionForScroll = NO_POSITION
/**
* 列表滑动感知
*/
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()
}
})
}
/**
* 处理滑动中的曝光行为
*/
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进行生命周期的感知,在页面回到前台时检查当前的item曝光情况,在页面离开前台时进行数据上报操作
/**
* 页面生命周期感知
*/
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()
}
})
}
至此,我们可以通过在合适的检查时机使用layoutManager的findFirstVisibleItemPosition,findLastVisibleItemPosition方法,实现对RecyclerView.Adapter适配器中内容的曝光上报需求。
但随着ConcatAdapter的出现(顺序组合多个RecyclerView.Adapter,并显示在一个RecyclerView中),我们也需要对其进行适配。
由于我们只能通过RecyclerView的LayoutManager来获取可见item的坐标,无法通过RecyclerView.Adapter来获取,所以我们需要通过ConcatAdapter顺序组合的特性,根据RecycleView首末item的显示位置、多个RecyclerView.Adapter的ItemCount,来计算出我们关注的Adapter的曝光情况。
/**
* 处理曝光数据获取
*/
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)
}
}
}
}
至此,我们也实现了对在ConcatAdapter中的RecyclerView.Adapter曝光观察。
但需求总是变化无常的,如果我们需要对ConcatAdapter中多个RecyclerView.Adapter进行曝光观察,又应该如何处理呢?
于是我想到了addXXXListener的形式,将单个RecyclerView.Adapter的观察和上报抽成一个ListExpoListener进行封装。考虑到应对各种不同的数据类型以及使用便捷性,引入了泛型机制
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
/**
* 检查列表曝光,过滤不可见状态的
*/
private fun checkExpoItem() {
if (mLifecycle.currentState == Lifecycle.State.RESUMED && listenerList.isNotEmpty()) {
val (rvExpoFirstPosition, rvExpoLastPosition) = mRecyclerView.checkFirstAndLastItemPosition()
catch {
listenerList.forEach {
dealDataProvided(it, rvExpoFirstPosition, rvExpoLastPosition)
}
}
}
}
/**
* 上报缓存的埋点信息,并重置缓存数据
*/
private fun sendEventAndReset() {
listenerList.forEach {
it.dealReportTrack()
it.clearCache()
}
}
至此,可实现目前所接收到的大部分列表埋点需求