一、背景
产品需求中我们经常会有统计recyclerView的每个item的曝光需求:
recyclerView上下滚动每个item从不可见进入到屏幕可见范围(这里包含item的可见范围,还有item的曝光时长)
在tab切换,或者页面切换的时候会引起recyclerView从不可见到可见的变化(当前屏幕上可见的item都算一次曝光)
数据变化引起的曝光
为了达到产品需求,我们首先需要数据收集,在滑动过程中收集所有需要上报的item,然后在适当的时机进行上报比如滑动停止、页面切换。
二、需求分析
通过上面的需求分析,我们可以知道recyclerView的曝光主要分为滑动曝光和可见性变化曝光,还有数据变化曝光。
1、滑动曝光
我们可以通过监听recyclerView的滑动过程,在滑动的过程中收集曝光的数据(因为曝光行为就是在滑动过程中产生的),然后当滑动停止的时候去进行一个曝光上报(这样既能保证实时性,又可以兼顾手机的性能)。
2、可见性变化曝光
这里我们需要监听recyclerView的可见性变化,但是并没有提供给我们View可见性变化的监听。虽然有一些焦点变化的监听,但是并不能完全覆盖View的可见性变化。所以这里我们必须想别的办法来实现,这里我通过Actvity的生命周期的onResume和onPause想到有没有可能实现Fragment的onFragmentResume和onFragmentPause来监听Fragment的可见性,监听到Fragment的可见性,也就相当于监听到recyclerView的可见性。然后遍历当前可见的Item收集,并上报即可。
3、数据变化引起的曝光
有时候数据变化也会引起相应的曝光,这种的我们比较好处理只需要监听相应的数据变化。然后对可见的需要曝光的item进行曝光处理即可。
三、实现recyclerView滑动过程中引起的曝光
实现原理流程图如下:
因为Adatper控制着RecyclerView的ViewHolder的创建和绑定,并且对应的数据适配都是在Adapter中完成的,所以这里选择重写Adapter来实现曝光功能。
首先我们需要在recyclerView的滑动过程中进行数据收集,即收集显示到屏幕上需要曝光的item。
我们知道当recylerView的ViewHolder加载到屏幕上会先调用onViewAttachedToWindow(holder: VH),所以我们就选择在这个方法进行数据收集。只要显示到屏幕的数据都会被收集到collectDatas列表当中
/**
* 进行曝光items收集
*/
override fun onViewAttachedToWindow(holder: VH) {
val item = ExpItem<T>()
item.data = holder.mData
item.itemView = holder.itemView
item.postion = holder.mPosition
collectDatas.add(item)
super.onViewAttachedToWindow(holder)
//检查曝光范围,并更新曝光开始时间
if (innerCheckExposureData(item)){
item.startTime = TimeUtil.getCurrentTimeMillis()
}
}
接着我们需要筛选需要进行曝光的数据,计算每个Item在屏幕上的位置,自定义筛选条件(比如:只曝光广告)这个筛选我们需要在recyclerView的滚动过程中进行计算,因为滚动过程中ViewHolder的露出范围是不断发生改变的,然后我们把筛选的数据从collectDatas中移动到expDatas列表当中
为什么这个筛选过程要放在onScrolled过程中?
首先曝光的行为是在滑动过程中达成的,比如我们不断的上下滑动recyclerView,导致item_1不断的出现在屏幕中和消失在屏幕中。假如这个过程中item_1曝光了5次,滑动停止后回到我们初始的滑动位置。如果我们在滑动停止的时候来筛选曝光的item,可能会认为完全没有新曝光的item。因为我们滑动停止在了原来初始的位置,显然这个计算是不对的。想要正确的记录曝光的item,就必须要在recyclerView的滑动过程中去筛选达到曝光条件的item。
其次考虑到曝光时间,在滑动过程中Item达到曝光条件,这时候我们就应该记录曝光的开始时间。在其他的时机无法正确的记录曝光时间。
在onScrolled的过程中进行筛选计算是否会影响recyclerView的性能,导致滑动不流畅?
这个筛选计算分为两个部分,一个部分是需要开发者定义的筛选逻辑,这里就需要开发者自己注意不要有耗时的判断逻辑。 第二个部分是内部的筛选逻辑,主要是判断item的露出高度是否达到曝光要求。
这个判断是否会影响recyclerView的性能?其实也是不会的。
首先item的自身的高度、位置(滑动偏移量)在滑动过程中每一帧的渲染之前都是已经由recyclerView计算好的,否则recyclerView也没有办法把每个item绘制在正确的位置。所以显然这个计算肯定不会影响recyclerView的流畅性。
而我们需要做的判断主要是拿到当前item的位置信息,进行比较看是否达到曝光要求,这个显然也不是一个耗时操作。我们是通过 itemView!!.getGlobalVisibleRect(rect)这个方法来获取item的位置信息的,通过代码跟踪,我们可以看下具体实现逻辑
public boolean getChildVisibleRect(
View child, Rect r, android.graphics.Point offset, boolean forceParentCheck) {
...
rect.set(r);
final int dx = child.mLeft - mScrollX;
final int dy = child.mTop - mScrollY;
...
rect.offset(dx, dy);
...
rectIsVisible = rect.intersect(0, 0, width, height);
...
return rectIsVisible;
}
public boolean intersect(float left, float top, float right, float bottom) {
if (this.left < right && left < this.right
&& this.top < bottom && top < this.bottom) {
if (this.left < left) {
this.left = left;
}
if (this.top < top) {
this.top = top;
}
if (this.right > right) {
this.right = right;
}
if (this.bottom > bottom) {
this.bottom = bottom;
}
return true;
}
return false;
}
通过代码可知我们获取itemView的可见范围主要是通过itemView的当前位置rect和他所在的ViewGroup(即recyclerView)的范围大小做交集。看代码可知也都是一些比较大小的逻辑,没有耗时操作,所以无需担心这个会造成recyclerView的滑动卡顿。
那么接下来我们看下具体实现筛选过程
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
val it = collectDatas.iterator()
while (it.hasNext()) {
val item = it.next()
//判断露出范围
if (innerCheckExposureData(item)) {
if (item.startTime == 0L) {
item.startTime = TimeUtil.getCurrentTimeMillis()
}
if (funcCheck == null) {
expDatas.add(item)
//自定义过滤条件
} else if (funcCheck!!.invoke(item)) {
expDatas.add(item)
}
it.remove()
}
}
}
onScrolled方法第8-10行是更新曝光开始时间 onScrolled方法第7行是露出范围的检测
/**
* 内部判断当itemView的可见度达到多少才需要曝光
*/
private fun innerCheckExposureData(item: ExpItem<*>): Boolean {
val rect = Rect()
val visible = item.itemView!!.getGlobalVisibleRect(rect)
if (visible) {
if (rect.height() >= item.itemView!!.measuredHeight * outPercent) {
return true
}
}
return false
}
onScrolled方法第11-16行是我们自定义的筛选条件判断,即下面对应第10行的筛选条件
/**
* 设置曝光监听
*/
myAdapter.setExposureFunc(items->{
//返回需要曝光的数据列表
for (ExpItem<NewFeedBean> item : items) {
LogUtil.d("kami","exposure position = " + item.getPostion() + ": " +item.getData().sourceFeed.content + ",duration = " + (item.getEndTime() - item.getStartTime()));
}
return null;
},item->{
//自定义需要曝光筛选:比如只曝光广告数据
return item.getData().isAd();
});
最后在滑动停止进行曝光数据回调之前进行曝光时长的筛选。从expDatas中选择达到曝光时长的数据,最后进行数据上报
//设置曝光监听
when (newState) {
//滑动完成
RecyclerView.SCROLL_STATE_IDLE ->
val needExpDatas = getExposureList(expDatas)
if (!needExpDatas.isEmpty()) {
funcExp?.invoke(needExpDatas)
}
else -> {
}
第5行就是我们对曝光时长的筛选
/**
* 内部判断当itemView的曝光时长达到多少才需要进行曝光
*/
private fun getExposureList(expDatas: ArrayList<ExpItem<T>>): ArrayList<ExpItem<T>> {
val needExpDatas = ArrayList<ExpItem<T>>()
val it = expDatas.iterator()
while (it.hasNext()) {
val item = it.next()
if (item.endTime != 0L) {
if (item.endTime - item.startTime >= exposureTime) {
needExpDatas.add(item)
}
it.remove()
} else {
if (TimeUtil.getCurrentTimeMillis() - item.startTime >= exposureTime) {
item.endTime = TimeUtil.getCurrentTimeMillis()
needExpDatas.add(item)
it.remove()
}
}
}
return needExpDatas
}
在ViewHolder AttachToWindow 的时候,和RecyclerView 滑动的时候我们会更新曝光开始时间,在 ViewHolder DettachToWindw 和 RecyclerView 滑动停止的时候我们会更新曝光结束时间。
item.endTime 不为零如果达到曝光时长,则表示需要进行曝光加入曝光列表,否则就舍弃。
item.endTime 为零则表示ViewHoler还在持续曝光,则用当前时间计算,达到曝光时间加入曝光列表,否则不做处理(因为还在持续曝光,等下次滑动达到曝光时长在进行曝光)。
下面是onViewDetachedFromWindow的具体代码,包括移除无需曝光的数据和更新曝光结束时间。
/**
* 对移除的itemView进行曝光时长的修改
*/
override fun onViewDetachedFromWindow(holder: VH) {
//在Dettached的时候,未被移动到expDatas列表的数据证明没有达到曝光条件,无需曝光。就可以把它们从collectDatas列表中移除了
val it = collectDatas.iterator()
while (it.hasNext()) {
val item = it.next()
if (holder .mPosition == item.postion && holder.itemView === item.itemView) {
it.remove()
}
}
//更新曝光结束时间
for (expItem in expDatas) {
if (holder.mPosition == expItem.postion && expItem.itemView === holder.itemView) {
expItem.endTime = TimeUtil.getCurrentTimeMillis()
}
}
super.onViewDetachedFromWindow(holder)
}
这样我们就完成了RecyclerView在滑动过程中引起的上报。