服务粉丝

我们一直在努力
当前位置:首页 > 财经 >

厉害,最优雅的Android列表项可见性检测!

日期: 来源:刘望舒收集编辑:唐子玄
 安卓进阶涨薪训练营,让一部分人先进大厂


大家好,我是皇叔,最近开了一个安卓进阶涨薪训练营,可以帮助大家突破技术&职场瓶颈,从而度过难关,进入心仪的公司。


详情见文章:没错!皇叔开了个训练营


作者:唐子玄
https://juejin.cn/post/7165428399282847757

1.引子

业务开发中列表项的曝光埋点做得越来越精细了。


一开始,我是在 onBindView() 中上报列表项曝光的:
// RecyclerView.Adapter.kt
override fun onBindViewHolder(holder: ViewHolder, position: Int, payloads: MutableList<Any>) {
    ReportUtil.reportShow("material-item-show",materialId)
}

这样实现超简单,但也有缺点。
首先埋点逻辑入侵 Adapter,Adapter 的使命是数据和视图的变换,现在和它使命无关的埋点被植入。这使得它不再单纯,后果是它无法被单独的复用。假设另一个业务场景会请求同样的接口,展示同样的列表,Adapter 的代码无法被复用,因为它和"material-item-show"耦合(现在的埋点可能换为"search-material-item_show")。
其次曝光的准确性不够,因为 onBindViewHolder() 方法是先于展示的,即使用户还没有看到该表项,它就已经被上报展示了。
为了更精确的上报列表项展示埋点,埋点需求变为当表项展示超过 50% 时,才上报。

这样的话,就无法在 onBindViewHolder() 触发埋点上报了。

2.现有方案

Stack Overflow 上有一个高赞回答:
// 监听列表滚动事件
recycler.addOnScrollListener(object : RecyclerView.OnScrollListener() {
    override fun onScrolled(recyclerView: RecyclerView?, dx: Int, dy: Int) {
        // 获取线性布局管理器(假设)
        val layoutManager = recycler.layoutManager as LinearLayoutManager
        // 获取布局管理器中的第一个/最后一个表项索引
        val firstPosition = layoutManager.findFirstVisibleItemPosition()
        val lastPosition = layoutManager.findLastVisibleItemPosition()
        // 遍历可见表项逐个计算可见百分比
        for (pos in firstPosition..lastPosition) {
           val view = layoutManager.findViewByPosition(pos)
           if (view != null) {
               val percentage = getVisibleHeightPercentage(view)
           }
        }
    }

    // 计算表项可见百分比
    private fun getVisibleHeightPercentage(view: View): Double {
        // 获取表项可见矩形区域
        val itemRect = Rect()
        val isParentViewEmpty = view.getLocalVisibleRect(itemRect)

       // 获取表项应有高度
       val visibleHeight = itemRect.height().toDouble()
       val height = view.getMeasuredHeight()
       // 获取表项高度可见百分比(假设)
       val viewVisibleHeightPercentage = visibleHeight / height * 100
       if(isParentViewEmpty){
          return viewVisibleHeightPercentage
       }else{
           return 0.0
        }
     }
})
该方案存在两个假设:
  1. 列表项使用线性布局管理器 LinearLayoutManager。
  2. 列表是纵向滑动的(所以只要计算高度百分比就好)。
显然这方案不够通用,比如当换用 GridLayoutManager时,就 gg 了。
于是乎,就有了下面这个分类讨论的方案:
recycler.addOnScrollListener(object : RecyclerView.OnScrollListener() {
    override fun onScrolled(recyclerView: RecyclerView?, dx: Int, dy: Int) {
        val layoutManager = recycler.layoutManager
        if( layoutManager is LinearLayoutManager) {
           ...
        } else if( layoutManager is GridLayoutManager) {
           ...
        } else if( layoutManager is StaggeredGridLayoutManager) {
           ...
        }
    }
})
那自定义 LayoutManager 咋办?
每次新增一个 LayoutManager 都来修改上面这个方法?显然这破坏了开闭原则。

