全网最优雅安卓控件可见性检测

引子

view.setOnClickListener { // 当控件被点击时触发的逻辑 }

正是因为 View 对控件点击采用了策略模式,才使得监听任何控件的点击事件变得易如反掌。

我有一个愿望。。。

如果 View 能有一个可见性监听该多好啊!

view.setOnVisibilityChangeListener { isVisible: Boolean ->   }

系统并未提供这个方法。。。

但业务上有可见性监听的需要,比如曝光埋点。当某控件可见时,上报XXX。

数据分析同学经常抱怨曝光数据不准确,有的场景曝光多报了,有的场景曝光少报了。。。

开发同学看到曝光埋点也很头痛,不同场景的曝光检测有不同的方法,缺乏统一的可见性检测入口,存在一定重复开发。

本文就试图为单个控件以及列表项的可见性提供统一的检测入口。

控件的可见性受到诸多因素的影响,下面是影响控件可见性的十大因素:

  1. 手机电源开关
  2. Home 键
  3. 动态替换的 Fragment 遮挡了原有控件
  4. ScrollView, NestedScrollView 的滚动
  5. ViewPager, ViewPager2 的滚动
  6. RecyclerView 的滚动
  7. 被 Dialog 遮挡
  8. Activity 切换
  9. 同一 Activity 中 Fragment 的切换
  10. 手动调用 View.setVisibility(View.GONE)。

能否把这所有的情况都通过一个回调方法表达?目标是通过一个 View 的扩展方法完成上述所有情况的检测,并将可见性回调给上层,形如:

fun View.onVisibilityChange(block: (view: View, isVisible: Boolean) -> Unit) {}

若能实现就极大化简了上层可见性检测的复杂度,只需要如下代码就能实现任意控件的曝光上报埋点:

view.onVisibilityChange { view, isVisible ->
    if(isVisible) { // 曝光埋点 }
    else {}
}

控件全局可见性检测

可见性检测分为两步:

  1. 捕获时机:调用检测算法检测控件可见性的时机。
  2. 检测算法:描述如何检测控件是否对用户可见。

拿“手动调用 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()的实现就得修改。

上面列出的十种影响控件可见性的因素都是现象,不同的现象背后可能对应相同的本质。

经过深挖,上述现象的本质可被收敛为下面四个:

  1. 控件全局重绘
  2. 控件全局滚动
  3. 控件全局焦点变化
  4. 容器控件新增子控件

下面就针对这四个本质编程。

捕获全局重绘时机

系统提供了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 树的观察者。

全局重绘其实覆盖了上述的两个场景:

  1. 同一 Activity 中 Fragment 的切换
  2. 手动调用 View.setVisibility(View.GONE)

这两个场景都会发生 View 树的重绘。

捕获全局滚动时机

  1. ScrollView, NestedScrollView 的滚动
  2. ViewPager, ViewPager2 的滚动
  3. 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 都是焦点发生了变化:

  1. 手机电源开关
  2. Home 键
  3. 被 Dialog 遮挡
  4. 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 是否在屏幕中由三个表达式共同决定。

  1. 先通过 ViewCompat.isAttachedToWindow(this) 判断控件是否依附于窗口。
  2. 再通过 visibility == View.VISIBLE 判断视图是否可见。
  3. 最后调用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

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,826评论 6 506
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,968评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,234评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,562评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,611评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,482评论 1 302
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,271评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,166评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,608评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,814评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,926评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,644评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,249评论 3 329
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,866评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,991评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,063评论 3 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,871评论 2 354

推荐阅读更多精彩内容