RecycleView的Item曝光事件、曝光时间、阅读进度上报

本文详细介绍如何优化Android RecyclerView性能,避免在onScroll中进行频繁上报导致的性能消耗。通过在Adapter的onViewAttachedToWindow与onViewDetachedFromWindow方法中上报,实现商品曝光次数与曝光总时间的准确统计,同时提供列表阅读进度的上报解决方案。
摘要由CSDN通过智能技术生成

2020-10-22 更新

在日常使用过程中,发现在onScroll中进行上报会大大增加滑动时的性能消耗(由于在onScroll在滑动时过于频繁,而每次findFirstVisibleItemPosition都从0开始遍历一次子元素直至显示的view)。因此建议把上报的起始位置移到Adapter的onViewAttachedToWindow与onViewDetachedFromWindow方法中,此方法在item被移入和移除屏幕时会直接回调,不用再进行多余的scroll判定

    @Override
    public void onViewAttachedToWindow(@NonNull T holder) {
        super.onViewAttachedToWindow(holder);
        final int position = holder.getLayoutPosition();
        if (position >= 0) {
            handleItemVisibleChange(position, true);
        }
    }

    @Override
    public void onViewDetachedFromWindow(@NonNull T holder) {
        super.onViewDetachedFromWindow(holder);
        final int position = holder.getLayoutPosition();
        if (position >= 0) {
            handleItemVisibleChange(position, false);
        }
    }

原因

工作中一般会针对页面展示与点击事件进行数据的上报,用于给运营分析用户行为。不过这次要求的会多一些,要求统计如下内容

  1. 列表内每个商品的封面曝光次数
  2. 列表内每个商品的封面曝光总时间
  3. 列表的阅读进度
    曝光效果

问题点

如何判断一次商品的封面曝光?

  1. 判断时机:①在RecycleView滑动时,即通过OnScrollListener判断。②在界面切换时生命周期判断
  2. 如何判断商品出现:RecycleView的LinearLayoutManager有findFirstVisibleItemPosition和findLastVisibleItemPosition方法,可以获取当前显示的item的position范围(GridLayoutManager也有该方法)

如何判断一次商品的曝光时间?

  1. 曝光起始时间:即商品的封面曝光时间,可以从上一个问题点获取
  2. 曝光结束时间:可理解为每个商品Item的消失时间,每个Item消失一定会经过从显示100%到0%的过程,我们定一个阈值如50%,在滑动过程中若从显示状态减少到50%,则判断为此商品进入消失状态。
  3. 商品出现时间 = 曝光结束时间-曝光起始时间

列表阅读进度

  1. 上报阅读进度时机:在页面退出时间上报,以Fragment为例,在Fragment的onPause回调上报即可
  2. 阅读进度=(最末一位item的位置+1)/数据总大小。最末一位item的位置可由LinearLayoutManager#findLastVisibleItemPosition获得

代码实现

如何使用

我们首先来看看最终使用该工具类的效果

//TrackListFragment.java
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)
    //...省略
    RecyclerViewTrack(rv_track).startTrack(lifecycle,
        object : RecyclerViewTrack.ItemExposeListener {
            override fun onItemViewVisible(position: Int) {
                Log.i(TAG, "onItemViewVisible: position = $position")
                //在此处上报Item的曝光时间点
            }

            override fun onItemViewInvisible(position: Int, showTime: Long) {
                //在此处上报Item的曝光时间段
                Log.i(TAG, "onItemViewInvisible: position = $position,showTime = $showTime")
                Toast.makeText(context, "商品${position}不再显示,曝光时间为$showTime", Toast.LENGTH_SHORT)
                    .show()
            }

        })
    }
对外接口

下面是对外接口ItemExposeListener的方法描述

//RecyclerViewTrack.java
    interface ItemExposeListener{
        /**
         * item可见回调
         * @param position item在列表中的位置
         */
        fun onItemViewVisible(position: Int)

        /**
         * item消失回调
         * @param position item在列表中的位置
         * @param showTime 曝光时间
         */
        fun onItemViewInvisible(position: Int, showTime: Long)
    }
实现滑动时上报

具体可以通过代码注释来看到滑动时是如何触发回调的

