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 = findViewById<RecyclerView>(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,比如做实时曝光统计,就得挂OnScrollListener:
recyclerView.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,再拿mCachedViews:
fun getCachedViews(recyclerView: RecyclerView): List<View> { val result = mutableListOf<View>() 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 = mutableSetOf<Int>() 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 会疯狂复用 ViewHolder,getChildAdapterPosition可能返回一个正在变化的值。我遇到过的现象是:曝光数据里出现了根本没显示过的 position。
排查下来,根子在于我在onBindViewHolder里就急着上报,那时候 position 还没稳定。解决办法是把上报逻辑挪到onViewAttachedToWindow之后,或者干脆用滑动停止时统一计算。另外,holder.adapterPosition在绑定过程中可能返回NO_POSITION,一定要判空。
6.2 findViewByPosition 返回 null 的几种情况
findViewByPosition返回 null 是家常便饭,常见原因有这么几个:
- position 超出当前可见范围,View 已经被回收了
- 列表正在布局中,View 还没 attach 完
- 用了 StaggeredGridLayoutManager,position 在某一列里但不在你查的那一列
排查的时候先确认 position 是不是在 first 到 last 之间,如果不是,返回 null 是正常的。如果是,那可能是布局还没完成,加个post延迟一下再取。
6.3 反射拿缓存 View 在不同版本上失效
前面提过反射mCachedViews有版本风险。我实测在某个版本上字段名没变,但另一个版本上mRecycler的获取方式变了。所以我的建议是:反射代码一定要包 try-catch,失败时降级到“用 attach 状态反推”的方案,别让整个功能因为反射失败就崩了。
6.4 常见问题速查表
| 问题现象 | 可能原因 | 解决思路 |
|---|---|---|
| 曝光数据重复上报 | 没有去重标记 | 用 Set 记录已曝光 position |
| 快速滑动 position 错乱 | 绑定过程中取 position | 挪到 attach 后或滑动停止时算 |
| findViewByPosition 返回 null | View 已回收或未 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: (List<Int>) -> Unit ) { private val exposedSet = mutableSetOf<Int>() private var lastVisible = listOf<Int>() 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 = mutableListOf<Int>() 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里attach,onPause里detach,数据刷新时清一下exposedSet。这套东西我在几个列表页里都跑过,稳定性没问题。
最后分享一个小技巧:如果你只是想知道“当前第一个和最后一个可见 position”,根本不用这么麻烦,直接layoutManager.findFirstVisibleItemPosition()和findLastVisibleItemPosition()就够了。真正需要拿 View 对象的场景,其实没那么多,大部分时候你要的是 position 和状态,而不是 View 本身。想清楚这一点,能省掉很多不必要的复杂代码。