引子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()的实现就得修改。上面列出的十种影响控件可见性的因素都是现象不同的现象背后可能对应相同的本质。经过深挖上述现象的本质可被收敛为下面四个控件全局重绘控件全局滚动控件全局焦点变化容器控件新增子控件下面就针对这四个本质编程。捕获全局重绘时机系统提供了ViewTreeObserverpublic final class ViewTreeObserver { public void addOnGlobalLayoutListener(OnGlobalLayoutListener listener) { checkIsAlive(); if (mOnGlobalLayoutListeners null) { mOnGlobalLayoutListeners new CopyOnWriteArrayOnGlobalLayoutListener(); } mOnGlobalLayoutListeners.add(listener); } }ViewTreeObserver 是一个全局的 View 树变更观察者它提供了一系列全局的监听器全局重绘即是其中OnGlobalLayoutListenerpublic 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 CopyOnWriteArrayOnScrollChangedListener(); } 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: ListViewGroup 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 { thisonVisibilityChange.getGlobalVisibleRect(it) } // 如果插入视图矩形区域包含当前视图矩形区域则视为当前控件不可见 if (addedRect.contains(rect)) { block(thisonVisibilityChange, false) setTag(KEY_VISIBILITY, false) } else { block(thisonVisibilityChange, 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官方认证微信卡片免费领取↓↓↓