//RecyclerViewTrack.java
    /**
     * 开启上报
     * @param lifecycle 可为空,用于监听对应的生命周期
     * @param listener 上报监听器
     */
    fun startTrack(lifecycle: Lifecycle?, listener: ItemExposeListener) {
        //...
        recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
            override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                super.onScrolled(recyclerView, dx, dy)
                checkCurrentVisibleItem()
            }
        })
        //...
    }
    
    /**
     * 判断Item是否展示,注意此处只支持了LinearLayoutManager和GridLayoutManager,如果是需要其它LayoutManager可以在此处修改
     */
    private fun checkCurrentVisibleItem() {
        val range = IntArray(2)
        val manager = recyclerView.layoutManager
        val orientation: Int
        when (manager) {
            is LinearLayoutManager -> {
                //获取visible的item范围
                range[0] = manager.findFirstVisibleItemPosition()
                range[1] = manager.findLastVisibleItemPosition()
                orientation = manager.orientation
            }
            else -> return
        }
        for (i in range[0]..range[1]) {
            val view = manager.findViewByPosition(i)
            dispatchViewVisible(view, i, orientation)
        }
    }
    
    /**
     * 判断View的可见性并进行分发
     */
    private fun dispatchViewVisible(view: View?, position: Int, orientation: Int) {
        view?.let {
            val rect = Rect()
            //通过getGlobalVisibleRect得到在界面中展示的大小
            val rootVisible = view.getGlobalVisibleRect(rect)
            //判断若超出了一半位置则算曝光
            val visibleHeightEnough =
                orientation == OrientationHelper.VERTICAL && rect.height() >= view.measuredHeight / 2
            val visibleWidthEnough =
                orientation == OrientationHelper.HORIZONTAL && rect.width() >= view.measuredWidth / 2
            //可见区域超过百分之五十
            val visible = (visibleHeightEnough || visibleWidthEnough) && rootVisible
            val lastValue = timeSparseArray[position]
            val curTime = System.currentTimeMillis()
//            Log.i(TAG, "checkViewVisible: position = $position, visible = $visible, lastValue = $lastValue")
            if (lastValue > 0) {
                //从显示到不显示
                if (!visible) {
                    //触发Invisible分发
                    dispatchInvisible(position, lastValue, curTime)
                }
            } else if (visible) {
                //从不显示到显示,触发visible分发
                dispatchVisible(position, curTime)
            }
        }
    }
    
    /**
     * 分发InVisible
     */
    private fun dispatchInvisible(
        position: Int,
        lastTime: Long,
        curTime: Long
    ) {
        Log.i(TAG, "dispatchInvisible: position = $position")
        if (lastTime == curTime) {
            return
        }
        timeSparseArray.put(position, -1)
        listener?.onItemViewInvisible(position, curTime - lastTime)
    }

    /**
     * 分发Visible
     */
    private fun dispatchVisible(position: Int, curTime: Long) {
        Log.i(TAG, "dispatchVisible: position = $position")
        timeSparseArray.put(position, curTime)
        listener?.onItemViewVisible(position)
    }

我们会通过timeSparseArray来记录每个Item的曝光的时间点,并在item变为invisible时直接计算出这次item曝光的时间长度

实现页面切换时上报

上面的代码实现时会有一个问题,如点击Home键回到首页时,由于不会触发RecycleView的scroll事件,导致会把应用在后台的这段时间也算到曝光时间长度,所以我们需要监听界面的生命周期,并在resume和pause状态时手动触发item的对应回调

//RecyclerViewTrack.java
    /**
     * 开启上报
     * @param lifecycle 可为空,用于监听对应的生命周期
     * @param listener 上报监听器
     */
    fun startTrack(lifecycle: Lifecycle?, listener: ItemExposeListener) {
        //...
        //通过lifecycle监听activity和fragment的生命周期
        lifecycle?.addObserver(LifecycleEventObserver { _, event ->
            if (event == Lifecycle.Event.ON_RESUME) dispatchResume()
            else if (event == Lifecycle.Event.ON_PAUSE) dispatchPause()
        })
    }
    
    /**
     * 在Fragment走到Pause时onScroll不会被触发上报,所以需要手动触发
     */
    private fun dispatchPause() {
        val size = timeSparseArray.size()
        for (i in size - 1 downTo 0) {
            val key = timeSparseArray.keyAt(i)
            val value = timeSparseArray.valueAt(i)
            if (value > 0) {
                //是可见状态,则改为不可见状态,在resume时还需要把这些item重置为可见状态
                dispatchInvisible(key, value, System.currentTimeMillis())
            } else {
                //不是可见状态直接移除
                timeSparseArray.delete(key)
            }
        }
    }

    /**
     * 在Fragment在后续走到Resume时onScroll不会被触发,所以需要手动触发
     */
    private fun dispatchResume() {
        val size = timeSparseArray.size()
        val curTime = System.currentTimeMillis()
        //若界面到了resume状态,则把界面退出时可见状态的item都重置成可见状态
        for (i in size - 1 downTo 0) {
            val key = timeSparseArray.keyAt(i)
            dispatchVisible(key, curTime)
        }
    }

