全网最优雅安卓控件可见性检测
Posted 上马定江山
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了全网最优雅安卓控件可见性检测相关的知识,希望对你有一定的参考价值。
引子
view.setOnClickListener // 当控件被点击时触发的逻辑
正是因为 View 对控件点击采用了策略模式,才使得监听任何控件的点击事件变得易如反掌。
我有一个愿望。。。
如果 View 能有一个可见性监听该多好啊!
view.setOnVisibilityChangeListener isVisible: Boolean ->
系统并未提供这个方法。。。
但业务上有可见性监听的需要,比如曝光埋点。当某控件可见时,上报XXX。
数据分析同学经常抱怨曝光数据不准确,有的场景曝光多报了,有的场景曝光少报了。。。
开发同学看到曝光埋点也很头痛,不同场景的曝光检测有不同的方法,缺乏统一的可见性检测入口,存在一定重复开发。
本文就试图为单个控件以及列表项的可见性提供统一的检测入口。
控件的可见性受到诸多因素的影响,下面是影响控件可见性的十大因素:
- 手机电源开关
- Home 键
- 动态替换的 Fragment 遮挡了原有控件
- ScrollView, NestedScrollView 的滚动
- ViewPager, ViewPager2 的滚动
- RecyclerView 的滚动
- 被 Dialog 遮挡
- Activity 切换
- 同一 Activity 中 Fragment 的切换
- 手动调用 View.setVisibility(View.GONE)。
能否把这所有的情况都通过一个回调方法表达?目标是通过一个 View 的扩展方法完成上述所有情况的检测,并将可见性回调给上层,形如:
fun View.onVisibilityChange(block: (view: View, isVisible: Boolean) -> Unit)
若能实现就极大化简了上层可见性检测的复杂度,只需要如下代码就能实现任意控件的曝光上报埋点:
view.onVisibilityChange view, isVisible ->
if(isVisible) // 曝光埋点
else
控件全局可见性检测
可见性检测分为两步:
- 捕获时机:调用检测算法检测控件可见性的时机。
- 检测算法:描述如何检测控件是否对用户可见。
拿“手动调用 View.setVisibility(View.GONE)”举例,得先捕获 View Visibility 发生变化的时机,并在此刻检测控件的可见性。
下面是View.setVisibility()
的源码:
// android.view.View.java
public void setVisibility(@Visibility int visibility)
setFlags(visibility, VISIBILITY_MASK);
系统并未在该方法中提供类似于回调的接口,即一个 View 的实例无法通过回调的方式捕获到 visibility 变化的时机。
难道通过自定义 View,然后重写 setVisibility() 方法?
这个做法接入成本太高且不具备通用性。
除了“手动调用 View.setVisibility(View.GONE)”,剩下的影响可见性的因素大多都可找到对应回调。难道得在fun View.onVisibilityChange()
中对每个因素逐个添加回调吗?
这样实现太过复杂了,而且也不具备通用性,假设有例外情况,fun View.onVisibilityChange()
的实现就得修改。
上面列出的十种影响控件可见性的因素都是现象,不同的现象背后可能对应相同的本质。
经过深挖,上述现象的本质可被收敛为下面四个:
- 控件全局重绘
- 控件全局滚动
- 控件全局焦点变化
- 容器控件新增子控件
下面就针对这四个本质编程。
捕获全局重绘时机
系统提供了ViewTreeObserver
:
public final class ViewTreeObserver
public void addOnGlobalLayoutListener(OnGlobalLayoutListener listener)
checkIsAlive();
if (mOnGlobalLayoutListeners == null)
mOnGlobalLayoutListeners = new CopyOnWriteArray<OnGlobalLayoutListener>();
mOnGlobalLayoutListeners.add(listener);
ViewTreeObserver 是一个全局的 View 树变更观察者,它提供了一系列全局的监听器,全局重绘即是其中OnGlobalLayoutListener
:
public interface OnGlobalLayoutListener
public void onGlobalLayout();
当 View 树发生变化需要重绘的时候,就会触发该回调。
调用 View.setVisibility(View.GONE) 之所以能将控件隐藏,正是因为整个 View 树触发了一次重绘。(任何一次微小的重绘都是从 View 树的树根自顶向下的遍历并触发每一个控件的重绘,不需要重绘的控件会跳过,关于 Adroid 绘制机制的分析可以点击Android自定义控件 | View绘制原理(画多大?))
在可见性检测扩展方法中捕获第一个时机:
fun View.onVisibilityChange(block: (view: View, isVisible: Boolean) -> Unit)
viewTreeObserver.addOnGlobalLayoutListener
其中viewTreeObserver
是 View 的方法:
// android.view.View.java
public ViewTreeObserver getViewTreeObserver()
if (mAttachInfo != null)
return mAttachInfo.mTreeObserver;
if (mFloatingTreeObserver == null)
mFloatingTreeObserver = new ViewTreeObserver(mContext);
return mFloatingTreeObserver;
getViewTreeObserver() 用于返回当前 View 所在 View 树的观察者。
全局重绘其实覆盖了上述的两个场景:
- 同一 Activity 中 Fragment 的切换
- 手动调用 View.setVisibility(View.GONE)
这两个场景都会发生 View 树的重绘。
捕获全局滚动时机
- ScrollView, NestedScrollView 的滚动
- ViewPager, ViewPager2 的滚动
- RecyclerView 的滚动
上述三个时机的共同特点是“发生了滚动”。
每个可滚动的容器控件都提供了各自滚动的监听
// android.view.ScrollView.java
public interface OnScrollChangeListener
void onScrollChange(View v, int scrollX, int scrollY, int oldScrollX, int oldScrollY);
// androidx.viewpager2.widget.ViewPager2.java
public abstract static class OnPageChangeCallback
public void onPageScrolled(int position, float positionOffset, @Px int positionOffsetPixels)
public void onPageSelected(int position)
public void onPageScrollStateChanged(@ScrollState int state)
// androidx.recyclerview.widget.RecyclerView.java
public abstract static class OnScrollListener
public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState)
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy)
难道要针对不同的滚动控件设置不同的滚动监听器?
这样可见性检测就和控件耦合了,不具有通用性,也愧对View.onVisibilityChange()
这个名字。
还好又在ViewTreeObserver
中找到了全局的滚动监听:
public final class ViewTreeObserver
public void addOnScrollChangedListener(OnScrollChangedListener listener)
checkIsAlive();
if (mOnScrollChangedListeners == null)
mOnScrollChangedListeners = new CopyOnWriteArray<OnScrollChangedListener>();
mOnScrollChangedListeners.add(listener);
public interface OnScrollChangedListener
public void onScrollChanged();
在可见性检测扩展方法中捕获第二个时机:
fun View.onVisibilityChange(block: (view: View, isVisible: Boolean) -> Unit)
viewTreeObserver.addOnGlobalLayoutListener
viewTreeObserver.addOnScrollChangedListener
捕获全局焦点变化时机
下面这些 case 都是焦点发生了变化:
- 手机电源开关
- Home 键
- 被 Dialog 遮挡
- Activity 切换
同样借助于 ViewTreeObserver 可以捕获到焦点变化的时机。
到目前为止,全局可见性扩展方法中已经监听了三种时机,分别是全局重绘、全局滚动、全局焦点变化:
fun View.onVisibilityChange(block: (view: View, isVisible: Boolean) -> Unit)
viewTreeObserver.addOnGlobalLayoutListener
viewTreeObserver.addOnScrollChangedListener
viewTreeObserver.addOnWindowFocusChangeListener
捕获新增子控件时机
最后一个 case 是最复杂的:动态替换的 Fragment 遮挡了原有控件。
该场景如下图所示:
界面中有一个底边栏,其中包含各种 tab 标签,点击其中的标签会以 Fragment 的形式从底部弹出。此时,底边栏各 tab 从可见变为不可见,当点击返回时,又从不可见变为可见。
一开始的思路是“从被遮挡的 View 本身出发”,看看某个 View 被遮挡后,其本身的属性是否会发生变化?
View 内部以is
开头的方法如下所示:
我把其中名字看上去可能和被遮挡有关联的方法值全都打印出来了,然后触发 gif 中的场景,观察这些值在触发前后是否会发生变化。
几十个属性,一一比对,在看花眼之前,log 告诉我,被遮挡之后,这些都没有发生任何变化。。。。
绝望。。。但还不死心,继续寻找其他方法:
我有找了 View 内部所有has
开发的方法,也把其中看上去和被遮挡有关的方法全打印出来了。。。你猜结果怎么着?依然是徒劳。。。。
我开始质疑出发点是否正确。。。此时一声雷鸣劈醒了我。
视图只可能了解其自身以及其下层视图的情况,它无法得知它的平级甚至是父亲的绘制情况。而 gif 中的场景,即是在底边栏的同级有一个 Fragment 的容器。而且当视图被其他层级的控件遮挡时,整个绘制体系也不必通知那个被遮挡的视图,否则多低效啊(我yy的,若有大佬知道内情,欢迎留言指点一二。)
经过这层思考之后,我跳出了被遮挡的那个视图,转而去 Fragment 的容器哪里寻求解决方案。
Fragment 要被添加到 Activity 必须提供一个容器控件,容器控件提供了一个回调用于监听子控件被添加:
// android.view.ViewGroup.java
public void setOnHierarchyChangeListener(OnHierarchyChangeListener listener)
mOnHierarchyChangeListener = listener;
public interface OnHierarchyChangeListener
void onChildViewAdded(View parent, View child);
void onChildViewRemoved(View parent, View child);
为了监听 Fragment 被添加的这个瞬间,得为可见性检测扩展方法添加一个参数:
fun View.onVisibilityChange(
viewGroup: ViewGroup? = null, // 容器
block: (view: View, isVisible: Boolean) -> Unit
)
其中 viewGroup 表示 Fragment 的容器控件。
既然 Fragment 的添加也是往 View 树中插入子控件,那 View 树必定会重绘,可以在全局重绘回调中进行分类讨论,下面是伪代码:
fun View.onVisibilityChange(
viewGroup: ViewGroup? = null,
block: (view: View, isVisible: Boolean) -> Unit
)
var viewAdded = false
// View 树重绘时机
viewTreeObserver.addOnGlobalLayoutListener
if(viewAdded)
// 检测新插入控件是否遮挡当前控件
else
// 检测当前控件是否出现在屏幕中
// 监听子控件插入
viewGroup?.setOnHierarchyChangeListener(object : OnHierarchyChangeListener
override fun onChildViewAdded(parent: View?, child: View?)
viewAdded = true
override fun onChildViewRemoved(parent: View?, child: View?)
viewAdded = false
)
子控件的插入回调总是先于 View 树重绘回调。所以先在插入时置标志位viewAdded = true
,以便在重绘回调中做分类讨论。(因为检测子控件遮挡和是否出现在屏幕中是两种不同的检测方案)
可见性检测算法
检测控件的可见性的算法是:“判断控件的矩形区域是否和屏幕有交集”。
为此新增扩展属性:
val View.isInScreen: Boolean
get() = ViewCompat.isAttachedToWindow(this) && visibility == View.VISIBLE && getLocalVisibleRect(Rect())
val 类名.属性名: 属性类型
这样的语法用于为类的实例添加一个扩展属性,它并不是真地给类新增了一个成员变量,而是在类的外部新增属性值的获取方法。
当前新增的属性是 val 类型的,即常量,所以只需要为其定义个 get() 方法来表达如何获取它的值。
View 是否在屏幕中由三个表达式共同决定。
- 先通过 ViewCompat.isAttachedToWindow(this) 判断控件是否依附于窗口。
- 再通过 visibility == View.VISIBLE 判断视图是否可见。
- 最后调用
getLocalVisibleRect()
判断它的矩形相对于屏幕是否可见:
// android.view.View.java
public final boolean getLocalVisibleRect(Rect r)
final Point offset = mAttachInfo != null ? mAttachInfo.mPoint : new Point();
if (getGlobalVisibleRect(r, offset))
r.offset(-offset.x, -offset.y);
return true;
return false;
该方法会先获取控件相对于屏幕的矩形区域并存放在传入的 Rect 参数中,然后再将其偏移到控件坐标系。如果矩形区域为空,则返回 false 表示不在屏幕中,否则为 true。
刚才捕获的那一系列时机,有可能会被多次触发。为了只将可见性发生变化的事件回调给上层,得做一次过滤:
val KEY_VISIBILITY = "KEY_VISIBILITY".hashCode()
val checkVisibility =
// 获取上一次可见性
val lastVisibility = getTag(KEY_VISIBILITY) as? Boolean
// 获取当前可见性
val isInScreen = this.isInScreen() && visibility == View.VISIBLE
// 无上一次可见性,表示第一次检测
if (lastVisibility == null)
if (isInScreen)
// 回调可见性回调给上层
block(this, true)
// 更新可见性
setTag(KEY_VISIBILITY, true)
// 当前可见性和上次不同
else if (lastVisibility != isInScreen)
// 回调可见性给上层
block(this, isInScreen)
// 更新可见性
setTag(KEY_VISIBILITY, isInScreen)
过滤重复事件的方案是记录上一次可见性(记录在 View 的 tag 中),如果这一次可见性检测结果和上一次相同则不回调给上层。
将可见性检测定义为一个 lambda,这样就可以在捕获不同时机时复用。
以下是完整的可见性检测代码:
fun View.onVisibilityChange(
viewGroups: List<ViewGroup> = emptyList(), // 会被插入 Fragment 的容器集合
needScrollListener: Boolean = true,
block: (view: View, isVisible: Boolean) -> Unit
)
val KEY_VISIBILITY = "KEY_VISIBILITY".hashCode()
val KEY_HAS_LISTENER = "KEY_HAS_LISTENER".hashCode()
// 若当前控件已监听可见性,则返回
if (getTag(KEY_HAS_LISTENER) == true) return
// 检测可见性
val checkVisibility =
// 获取上一次可见性
val lastVisibility = getTag(KEY_VISIBILITY) as? Boolean
// 判断控件是否出现在屏幕中
val isInScreen = this.isInScreen
// 首次可见性变更
if (lastVisibility == null)
if (isInScreen)
block(this, true)
setTag(KEY_VISIBILITY, true)
// 非首次可见性变更
else if (lastVisibility != isInScreen)
block(this, isInScreen)
setTag(KEY_VISIBILITY, isInScreen)
// 全局重绘监听器
class LayoutListener : ViewTreeObserver.OnGlobalLayoutListener
// 标记位用于区别是否是遮挡case
var addedView: View? = null
override fun onGlobalLayout()
// 遮挡 case
if (addedView != null)
// 插入视图矩形区域
val addedRect = Rect().also addedView?.getGlobalVisibleRect(it)
// 当前视图矩形区域
val rect = Rect().also this@onVisibilityChange.getGlobalVisibleRect(it)
// 如果插入视图矩形区域包含当前视图矩形区域,则视为当前控件不可见
if (addedRect.contains(rect))
block(this@onVisibilityChange, false)
setTag(KEY_VISIBILITY, false)
else
block(this@onVisibilityChange, true)
setTag(KEY_VISIBILITY, true)
// 非遮挡 case
else
checkVisibility()
val layoutListener = LayoutListener()
// 编辑容器监听其插入视图时机
viewGroups.forEachIndexed index, viewGroup ->
viewGroup.setOnHierarchyChangeListener(object : ViewGroup.OnHierarchyChangeListener
override fun onChildViewAdded(parent: View?, child: View?)
// 当控件插入,则置标记位
layoutListener.addedView = child
override fun onChildViewRemoved(parent: View?, child: View?)
// 当控件移除,则置标记位
layoutListener.addedView = null
)
viewTreeObserver.addOnGlobalLayoutListener(layoutListener)
// 全局滚动监听器
var scrollListener:ViewTreeObserver.OnScrollChangedListener? = null
if (needScrollListener)
scrollListener = ViewTreeObserver.OnScrollChangedListener checkVisibility()
viewTreeObserver.addOnScrollChangedListener(scrollListener)
// 全局焦点变化监听器
val focusChangeListener = ViewTreeObserver.OnWindowFocusChangeListener hasFocus ->
val lastVisibility = getTag(KEY_VISIBILITY) as? Boolean
val isInScreen = this.isInScreen
if (hasFocus)
if (lastVisibility != isInScreen)
block(this, isInScreen)
setTag(KEY_VISIBILITY, isInScreen)
else
if (lastVisibility == true)
block(this, false)
setTag(KEY_VISIBILITY, false)
viewTreeObserver.addOnWindowFocusChangeListener(focusChangeListener)
// 为避免内存泄漏,当视图被移出的同时反注册监听器
addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener
override fun onViewAttachedToWindow(v: View?)
override fun onViewDetachedFromWindow(v: View?)
v ?: return
// 有时候 View detach 后,还会执行全局重绘,为此退后反注册
post
try
v.viewTreeObserver.removeOnGlobalLayoutListener(layoutListener)
catch (_: java.lang.Exception)
v.viewTreeObserver.removeGlobalOnLayoutListener(layoutListener)
v.viewTreeObserver.removeOnWindowFocusChangeListener(focusChangeListener)
if(scrollListener !=null) v.viewTreeObserver.removeOnScrollChangedListener(scrollListener)
viewGroups.forEach it.setOnHierarchyChangeListener(null)
removeOnAttachStateChangeListener(this)
)
// 标记已设置监听器
setTag(KEY_HAS_LISTENER, true)
作者:唐子玄
链接:https://juejin.cn/post/7165427955902971918
最后
如果想要成为架构师或想突破20~30K薪资范畴,那就不要局限在编码,业务,要会选型、扩展,提升编程思维。此外,良好的职业规划也很重要,学习的习惯很重要,但是最重要的还是要能持之以恒,任何不能坚持落实的计划都是空谈。
如果你没有方向,这里给大家分享一套由阿里高级架构师编写的《Android八大模块进阶笔记》,帮大家将杂乱、零散、碎片化的知识进行体系化的整理,让大家系统而高效地掌握Android开发的各个知识点。
相对于我们平时看的碎片化内容,这份笔记的知识点更系统化,更容易理解和记忆,是严格按照知识体系编排的。
全套视频资料:
一、面试合集
二、源码解析合集
三、开源框架合集
欢迎大家一键三连支持,若需要文中资料,直接点击文末CSDN官方认证微信卡片免费领取↓↓↓
以上是关于全网最优雅安卓控件可见性检测的主要内容,如果未能解决你的问题,请参考以下文章