接juejin.cn/post/708712…
之前的设计虽然能满足业务需求,但还存在一些问题。
存在问题
- ViewPager+Fragment,预加载Fragment会误上报 ViewPager是有预加载能力,预加载的Fragment中的RecyclerView首次setAdapter也会触发滚动事件,首屏的ItemView也是可见且曝光面积也满足限定(isExpose返回是true),所以也会触发上报,但这是误上报,这有点颠覆三观,Frgment不可见但是View可见,我理解这是系统为预加载所做的处理,有点扯远了;
虽然可以通过判断Fragment是否可见来做过滤,对于调用者来说有使用成本,View的曝光需要依赖外部,不能做到完全的收敛。
- ViewPager+Fragment,切换预加载的Fragment,其无法及时上报 切换到预加载的Fragment,RecyclerView onScrollListener不再触发(因为预加载的时候已经触发了setAdatper),那么就导致无法及时上报。
Fragment可主动触发,但对于调用者来说同样有使用成本。
3.无法满足其他容器控件的曝光,例如ViewPager 如果ViewPager也需要做曝光处理,自定义ViewPager跟自定义RecyclerView就有点冗余了。
自定义Layout可能能满足更多的场景需求。
4.ViewPager+Fragment,不同Fragment存在相同id数据,会导致误移除 属于业务场景,第一个Fragment是热门分类数据,第二个Fragment是某具体分类数据。两个不同Fragment可能有相同id的数据,第一个Fragment添加了曝光任务,第二个Fragment则移除掉(因为不可见),导致误移除。
id+分类,作为去重的依据。
- 上报曝光失败,导致无法重新上报。 主要是没有考虑上报失败的情况。
增加上报成功集合。
优化方案
问题1、2、3的出现,都是因为自定义RecyclerView以及getGlobalVisibleRect使用不当引起的,虽然可用通过外部增加处理来解决,但这对使用者来说是不友好,需要关注的事情变多。View的曝光判断应该是可以通过自身来处理,所以考虑自定义FrameLayout,注册onDrawListener,当View不断滑动时会回调listener,listener内判断曝光面积是否满足限定以便做后续的处理。注册onDrawListener的时机很重要,当View处于onDetachedFromWindow状态,绘制也会被触发,但我们只需要监听onAttachedToWindow以后的绘制就可以了,所以重写onAttachedToWindow负责注册绘制监听,onDetachedFromWindow注销绘制监听。
设计图
自定义ExposeLayout:
class ExposeLayout @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyle: Int = 0
) : FrameLayout(
context, attrs, defStyle
) {
private var onExpose: (() -> Unit)? = null//曝光回调
private var onUnExpose: (() -> Unit)? = null//没有曝光回调

private var exposePercentage: Float = EXPOSE_PERCENTAGE//曝光面积限定百分比,默认是2.0F / 3
companion object {
const val EXPOSE_PERCENTAGE: Float = 2.0F / 3//曝光面积限定百分比
}
private val onDrawListener by lazy {
ViewTreeObserver.OnDrawListener {
when (isExpose()) {
true -> postExpose()
else -> {
postUnExpose()
}
}
}
}
//设置曝光监听
fun initParams(
onExpose: (() -> Unit),
onUnExpose: (() -> Unit),
percentage: Float = EXPOSE_PERCENTAGE
) {
this.onExpose = onExpose
this.onUnExpose = onUnExpose
this.exposePercentage = percentage
}
//判断是否满足曝光条件:可见且超过曝光面积限定
private fun isExpose(): Boolean {
val rect = Rect()
val isVisible: Boolean = getGlobalVisibleRect(rect)//View是否可见
val isOverLimit =
rect.height() * rect.width() >= (measuredHeight * measuredWidth * exposePercentage)//是否超过曝光限定
return isVisible && isOverLimit
}
//View首次添加触发,例如View首次显示或经过onDetachedFromWindow后的显示
override fun onAttachedToWindow() {
super.onAttachedToWindow()
this.viewTreeObserver.addOnDrawListener(onDrawListener)//onDetachedFromWindow成对
}
//View移动出屏幕时候触发,例如RecyclerView滚动
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
this.viewTreeObserver.removeOnDrawListener(onDrawListener)//虽然View被Detached了,但绘制还是会触发,所以移除,防止多余处理
postUnExpose()//触发移除曝光任务
}
private fun postExpose() {
onExpose?.invoke()
}
private fun postUnExpose() {
onUnExpose?.invoke()
}
}
代码比较简单,看看注释就可以了。
调用处:
override fun onBindViewHolder(holder: MyViewHolder, p: Int) {
holder.layout?.run {
initParams(onExpose = {
//添加曝光任务
}, onUnExpose = {
//移除曝光任务
})
}
}
再回到之前提出的问题
-
“ViewPager+Fragment,预加载Fragment会误上报”,预加载的Fragment,其View getGlobalVisibleRect返回false,所以不会导致误上报;
-
“ViewPager+Fragment,切换预加载的Fragment,其无法及时上报”,切换预加载的Fragment,其View getGlobalVisibleRect返回true,所以会及时上报。
-
“无法满足其他容器控件的曝光,例如ViewPager”,自定义布局,可以用在任何场景下;
对于问题4
“ViewPager+Fragment,不同Fragment存在相同id数据,会导致误移除”,id+分类作为去重依据,带入如下
data class Entity(val category: String,val id: Long) {
override fun hashCode(): Int {
return (id.toString() + category).hashCode()
}
}
对应问题5
通过增加uploadSuccessEntityList集合解决
class ExposeManager {
companion object {
const val VISIBLE_TIME = 1500L//曝光时间
}
private val uploadingEntityList = ArrayList<Entity>()//记录上报中
private val uploadSuccessEntityList = ArrayList<Entity>()//记录上报成功
private val handlerThread = HandlerThread("expose_thread")//曝光处理线程,防止堵塞主线程
private val handler: Handler
init {
handlerThread.start()
handler = object : Handler(handlerThread.looper) {
override fun handleMessage(msg: Message) {//曝光时间到,触发上报
super.handleMessage(msg)
uploadingEntityList.add(msg.obj as Entity)
uploadEntity()
}
}
}
//判断是否上报过或者正在上报中
private fun isAlreadyUpload(entity: Entity): Boolean {
return (uploadingEntityList + uploadSuccessEntityList).any {
entity.id.hashCode() == it.id.hashCode()
}
}
//对于隐藏时触发
fun addEntity(entity: Entity) {//1
if (handler.hasMessages(entity.id.hashCode())) {
return
}
if (isAlreadyUpload(entity)) {
return
}
handler.sendMessageDelayed(Message.obtain().apply {
this.what = entity.id.hashCode()
this.obj = entity
}, VISIBLE_TIME)
}
//对于view隐藏时触发
fun removeEntity(entity: Entity) {//2
handler.removeMessages(entity.hashCode())
}
//触发上报
private fun uploadEntity(entity: Entity) {
/***
* 1.如果成功,则将entity从uploadingEntityList,添加到uploadSuccessEntityList
* 2.如果失败,则将再次触发uploadEntity上报,重试次数3次
*/
}
//销毁线程,不满足曝光时间的实体,将无法触发上报
fun destroy() {
handlerThread.quit()
}
//曝光实体
data class Entity(val id: Long)
}
总结
- 将监听曝光收敛到内部,不依赖其他组件,提高易用性、降低使用成本;
- 极端情况下,进入RecyclerView后立马熄屏或者跳转Activity,无法将失去焦点的ItemView移除曝光任务,因为RecyclerView 下的ItemView无法监听到自身的焦点,但可通过监听RecyclerView的是否有焦点解决。
以上分析有不对的地方,请指出,互相学习,谢谢哦!