Ps.若Fragment某些情形下不会触发onResume和onPause回调,建议使用ViewPager2或者手动设置fragmentTransaction.setMaxLifecycle方法来实现正确的生命周期回调

列表的阅读进度上报
实现TrackFragment统一上报

在上面曝光事件实现的基础上我们就可以实现阅读的进度上报

  1. 在onItemViewVisible回调里记录最大position
  2. 在退出界面时上传阅读进度即可
  3. 可以把此逻辑封装到抽象类中
/**
 * Created by wzt on 2020/9/7
 * 上报页面阅读进度基类
 */
abstract class TrackFragment : Fragment() {
    //浏览数据集合的大小
    private var infoSize = 0

    //浏览的位置的最大值
    private var curMaxNum = 0

    override fun onPause() {
        super.onPause()
        //在onPause时上报
        postShowPercent()
    }

    /**
     * 设置数据总大小
     * @param infoSize 数据总大小
     */
    fun setInfoSize(infoSize: Int) {
        this.infoSize = infoSize
    }

    /**
     * 设置阅读进度最大值
     * @param curNum 当前阅读进度
     */
    open fun setCurNum(curNum: Int) {
        curMaxNum = max(curNum, curMaxNum)
    }

    /**
     * 得到阅读进度
     * @return 阅读进度,从0~1的分数
     */
    private fun getProgressRate(): Float {
        return if (infoSize != 0) {
            curMaxNum.toFloat() / infoSize
        } else 0f
    }

    /**
     * 上报阅读进度
     */
    private fun postShowPercent() {
        val progressRate = getProgressRate()
        if (progressRate == 0f) {
            return
        }
        //在此处上报界面阅读进度
        Toast.makeText(context, "阅读进度为$progressRate", Toast.LENGTH_SHORT).show()
    }
}
使用方式

只需要继承TrackFragment,并在获取到数据时调用setInfoSize,在item曝光时调用setCurNum即可在onPause中上报

class TrackListFragment : TrackFragment() {
    RecyclerViewTrack(rv_track).startTrack(lifecycle,
        object : RecyclerViewTrack.ItemExposeListener {
            override fun onItemViewVisible(position: Int) {
                Log.i(TAG, "onItemViewVisible: position = $position")
                //此处收到item曝光事件,调用setCurNum
                //由于position是从0开始计算,所以此处需要记得+1
                setCurNum(position+1)
            }
    })
    
    override fun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)
        viewModel = ViewModelProvider(this).get(TrackListViewModel::class.java)
        viewModel.productsLiveData.observe(viewLifecycleOwner,
            Observer {
                list.clear()
                list.addAll(it)
                //此处获取到了数据,调用setInfoSize
                setInfoSize(list.size)
                rv_track.adapter!!.notifyDataSetChanged()
        })
    }
}

总结

关键类

主要的上报工具类

package com.kyrie.proj.blog.track

import android.graphics.Rect
import android.util.Log
import android.util.SparseLongArray
import android.view.View
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.OrientationHelper
import androidx.recyclerview.widget.RecyclerView

/**
 * Created by wzt on 2020/9/4
 * RecycleView上报工具类
 */
open class RecyclerViewTrack(private val recyclerView: RecyclerView) {
    companion object{
        const val TAG = "RecyclerViewTrack"
    }

    private var listener: ItemExposeListener? = null
    private val timeSparseArray = SparseLongArray(10)