和类型相关的问题,如果使用 if-else 来讨论,那就没有扩展性可言。

3.类型无关列表项可见性检测

通过为 RecyclerView 新增扩展方法的方式来检测表项可见性:
fun RecyclerView.addOnItemVisibilityChangeListener(
    percent: Float = 0.5f, // 列表项可见性阈值
    block: (itemView: View, adapterIndex: Int, isVisible: Boolean) -> Unit
) {
    val scrollListener = object : OnScrollListener() {
        override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {}
    }
    addOnScrollListener(scrollListener)
}
为 RecyclerView 新增一个扩展方法用于表项的可见性检测,在方法内部为它设置一个滚动监听器。
这就完成了可见性检测的第一步,即捕获滚动的时机。
第二步检测可见性的方案是:“遍历所有 RecyclerView 的子控件,逐个获取子控件的可见矩形区域,并将其和原始尺寸做比对。”
fun RecyclerView.onItemVisibilityChange(
    percent: Float = 0.5f, 
    viewGroups: List<ViewGroup> = emptyList(),
    block: (itemView: View, adapterIndex: Int, isVisible: Boolean) -> Unit
) {
    // 可复用的矩形区域,避免重复创建
    val childVisibleRect = Rect()
    // 记录所有可见表项搜索的列表
    val visibleAdapterIndexs = mutableSetOf<Int>()
    // 将列表项可见性检测定义为一个 lambda
    val checkVisibility = {
        // 遍历所有 RecyclerView 的子控件
        for (i in 0 until childCount) {
            val child = getChildAt(i)
            // 获取其适配器索引
            val adapterIndex = getChildAdapterPosition(child)
            if(adapterIndex == NO_POSITION) continue
            // 计算子控件可见区域并获取是否可见标记位
            val isChildVisible = child.getLocalVisibleRect(childVisibleRect) 
            // 子控件可见面积
            val visibleArea = childVisibleRect.let { it.height() * it.width() }
            // 子控件真实面积
            val realArea = child.width * child.height
            // 比对可见面积和真实面积,若大于阈值,则回调可见,否则不可见
            if (isChildVisible && visibleArea >= realArea * percent) {
                if (visibleAdapterIndexs.add(adapterIndex)) {
                    block(child, adapterIndex, true)
                }
            } else {
                if (adapterIndex in visibleAdapterIndexs) {
                    block(child, adapterIndex, false)
                    visibleAdapterIndexs.remove(adapterIndex)
                }
            }
        }
    }
    // 为列表添加滚动监听器
    val scrollListener = object : OnScrollListener() {
        override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
            super.onScrolled(recyclerView, dx, dy)
            checkVisibility()
        }
    }
    addOnScrollListener(scrollListener)
    // 避免内存泄漏,当列表被移除时,反注册监听器
    addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
        override fun onViewAttachedToWindow(v: View?) {
        }

        override fun onViewDetachedFromWindow(v: View?) {
            if (v == null || v !is RecyclerView) return
            v.removeOnScrollListener(scrollListener)
            removeOnAttachStateChangeListener(this)
        }
    })
}

View.getLocalVisibleRect()该方法会产生两个结果,一是控件是否可见的布尔值,二是控件可见区域 Rect。当控件不可见时,即返回值为 false,Rect 会变成整个控件的真实区域。所以得结合布尔值和区域做综合判断。
该方案的通用性表现在,完全不依赖于 LayoutManager,而是直接获取 RecyclerView 的子控件进行遍历。(其实 LayoutManager 只是在获取子控件的调用链路上包了一层,最终还是通过 RecyclerView 获取其子控件)
在 onScrolled() 回调中遍历 RecyclerView 所有的子控件会不会有性能问题?
不会,因为 RecyclerView 在任何时候只会持有可见的那几个表项作为子控件,如下图所示:

