# 全网最优雅安卓列表项可见性检测引子现有方案类型无关列表项可见性检测嵌套列表适配ViewPager2 页可见性检测Talk is cheap,show me the code推荐阅读 站点:答源(https://www.dandroid.cn/) 原文:https://www.dandroid.cn/archives/14363 Canonical:https://www.dandroid.cn/archives/14363 作者:dandroid002 发布日期:2023-07-27 最后更新:2023-07-27 03:08 分类:Android应用源码 / 列表 内容类型:post 说明:本页是原文的纯文本镜像,供大模型与检索器完整读取;不含导航、广告、付费框。引用请以上方「原文」链接为准。 ## 正文 引子 业务开发中列表项的曝光埋点做得越来越精细了。 一开始,我是在 onBindView() 中上报列表项曝光的: // RecyclerView.Adapter.kt override fun onBindViewHolder(holder: ViewHolder, position: Int, payloads: MutableList) { ReportUtil.reportShow("material-item-show",materialId) } 这样实现超简单,但也有缺点。 首先埋点逻辑入侵 Adapter,Adapter 的使命是数据和视图的变换,现在和它使命无关的埋点被植入。这使得它不再单纯,后果是它无法被单独的复用。假设另一个业务场景会请求同样的接口,展示同样的列表,Adapter 的代码无法被复用,因为它和"material-item-show"耦合(现在的埋点可能换为"search-material-item_show")。 其次曝光的准确性不够,因为 onBindViewHolder() 方法是先于展示的,即使用户还没有看到该表项,它就已经被上报展示了。 为了更精确的上报列表项展示埋点,埋点需求变为当表项展示超过 50% 时,才上报。 这样的话,就无法在 onBindViewHolder() 触发埋点上报了。 现有方案 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 } } }) 该方案存在两个假设: 列表项使用线性布局管理器 LinearLayoutManager 列表是纵向滑动的(所以只要计算高度百分比就好) 显然这方案不够通用,比如当换用 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 来讨论,那就没有扩展性可言。 类型无关列表项可见性检测 通过为 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 = emptyList(), block: (itemView: View, adapterIndex: Int, isVisible: Boolean) -> Unit ) { // 可复用的矩形区域,避免重复创建 val childVisibleRect = Rect() // 记录所有可见表项搜索的列表 val visibleAdapterIndexs = mutableSetOf() // 将列表项可见性检测定义为一个 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: https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/4a2454d3a7564e5480ce475d8179c13b~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.awebp? 页面底边栏的横向标签是一个用 RecyclerView 实现的列表,当点击列表项时会弹出 Fragment 并遮挡列表。此时,列表应该将之前可见的那些表项回调为不可见,当 Fragment 消失时再回调可见。 列表不可见,应该回调其所有表项也不可见。但当列表被遮挡时,并不会回调 onScroll(),所以上述方案缺少一个遮挡时机。 结合全网最优雅安卓控件可见性检测中检测控件可见性的方案,修改如下: fun RecyclerView.onItemVisibilityChange( percent: Float = 0.5f, viewGroups: List? = null, block: (itemView: View, adapterIndex: Int, isVisible: Boolean) -> Unit ) { val childVisibleRect = Rect() val visibleAdapterIndexs = mutableSetOf() 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) } }) } 嵌套列表适配 上述方案可用于嵌套 RecyclerView 的可见性检测,嵌套列表效果如下图所示: https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/23d7e9a36d58494e9f96027d6a5a38fe~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.awebp? 整个页面是一个纵向 RecyclerView,其内部每个表项都是一个横向的 RecyclerView。 只需要如下面这样使用即可检测每个内层表项的可见性: // 为外层列表设置可见性监听 recyclerView.onItemVisibilityChange { itemView, outerIndex, isVisible -> // 为内层列表设置可见性监听 (itemView as? RecyclerView)?.takeIf { isVisible }?.apply { onItemVisibilityChange { itemView, innerIndex, isInnerVisible -> } scrollBy(1,0)// 当内层列表可见时,手动触发一次滚动 } } 为外层设置一个可见性监听器,当外层列表每个表项可见的时候,判断其是否是 RecyclerView,若是则表示嵌套,就继续为内层列表设置可见性监听。最后还要做一个小动作,即让内层表项滚动一个像素,这样才会触发它的 onScroll,才能检测其表项可见性。 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,在内部记录了上一页,以此来向上层回调上一页不可见事件。 Talk is cheap,show me the code 文章来源于互联网:全网最优雅安卓列表项可见性检测引子现有方案类型无关列表项可见性检测嵌套列表适配ViewPager2 页可见性检测Talk is cheap,show me the code推荐阅读 --- 由 答源 DAN 底座 1.4.0 生成 · 2026-11-07T20:54:51+00:00