Android-RecyclerView ItemView曝光设计(1)

527 阅读4分钟
业务背景

RecyclerView ItemView满足曝光限定,则立刻将相关信息上报服务器,作为后续产品设计、数据下发的依据。

实现思路
  1. 自定义RecyclerView监听滚动事件,判断所有ItemView曝光面积是否满足限定,满足则加入曝光管理器的待上报队列,否则将其从队列中移除,为什么需要移除?在RecyclerView快速滚动的时候,ItemView快速显示隐藏,曝光时间可能无法满足限定,所以需要移除。
  2. 考虑到曝光面积、时间满足限定则立马上报,而不是依赖ItemView滑走、Activity/Fragment关闭等触发,所以曝光管理器采用Handler发送延时消息的方案;
曝光条件
  1. View曝光面积需大于等于自身面积的2/3,可使用getGlobalVisibleRect解决;
  2. 曝光时间需大于等于1500毫秒,可用Handler().sendMessageDelayed解决;
  3. 同一条数据一个Activity生命周期只上报一次,可维护上传集合去重来解决;
流程图

1.png

根据以上分析,需设计有自定义RecyclerView以及曝光管理器ExposeManager。
  1. 自定义RecyclerView 自定义RecyclerView,其负责监听ItemView的曝光面积。
class ExposeRecyclerView @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null, defStyle: Int = 0
) : RecyclerView(//自定义RecyclerView
    context, attrs, defStyle
) {
    
    interface ItemViewVisibleListener {//回调接口(显示、隐藏)
        fun onItemViewVisible(position: Int)
        fun onItemViewGone(position: Int)
    }

    // 回调监听器
    var itemViewVisibleListener: ItemViewVisibleListener? = null

    init {
        //注册滚动监听
        addOnScrollListener(object : OnScrollListener() {
            override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                super.onScrolled(recyclerView, dx, dy)
                handleScrolled()
            }
        })
    }

    //处理滚动回调,第一次加载数据也会触发
    private fun handleScrolled() {
        when (layoutManager) {
            is LinearLayoutManager -> {
                handleVisibleItem(
                    recyclerView,
                    layoutManager.findFirstVisibleItemPosition(),
                    layoutManager.findLastVisibleItemPosition(),
                    layoutManager.itemCount
                )
            }
        }
    }

    private fun handleVisibleItem(
        recyclerView: RecyclerView, firstVisiblePosition: Int, lastVisiblePosition: Int, itemCount: Int
    ) {
        for (i in 0 until itemCount) {//1
            when (i) {
                in firstVisiblePosition..lastVisiblePosition -> {
                    callListenerByItemViewRect(recyclerView.findViewHolderForAdapterPosition(i))
                }
                else -> {
                    itemViewVisibleListener?.onItemViewGone(i)
                }
            }
        }
    }

    //回调不同的接口
    private fun callListenerByItemViewRect(viewHolder: ViewHolder?) {
        viewHolder?.apply {
            if (itemView.isExpose()) {
                itemViewVisibleListener?.onItemViewVisible(adapterPosition)
            } else {
                itemViewVisibleListener?.onItemViewGone(adapterPosition)
            }
        }
    }

    //判断曝光面积是否满足条件
    fun View.isExpose(): Boolean {//2
        val rect = Rect()
        val isVisibleRect: Boolean = this.getGlobalVisibleRect(rect)
        return (isVisibleRect
                && rect.height() * rect.width() >= measuredHeight * measuredWidth * 2 / 3)
    }
}

重点分析以下2个点,其他比较简单,看看注释就差不多了。

  • 注释1:在[firstVisiblePosition,lastVisiblePosition]区域则认为可见,需进一步判断曝光面积,否则回调不可见;为什么需要回调不可见呢?上面有解释,大家可以返回看下;
  • 注释2:getGlobalVisibleRect获取当前ItemView可视区域,返回true则为可见,且rect记录可见区域大小,条件rect.height() * rect.width() >= measuredHeight * measuredWidth * 2 / 3,判断可见区域是否大于等于View面积的2/3;
曝光管理器ExposeManager

负责处理延时的曝光任务。利用Handler自带队列的特性,将满足曝光面积满足限定的添加到队列,延时处理。

class ExposeManager {

    companion object {
        const val VISIBLE_TIME = 1500L//曝光时间
    }

    private val uploadedEntityList = 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)
                uploadedEntityList.add(msg.obj as Entity)
                uploadEntity()
            }
        }
    }

    //判断是否上报过
    private fun isAlreadyUpload(entity: Entity): Boolean {
        return uploadedEntityList.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() {}

    //销毁线程,不满足曝光时间的实体,将无法触发上报
    fun destroy() {
        handlerThread.quit()
    }
    
    //曝光实体
    data class Entity(val id: Long)
}

重点分析以下2个点,其他比较简单,看看注释就差不多了。

  • 注释1:当ItemView曝光面积满足限定时触发,如果Handler队列中没有且没有上报过,则发送延时任务,曝光时间达到则上报;
  • 注释2:当ItemView曝光面积不满足限定时触发,如果Handler队列没有相同的任务,则移除失败,内部会自动处理。
如何使用
resourceRv.itemViewVisibleListener = object :
    ExposeRecyclerView.ItemViewVisibleListener {
    override fun onItemViewVisible(position: Int) {
        //调用ExposeManager addEntity()添加
    }

    override fun onItemViewGone(position: Int) {
        //调用ExposeManager removeEntity()移除
    }
}
总结
  1. 分层设计,自定义RecyclerView负责监听ItemView的曝光面积;而ExposeManager负责处理曝光时间、待上报任务队列、上报成功任务队列,两者不应该耦合在一起,责任单一,也好维护;
  2. 曝光发生会比较频繁,如果是在主线程处理任务,可能会堵塞主线程,所以考虑使用HandlerThread;
  3. 使用handlerThread.quit()销毁线程,不满足曝光时间限定的任务,将无法触发上报,使用quitSafely效果也是一样,因为这里都是延迟消息;

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