recyclerView中的item曝光邏輯實(shí)現(xiàn)

在日常的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)需求

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容