Android-RecyclerView ItemView曝光设计(2)

2,003 阅读5分钟
juejin.cn/post/708712…

之前的设计虽然能满足业务需求,但还存在一些问题。

存在问题
  1. ViewPager+Fragment,预加载Fragment会误上报 ViewPager是有预加载能力,预加载的Fragment中的RecyclerView首次setAdapter也会触发滚动事件,首屏的ItemView也是可见且曝光面积也满足限定(isExpose返回是true),所以也会触发上报,但这是误上报,这有点颠覆三观,Frgment不可见但是View可见,我理解这是系统为预加载所做的处理,有点扯远了;
虽然可以通过判断Fragment是否可见来做过滤,对于调用者来说有使用成本,View的曝光需要依赖外部,不能做到完全的收敛。
  1. 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. 上报曝光失败,导致无法重新上报。 主要是没有考虑上报失败的情况。
增加上报成功集合。
优化方案

问题1、2、3的出现,都是因为自定义RecyclerView以及getGlobalVisibleRect使用不当引起的,虽然可用通过外部增加处理来解决,但这对使用者来说是不友好,需要关注的事情变多。View的曝光判断应该是可以通过自身来处理,所以考虑自定义FrameLayout,注册onDrawListener,当View不断滑动时会回调listener,listener内判断曝光面积是否满足限定以便做后续的处理。注册onDrawListener的时机很重要,当View处于onDetachedFromWindow状态,绘制也会被触发,但我们只需要监听onAttachedToWindow以后的绘制就可以了,所以重写onAttachedToWindow负责注册绘制监听,onDetachedFromWindow注销绘制监听。

设计图

1.png 自定义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//没有曝光回调

![插件化流程 (4).png](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/851091b01bd844deadaa9d2a76b54b53~tplv-k3u1fbpfcp-watermark.image?)
    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 = {
            //移除曝光任务
        })
    }
}
再回到之前提出的问题
  1. “ViewPager+Fragment,预加载Fragment会误上报”,预加载的Fragment,其View getGlobalVisibleRect返回false,所以不会导致误上报;

  2. “ViewPager+Fragment,切换预加载的Fragment,其无法及时上报”,切换预加载的Fragment,其View getGlobalVisibleRect返回true,所以会及时上报。

  3. “无法满足其他容器控件的曝光,例如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)
}
总结
  1. 将监听曝光收敛到内部,不依赖其他组件,提高易用性、降低使用成本;
  2. 极端情况下,进入RecyclerView后立马熄屏或者跳转Activity,无法将失去焦点的ItemView移除曝光任务,因为RecyclerView 下的ItemView无法监听到自身的焦点,但可通过监听RecyclerView的是否有焦点解决。

以上分析有不对的地方,请指出,互相学习,谢谢哦!