Android RecyclerView 可见与不可见 View 获取及曝光统计实战
发布时间:2026/10/22 16:19:56 锦皓数字建站

1. 为什么“拿到可见与不可见的 View”是个高频刚需做 Android 列表页做久了迟早会撞上这个需求想对屏幕上真正显示出来的 item 做点事情或者反过来想拿到那些已经滑出屏幕、被回收掉的 item 做预加载、埋点补报、状态回写。RecyclerView 不像老式的 ListView 那样有getChildAt就能一把梭它的复用机制决定了“可见”和“不可见”是两个完全不同的世界你得用两套思路分别处理。我第一次踩这个坑是在做一个视频流列表的时候。产品要求“滑到哪一屏就自动播放那一屏的视频滑走的暂停”听起来简单但真写起来发现onBindViewHolder里拿不到“我现在是不是可见”这个状态onScrolled里又只能拿到偏移量得自己算哪个 position 落在可视区里。后来又要做曝光埋点要求“item 真正露出 50% 以上才算一次有效曝光”这就更细了光靠 position 判断根本不够得拿到真实的 View 对象去测它的可见比例。所以这篇东西我打算把这几件事一次讲透怎么拿到当前屏幕上可见的 View、怎么拿到那些被缓存的不可见 View、以及怎么判断一个 View 到底“可见了多少”。这些都是我在实际项目里反复验证过的写法不是抄文档是踩过坑之后沉淀下来的。适合谁看只要你在用 RecyclerView 做列表不管是做播放器、做曝光统计、做局部刷新还是单纯想搞清楚 RecyclerView 的复用逻辑这篇都能给你可直接抄的代码。基础要求不高会写 Adapter、知道 ViewHolder 是什么就行剩下的我尽量用大白话讲清楚。2. 先搞懂 RecyclerView 的“可见”到底分几层2.1 屏幕可见、布局可见、缓存可见别混为一谈很多人一上来就问“怎么拿到可见的 View”但“可见”这个词在 RecyclerView 里其实有三层含义分不清就会写出时灵时不灵的代码。第一层是屏幕可见也就是用户眼睛真正能看到的那部分。这一层最严格一个 item 可能只露出一个角算不算可见取决于你的业务定义。第二层是布局可见指的是这个 item 的 View 当前被 attach 到了 RecyclerView 上参与了布局。它可能有一部分在屏幕外但 View 对象是真实存在、可以拿到的。第三层是缓存可见指的是那些已经滑出屏幕、被 detach 掉但还躺在 RecyclerView 的缓存池比如mCachedViews或者RecycledViewPool里的 View。这些 View 严格来说不算“可见”但在做预加载、状态恢复的时候特别有用。我见过不少代码把这三层混着用结果就是快速滑动的时候曝光数据乱跳或者视频播放器该停的没停。根子就在于没分清你要的到底是哪一层。2.2 为什么不能直接用 getChildAt 遍历有人会说RecyclerView 不是有getChildAt(int index)吗直接 for 循环遍历不就行了能跑但有两个坑。第一个坑是getChildAt的参数是子 View 的索引不是 adapter 的 position。这两个值在正常情况下可能碰巧相等但一旦有 item 动画、或者有装饰器ItemDecoration插入了额外的 View它们就对不上了。你得用getChildAdapterPosition(child)去换算才能拿到真正的 adapter position。第二个坑是getChildCount()返回的是当前 attach 的子 View 数量它天然只包含“布局可见”这一层拿不到缓存里的 View。所以如果你的需求是“拿到所有可见 View”遍历getChildAt是对的但如果你要的是“拿到某个 position 的 View不管它在不在屏幕上”那就得换思路。提示getChildAt拿到的是 RecyclerView 的直接子 View通常就是 itemView但如果你的 item 布局里又套了 RecyclerView注意别拿错层级。2.3 可见性判断的核心LayoutManager 才是权威真正决定“哪些 item 可见”的是 LayoutManager。LinearLayoutManager、GridLayoutManager、StaggeredGridLayoutManager 各自有一套findFirstVisibleItemPosition/findLastVisibleItemPosition的方法这才是判断可见范围的正道。为什么强调用 LayoutManager因为 RecyclerView 本身不关心布局方式它把布局这件事完全委托给了 LayoutManager。你直接问 RecyclerView “谁可见”它答不上来但你问 LayoutManager它能给你精确的 first 和 last position。这里有个细节要注意findFirstVisibleItemPosition返回的是“第一个部分或完全可见的 item”而findFirstCompletelyVisibleItemPosition返回的是“第一个完全可见的 item”。这两个差一个词结果可能差好几个 item用的时候一定看清楚需求。3. 拿到可见 View 的三种实战写法3.1 遍历子 View最直接适合做局部操作如果你要对当前屏幕上所有可见的 item 做统一操作比如统一暂停视频、统一改背景色遍历子 View 是最直观的写法val recyclerView findViewByIdRecyclerView(R.id.recycler_view) for (i in 0 until recyclerView.childCount) { val child recyclerView.getChildAt(i) val position recyclerView.getChildAdapterPosition(child) if (position ! RecyclerView.NO_POSITION) { // 这里就能拿到可见的 child 和它对应的 position // 比如暂停这个 item 里的视频 val holder recyclerView.getChildViewHolder(child) (holder as? VideoViewHolder)?.pause() } }这段代码的关键在getChildAdapterPosition它帮你把子 View 索引换算成 adapter position。为什么要判NO_POSITION因为在 item 动画进行中或者 View 刚被添加还没绑定数据的时候position 可能是无效的这时候你去操作数据就会越界。我实测下来这段代码在正常滑动、快速滑动、甚至滑动中带 item 动画的场景下都稳。唯一要注意的是别在onBindViewHolder里调用它那时候布局还没完成childCount 可能是 0。3.2 用 LayoutManager 定位精确到 position 范围当你需要的是“第一个可见的 position 到最后一个可见的 position”这个范围而不是具体的 View 对象时LayoutManager 更合适val layoutManager recyclerView.layoutManager as LinearLayoutManager val firstVisible layoutManager.findFirstVisibleItemPosition() val lastVisible layoutManager.findLastVisibleItemPosition() for (position in firstVisible..lastVisible) { // 这里拿到的是 position 范围 // 如果需要 View再用 findViewByPosition 去取 val view layoutManager.findViewByPosition(position) if (view ! null) { // 这个 position 的 View 当前是可见的 } }findViewByPosition是个好东西它会在当前 attach 的子 View 里找对应 position 的 View找不到就返回 null。注意它只找“布局可见”的缓存里的找不到这是它的边界。GridLayoutManager 的用法类似但要注意它返回的 first/last 是按行算的跨行的时候范围可能比你想象的大。StaggeredGridLayoutManager 更特殊它的findFirstVisibleItemPositions返回的是一个数组因为瀑布流每一列的起始位置可能不一样你得取数组里的最小值当 first最大值当 last。3.3 结合 OnScrollListener滑动中实时获取上面两种写法都是“某一时刻”的快照。如果你要在滑动过程中持续获取可见 View比如做实时曝光统计就得挂OnScrollListenerrecyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) { super.onScrolled(rv, dx, dy) val lm rv.layoutManager as LinearLayoutManager val first lm.findFirstVisibleItemPosition() val last lm.findLastVisibleItemPosition() // 每次滑动都重新计算可见范围 updateVisibleItems(first, last) } })这里有个性能上的坑onScrolled触发非常频繁一秒钟可能几十次。如果你在里面做重操作比如遍历所有可见 View 去测可见比例很容易掉帧。我的做法是加一个节流或者只在dy累积到一定阈值时才真正计算。注意onScrolled在惯性滑动时也会持续触发别以为只有手指按着才触发。做曝光统计的时候要考虑这个否则用户一个快速甩动可能触发几十次曝光。4. 拿到不可见 View缓存机制与取用姿势4.1 RecyclerView 的四级缓存先弄明白再动手想拿不可见的 View必须先理解 RecyclerView 的缓存结构。它其实有四层从近到远分别是缓存层级名称特点能否直接拿到 View第一层mAttachedScrap当前 attach 但需要重新布局的是但通常是临时的第二层mCachedViews刚滑出屏幕的默认容量 2是但数量有限第三层ViewCacheExtension开发者自定义的缓存取决于你的实现第四层RecycledViewPool按 viewType 分类的池子否拿到的是“脏”View需要重新绑定关键点mCachedViews里的 View 是没有完全解绑的它的 position 信息还在理论上可以直接复用而RecycledViewPool里的 View 已经被彻底回收你拿到它也不知道它原来对应哪个 position。所以“拿到不可见的 View”这个需求如果指的是“刚滑出去不远、还在 mCachedViews 里的”那是有办法的如果指的是“滑出去很远、已经进池子的”那基本拿不到有意义的 View因为它的数据上下文已经丢了。4.2 通过 Recycler 拿到缓存 View 的反射写法RecyclerView 没有公开 API 让你直接访问mCachedViews但可以通过反射拿到mRecycler再拿mCachedViewsfun getCachedViews(recyclerView: RecyclerView): ListView { val result mutableListOfView() try { val recyclerField RecyclerView::class.java.getDeclaredField(mRecycler) recyclerField.isAccessible true val recycler recyclerField.get(recyclerView) val cachedViewsField recycler.javaClass.getDeclaredField(mCachedViews) cachedViewsField.isAccessible true val cachedViews cachedViewsField.get(recycler) as? ArrayList* cachedViews?.forEach { view - if (view is View) result.add(view) } } catch (e: Exception) { // 反射失败不同版本字段名可能变 } return result }这段代码能用但我要泼盆冷水反射依赖内部字段名不同 Android 版本、不同 RecyclerView 版本都可能变。我实测在较新的版本上mCachedViews这个名字还在但谁也不敢保证以后不变。所以这招只适合做辅助手段别把它当成核心逻辑。4.3 更稳的思路用 ViewHolder 的绑定状态反推比起反射我更推荐另一种思路不去直接拿缓存 View而是通过 ViewHolder 的状态来判断“这个 position 的 View 现在处于什么状态”。具体做法是在 Adapter 里维护一个 position 到 ViewHolder 的映射在onViewAttachedToWindow和onViewDetachedFromWindow里更新状态override fun onViewAttachedToWindow(holder: MyViewHolder) { super.onViewAttachedToWindow(holder) holder.isAttached true attachedPositions.add(holder.adapterPosition) } override fun onViewDetachedFromWindow(holder: MyViewHolder) { super.onViewDetachedFromWindow(holder) holder.isAttached false attachedPositions.remove(holder.adapterPosition) }这样你就能知道哪些 position 当前是 attach 的可见哪些是 detach 的不可见。虽然拿不到 detach 的 View 对象本身但你能知道它的状态对于做预加载、状态同步来说知道状态往往比拿到 View 更重要。提示onViewDetachedFromWindow触发时holder.adapterPosition可能已经变成NO_POSITION稳妥的做法是在 attach 的时候把 position 存到 holder 里detach 的时候用存下来的值。5. 判断“可见了多少”精确曝光统计的实现5.1 getLocalVisibleRect测量真实可见比例前面说的都是“在不在可见范围”但真正做曝光统计需要知道“可见了多少”。这时候getLocalVisibleRect就派上用场了fun getVisiblePercent(view: View): Float { val rect Rect() val isVisible view.getLocalVisibleRect(rect) if (!isVisible) return 0f val visibleArea rect.width() * rect.height() val totalArea view.width * view.height return if (totalArea 0) 0f else visibleArea.toFloat() / totalArea }getLocalVisibleRect会把 View 自身坐标系下“可见的那部分矩形”填到 rect 里。如果 View 完全不可见返回 false如果部分可见rect 就是可见区域。用它算出来的比例才是用户真正看到的比例。这个方法的原理是它会沿着 View 树往上找把所有父 View 的裁剪区域和屏幕边界都考虑进去。所以哪怕你的 item 被一个带 padding 的父容器包着它算出来的也是准确的。5.2 曝光阈值与去重别让同一条曝光报两次算出可见比例之后业务上通常有个阈值比如“露出 50% 以上算有效曝光”。但这里有个坑用户可能滑来滑去同一个 item 反复跨过 50% 这条线你不能每次都报。我的做法是给每个 position 维护一个“是否已曝光”的标记只有从“未曝光”变成“已曝光”时才上报val exposedSet mutableSetOfInt() fun checkExposure(position: Int, view: View) { val percent getVisiblePercent(view) if (percent 0.5f position !in exposedSet) { exposedSet.add(position) reportExposure(position) } }exposedSet的生命周期要跟列表数据绑定数据刷新的时候记得清空否则新数据会被旧标记挡住。5.3 滑动停止时统一计算比实时计算更省实时在onScrolled里算可见比例性能压力不小。我后来改成在滑动停止时统一算一次用onScrollStateChanged监听override fun onScrollStateChanged(rv: RecyclerView, newState: Int) { if (newState RecyclerView.SCROLL_STATE_IDLE) { // 滑动停了这时候统一算一遍可见比例 checkAllVisibleItems() } }这样既保证了曝光数据的准确性用户停下来看到的才算数又避免了滑动过程中的性能损耗。实测下来曝光数据的误差在可接受范围内因为用户真正“看到”一个 item本来也需要它稳定停留一下。6. 常见问题与排查技巧实录6.1 快速滑动时 position 错乱怎么办快速滑动时RecyclerView 会疯狂复用 ViewHoldergetChildAdapterPosition可能返回一个正在变化的值。我遇到过的现象是曝光数据里出现了根本没显示过的 position。排查下来根子在于我在onBindViewHolder里就急着上报那时候 position 还没稳定。解决办法是把上报逻辑挪到onViewAttachedToWindow之后或者干脆用滑动停止时统一计算。另外holder.adapterPosition在绑定过程中可能返回NO_POSITION一定要判空。6.2 findViewByPosition 返回 null 的几种情况findViewByPosition返回 null 是家常便饭常见原因有这么几个position 超出当前可见范围View 已经被回收了列表正在布局中View 还没 attach 完用了 StaggeredGridLayoutManagerposition 在某一列里但不在你查的那一列排查的时候先确认 position 是不是在 first 到 last 之间如果不是返回 null 是正常的。如果是那可能是布局还没完成加个post延迟一下再取。6.3 反射拿缓存 View 在不同版本上失效前面提过反射mCachedViews有版本风险。我实测在某个版本上字段名没变但另一个版本上mRecycler的获取方式变了。所以我的建议是反射代码一定要包 try-catch失败时降级到“用 attach 状态反推”的方案别让整个功能因为反射失败就崩了。6.4 常见问题速查表问题现象可能原因解决思路曝光数据重复上报没有去重标记用 Set 记录已曝光 position快速滑动 position 错乱绑定过程中取 position挪到 attach 后或滑动停止时算findViewByPosition 返回 nullView 已回收或未 attach判空 post 延迟反射拿缓存失败内部字段名变化try-catch 降级方案可见比例算不准没考虑父容器裁剪用 getLocalVisibleRect滑动卡顿onScrolled 里做重操作节流或滑动停止时统一算6.5 几个我踩过的坑直接说结论第一个坑别在onBindViewHolder里调getChildAt那时候 childCount 是 0你会拿到空结果还以为是代码写错了。第二个坑getLocalVisibleRect在 View 还没测量完成时返回的 rect 是空的所以曝光计算一定要等布局完成最好在滑动停止后做。第三个坑mCachedViews默认容量只有 2别指望从里面拿到很多 View。想要更大的缓存可以调setItemViewCacheSize但调大了内存占用也上去了得权衡。第四个坑做视频自动播放的时候判断“哪个 item 该播”不能只看 position因为用户可能只露出半个 item。我的做法是算可见比例超过 60% 的那个才播这样体验最自然。7. 一个完整的可见性监听封装把上面这些揉在一起我封装了一个小工具类核心就是监听滑动状态在停止时统一计算可见项和曝光class VisibleItemTracker( private val recyclerView: RecyclerView, private val onVisibleChanged: (ListInt) - Unit ) { private val exposedSet mutableSetOfInt() private var lastVisible listOfInt() private val scrollListener object : RecyclerView.OnScrollListener() { override fun onScrollStateChanged(rv: RecyclerView, newState: Int) { if (newState RecyclerView.SCROLL_STATE_IDLE) { computeVisible() } } } private fun computeVisible() { val lm recyclerView.layoutManager as? LinearLayoutManager ?: return val first lm.findFirstVisibleItemPosition() val last lm.findLastVisibleItemPosition() val current mutableListOfInt() for (pos in first..last) { val view lm.findViewByPosition(pos) ?: continue val percent getVisiblePercent(view) if (percent 0.5f) { current.add(pos) if (pos !in exposedSet) { exposedSet.add(pos) // 这里触发曝光上报 } } } if (current ! lastVisible) { lastVisible current onVisibleChanged(current) } } fun attach() { recyclerView.addOnScrollListener(scrollListener) } fun detach() { recyclerView.removeOnScrollListener(scrollListener) exposedSet.clear() lastVisible emptyList() } private fun getVisiblePercent(view: View): Float { val rect Rect() if (!view.getLocalVisibleRect(rect)) return 0f val visibleArea rect.width() * rect.height() val totalArea view.width * view.height return if (totalArea 0) 0f else visibleArea.toFloat() / totalArea } }用的时候在页面onResume里attachonPause里detach数据刷新时清一下exposedSet。这套东西我在几个列表页里都跑过稳定性没问题。最后分享一个小技巧如果你只是想知道“当前第一个和最后一个可见 position”根本不用这么麻烦直接layoutManager.findFirstVisibleItemPosition()和findLastVisibleItemPosition()就够了。真正需要拿 View 对象的场景其实没那么多大部分时候你要的是 position 和状态而不是 View 本身。想清楚这一点能省掉很多不必要的复杂代码。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。