图中假设 Adapter 持有 7 个数据,它们的 Adapter Index 是 0-6,而 RecyclerView 的高度只够展示 4 个。列表发生滚动时,Recyclerview 永远只有 4 个子控件,子控件的 Layout Index 永远是 0-3,但 Layout Index 和 Adapter Index 的映射会随着滚动而发生变化。
通过遍历 RecyclerView 子控件的方式具有通用性,简化了可见性检测代码的复杂度。
使得 RecyclerView 表项可见性检测时不再需要关心具体的 LayoutManger,避免面向具体的 LayoutManger 编程。
另外判定可见的方式是通过对比面积,这样就避免了对横竖列表的分类讨论,简化了实现复杂度。
最后该扩展方法除了向上层回调表项可见之外,还回调了不可见,以丰富上层的使用场景。
上述方案会有一个例外 case:


页面底边栏的横向标签是一个用 RecyclerView 实现的列表,当点击列表项时会弹出 Fragment 并遮挡列表。此时,列表应该将之前可见的那些表项回调为不可见,当 Fragment 消失时再回调可见。
列表不可见,应该回调其所有表项也不可见。但当列表被遮挡时,并不会回调 onScroll(),所以上述方案缺少一个遮挡时机。

结合全网最优雅安卓控件可见性检测中检测控件可见性的方案,修改如下:

https://juejin.cn/post/7165427955902971918


fun RecyclerView.onItemVisibilityChange(
    percent: Float = 0.5f,
    viewGroups: List<ViewGroup>? = null,
    block: (itemView: View, adapterIndex: Int, isVisible: Boolean) -> Unit
) {
    val childVisibleRect = Rect()
    val visibleAdapterIndexs = mutableSetOf<Int>()
    val checkVisibility = {
        for (i in 0 until childCount) {
            val child = getChildAt(i)
            val adapterIndex = getChildAdapterPosition(child)
            if(adapterIndex == NO_POSITION) continue
            val isChildVisible = child.getLocalVisibleRect(childVisibleRect)
            val visibleArea = childVisibleRect.let { it.height() * it.width() }
            val realArea = child.width * child.height
            if (this.isInScreen && isChildVisible && visibleArea >= realArea * percent) {
                if (visibleAdapterIndexs.add(adapterIndex)) {
                    block(child, adapterIndex, true)
                }
            } else {
                if (adapterIndex in visibleAdapterIndexs) {
                    block(child, adapterIndex, false)
                    visibleAdapterIndexs.remove(adapterIndex)
                }
            }
        }
    }
    val scrollListener = object : OnScrollListener() {
        override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
            super.onScrolled(recyclerView, dx, dy)
            checkVisibility()
        }
    }
    addOnScrollListener(scrollListener)
    // 为列表添加全局可见性检测
    onVisibilityChange(viewGroups,false) { view, isVisible ->
        // 当列表可见时,检测其表项的可见性
        if (isVisible) {
            checkVisibility()
        } else {
            // 当列表不可见时,回调所有可见表项为不可见
            for (i in 0 until childCount) {
                val child = getChildAt(i)
                val adapterIndex = getChildAdapterPosition(child)
                if (adapterIndex in visibleAdapterIndexs) {
                    block(child, adapterIndex, false)
                    visibleAdapterIndexs.remove(adapterIndex)
                }
            }
        }
    }
    addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
        override fun onViewAttachedToWindow(v: View?) {
        }

        override fun onViewDetachedFromWindow(v: View?) {
            if (v == null || v !is RecyclerView) return
            v.removeOnScrollListener(scrollListener)
            removeOnAttachStateChangeListener(this)
        }
    })
}

4.ViewPager2 页可见性检测

ViewPager2 是对 RecycerView 的二次封装,理论上可以复用 RecyclerView 的可见性检测方案。

