本文为稀土技术社区首发签约文章,14天内制止转载,14天后未获授权制止转载,侵权必究!
引子
事务开发中列表项的曝光埋点做得越来越精细了。
一开始,我是在 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() 触发埋点上报了。
现有计划
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<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(),所以上述计划缺少一个遮挡机遇。
结合全网最优雅安卓控件可见性检测中检测控件可见性的计划,修正如下:
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)
}
})
}
嵌套列表适配
上述计划可用于嵌套 RecyclerView 的可见性检测,嵌套列表效果如下图所示: 整个页面是一个纵向 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
上述源码能够在这里找到。
推荐阅读
事务代码参数透传满天飞?(一)
事务代码参数透传满天飞?(二)
全网最优雅安卓控件可见性检测
全网最优雅安卓列表项可见性检测
页面曝光难点剖析及应对计划
你的代码太烦琐了 | 这么多对象名?
你的代码太烦琐了 | 这么多办法调用?