recyclerView中的item曝光逻辑实现

在日常的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()
    }
  }

至此,可实现目前所接收到的大部分列表埋点需求

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容