但可惜的是,它并未开放获取内部 RecyclerView 的接口,遂也只能另起炉灶:
fun ViewPager2.addOnPageVisibilityChangeListener(block: (index: Int, isVisible: Boolean) -> Unit) {
    // 当前页
    var lastPage: Int = currentItem
    // 注册页滚动监听器
    val listener = object : OnPageChangeCallback() {
        override fun onPageSelected(position: Int) {
            // 回调上一页不可见
            if (lastPage != position) {
                block(lastPage, false)
            }
            // 回调当前页可见
            block(position, true)
            lastPage = position
        }
    }
    registerOnPageChangeCallback(listener)
    // 避免内存泄漏
    addOnAttachStateChangeListener(object : OnAttachStateChangeListener {
        override fun onViewAttachedToWindow(v: View?) {
        }

        override fun onViewDetachedFromWindow(v: View?) {
            if (v == null || v !is ViewPager2) return
            if (ViewCompat.isAttachedToWindow(v)) {
                v.unregisterOnPageChangeCallback(listener)
            }
            removeOnAttachStateChangeListener(this)
        }

    })
}

利用 ViewPager2 提供的 OnPageChangeCallback,在内部记录了上一页,以此来向上层回调上一页不可见事件。



为了防止失联,欢迎关注我防备的小号

 

               微信改了推送机制,真爱请星标本公号

相关阅读

  • Flutter布局指南之约束和尺寸

  • 点击上方蓝字关注我,知识会给你力量Flutter布局总纲——向下传递约束,向上传递尺寸。Box约束约束是Flutter布局的核心,在Flutter中,约束的表现形式是通过Constraints类来实现的,
  • 免跑腿,养老待遇资格认证很省心!

  • 新春伊始,大地回春2023年如约而至各位退休人员别忘了做这件事养老待遇资格认证一起重温养老待遇资格认证的政策吧认证时间 从2018年7月起,我市养老待遇资格认证时间由原来
  • 失业保险金申领指南

  • 网上自助操作方便多多,24小时在您身边。手机操作令您感到迷糊?对社保业务不够了解?社保短片让您一看就懂,过目不忘!工作暂时没着落?别担心失业保险金当您的小太阳失业保险金申领条
  • 来了!就在今天4时36分

  • 新朋友戳 蓝字关注我们哦!农历二月十五惊 蛰微雨众卉新,一雷惊蛰始惊蛰时节AWAKENING OF INSECTS今天4时36分我们迎来二十四节气中的第三个节气惊 蛰微雨众卉新,一雷惊蛰始数九
  • 党的二十大报告学习辅导(86)

  • 为深入学习贯彻党的二十大精神,中央有关方面组织编写了《党的二十大报告学习辅导百问》一书。该书紧密围绕党的二十大报告提出的新理念新战略新论断,对100多个问题进行了深入

热门文章

  • “复活”半年后 京东拍拍二手杀入公益事业

  • 京东拍拍二手“复活”半年后,杀入公益事业,试图让企业捐的赠品、家庭闲置品变成实实在在的“爱心”。 把“闲置品”变爱心 6月12日,“益心一益·守护梦想每一步”2018年四

最新文章

  • 被一体化压住

  • 原本是打算今天继续昨天的重组行情聊,没想到被一体化压铸爆头了。下午P了张图,居然在微博上传遍了......果然亏久了人人都可以是段子手。从行业维度,一体化压铸肯定是有边际利
  • 厉害,最优雅的Android列表项可见性检测!

  • 安卓进阶涨薪训练营,让一部分人先进大厂大家好,我是皇叔,最近开了一个安卓进阶涨薪训练营,可以帮助大家突破技术&职场瓶颈,从而度过难关,进入心仪的公司。详情见文章:没错!皇叔开了
  • 京东三面:怎么给详情页做性能优化的?

  • 原文地址:https://toutiao.io/posts/0kwkbbt一、背景针对老项目,去年做了许多降本增效的事情,其中发现最多的就是接口耗时过长的问题,就集中搞了一次接口性能优化。本文将给小伙