    /**
     * 开启上报
     * @param lifecycle 可为空,用于监听对应的生命周期
     * @param listener 上报监听器
     */
    fun startTrack(lifecycle: Lifecycle?, listener: ItemExposeListener) {
        this.listener = listener
        recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
            override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                super.onScrolled(recyclerView, dx, dy)
                checkCurrentVisibleItem()
            }
        })
        lifecycle?.addObserver(LifecycleEventObserver { _, event ->
            if (event == Lifecycle.Event.ON_RESUME) dispatchResume()
            else if (event == Lifecycle.Event.ON_PAUSE) dispatchPause()
        })
    }

    /**
     * 判断Item是否展示
     */
    private fun checkCurrentVisibleItem() {
        val range = IntArray(2)
        val manager = recyclerView.layoutManager
        val orientation: Int
        when (manager) {
            is LinearLayoutManager -> {
                range[0] = manager.findFirstVisibleItemPosition()
                range[1] = manager.findLastVisibleItemPosition()
                orientation = manager.orientation
            }
            else -> return
        }
        for (i in range[0]..range[1]) {
            val view = manager.findViewByPosition(i)
            dispatchViewVisible(view, i, orientation)
        }
    }

    /**
     * 判断View的可见性并进行分发
     */
    private fun dispatchViewVisible(view: View?, position: Int, orientation: Int) {
        view?.let {
            val rect = Rect()
            val rootVisible = view.getGlobalVisibleRect(rect)
            //判断若超出了一半位置则算曝光
            val visibleHeightEnough =
                orientation == OrientationHelper.VERTICAL && rect.height() >= view.measuredHeight / 2
            val visibleWidthEnough =
                orientation == OrientationHelper.HORIZONTAL && rect.width() >= view.measuredWidth / 2
            //可见区域超过百分之五十
            val visible = (visibleHeightEnough || visibleWidthEnough) && rootVisible
            val lastValue = timeSparseArray[position]
            val curTime = System.currentTimeMillis()
//            Log.i(TAG, "checkViewVisible: position = $position, visible = $visible, lastValue = $lastValue")
            if (lastValue > 0) {
                //从显示到不显示
                if (!visible) {
                    dispatchInvisible(position, lastValue, curTime)
                }
            } else if (visible) {
                //从不显示到显示
                dispatchVisible(position, curTime)
            }
        }
    }

    /**
     * 在Fragment走到Pause时onScroll不会被触发上报,所以需要手动触发
     */
    private fun dispatchPause() {
        val size = timeSparseArray.size()
        for (i in size - 1 downTo 0) {
            val key = timeSparseArray.keyAt(i)
            val value = timeSparseArray.valueAt(i)
            if (value > 0) {
                //是可见状态,则改为不可见状态
                dispatchInvisible(key, value, System.currentTimeMillis())
            } else {
                //不是可见状态直接移除
                timeSparseArray.delete(key)
            }
        }
    }

    /**
     * 在Fragment在后续走到Resume时onScroll不会被触发,所以需要手动触发
     */
    private fun dispatchResume() {
        val size = timeSparseArray.size()
        val curTime = System.currentTimeMillis()
        for (i in size - 1 downTo 0) {
            val key = timeSparseArray.keyAt(i)
            dispatchVisible(key, curTime)
        }
    }

    /**
     * 分发InVisible
     */
    private fun dispatchInvisible(
        position: Int,
        lastTime: Long,
        curTime: Long
    ) {
        Log.i(TAG, "dispatchInvisible: position = $position")
        if (lastTime == curTime) {
            return
        }
        timeSparseArray.put(position, -1)
        listener?.onItemViewInvisible(position, curTime - lastTime)
    }

    /**
     * 分发Visible
     */
    private fun dispatchVisible(position: Int, curTime: Long) {
        Log.i(TAG, "dispatchVisible: position = $position")
        timeSparseArray.put(position, curTime)
        listener?.onItemViewVisible(position)
    }

    interface ItemExposeListener{
        /**
         * item可见回调
         * @param position item在列表中的位置
         */
        fun onItemViewVisible(position: Int)

        /**
         * item消失回调
         * @param position item在列表中的位置
         * @param showTime 曝光时间
         */
        fun onItemViewInvisible(position: Int, showTime: Long)
    }
}

感悟

数据上报虽然用户无法感知,但是也是很常见的app功能,更是我们了解用户怎么使用应用的手段。除了文章描述的这种侵入性较高的上报方式外,还有如AspectJ、DroidAssist等无侵入的方式,这些都是值得我以后多多研究的

引用

曝光埋点方案:recyclerView中的item曝光逻辑实现
基于此大佬的思路,解决了没有曝光时间与界面切换时间的问题

源码

https://github.com/wangzici/blog/

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值