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);
}
}
原因
工作中一般会针对页面展示与点击事件进行数据的上报,用于给运营分析用户行为。不过这次要求的会多一些,要求统计如下内容
- 列表内每个商品的封面曝光次数
- 列表内每个商品的封面曝光总时间
- 列表的阅读进度
问题点
如何判断一次商品的封面曝光?
- 判断时机:①在RecycleView滑动时,即通过OnScrollListener判断。②在界面切换时生命周期判断
- 如何判断商品出现:RecycleView的LinearLayoutManager有findFirstVisibleItemPosition和findLastVisibleItemPosition方法,可以获取当前显示的item的position范围(GridLayoutManager也有该方法)
如何判断一次商品的曝光时间?
- 曝光起始时间:即商品的封面曝光时间,可以从上一个问题点获取
- 曝光结束时间:可理解为每个商品Item的消失时间,每个Item消失一定会经过从显示100%到0%的过程,我们定一个阈值如50%,在滑动过程中若从显示状态减少到50%,则判断为此商品进入消失状态。
- 商品出现时间 = 曝光结束时间-曝光起始时间
列表阅读进度
- 上报阅读进度时机:在页面退出时间上报,以Fragment为例,在Fragment的onPause回调上报即可
- 阅读进度=(最末一位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统一上报
在上面曝光事件实现的基础上我们就可以实现阅读的进度上报
- 在onItemViewVisible回调里记录最大position
- 在退出界面时上传阅读进度即可
- 可以把此逻辑封装到抽象类中
/**
* 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/