挖一挖 fitsSystemWindows 的奥秘

前言

最近新接手维护一个组件,需要解决组件的一些遗留历史问题,其中有一个问题就是页面的 UI 被底部虚拟导航栏盖住了。一般遇到这种情况,我们只需要设置一下 fitsSystemWindows=true 就可以了。然而正当我以为这个问题就这么简单的解决了时,我惊讶的发现,该页面的代码已经设置过了 fitsSystemWindows=true,还是被遮挡了。顿时觉得十分费解,看来自己对 fitsSystemWindows 属性还是没有理解透彻呀,趁此机会来了解一下 fitsSystemWindows

1. 什么时候需要使用 fitSystemWindows 这个属性呢?

回忆一下,你是否曾经遇到过底部导航栏遮盖了布局 UI 的情况出现呢?当我们设置了和沉浸式相关的属性,像 SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN (视图延伸至状态栏区域,状态栏上浮于视图之上)或 SYSTEM_UI_FLAG_HIDE_NAVIGATION (视图延伸至导航栏区域,导航栏上浮于视图之上)。这个时候就可能会出现布局 UI 放到了导航栏或状态栏下方,给用户一种界面被遮盖的感觉。

为了解决上面的问题,Android 便提供了 fitSystemWindows 这个属性,简单来说该属性可以帮助 UI 保持正常的位置,避免被窗口中其他元素遮盖,也就是自动帮 UI 处理了边距。

那么,为什么会出现已经设置了 fitSystemWindows,但是 UI 还是被遮盖的情况呢?

2. 先了解一下 WindowInsets 的概念

为了让更好的理解,先来看看 WindowInsets 的概念。WindowInsets 是 Window Content 的插入物。例如状态栏、导航栏、键盘等等,当它们显示时,会被插入到 Window 窗口的显示区域。一个 inset 对象中包括 left、top、right、bottom 的 4 个 int 偏移值。和 View 的事件分发机制一样,WindowInset 也是从父 View 开始分发的,也被称为深度优先。

这里简单捋一下,Activity 创建的时候,DecorView 也会被创建,但是此时 DecorView 还没有被加载到 Window 中,当处理 Activity 的 Resume 时,会把 DecorView 加载到 WindowManager 中。最后通过 ViewRootImpl 的 setView() 方法来执行加载 View 的逻辑。在这个方法中,会执行到大家熟知的 requestLayout 方法,兜兜转转最后来到 performTraversals,这也是一个大家熟悉的方法,是 View 的三大工作流程的入口方法。不懂的可以看这篇文章 View 系列 ------ 浅谈 View 的三大工作流程

当然了,这些内容对于理解本文的重点只是起到一个辅助作用,你只需要知道在处理 View 的过程中,会执行到一个方法 dispatchApplyInsets,看名字就能知道这是一个分发 WindowInsets 的方法。

3. WindowInsets 的分发

java 复制代码
public void dispatchApplyInsets(View host) {
    ...
    host.dispatchApplyWindowInsets(insets);
    ...
}

我只保留了该方法中重要的一句代码,这个 host 就是 DecorViewDecorView 没有重写 dispatchApplyWindowInsets方法,所以直接执行基类 ViewGroup 的 dispatchApplyWindowInsets

java 复制代码
@Override
public WindowInsets dispatchApplyWindowInsets(WindowInsets insets) {
    insets = super.dispatchApplyWindowInsets(insets);
    // 是否已经消耗过了该inset
    if (insets.isConsumed()) {
        return insets;
    }
    // 这个if判断不重要,只要知道没有消耗过inset就继续往下分发就好
    if (View.sBrokenInsetsDispatch) {
        return brokenDispatchApplyWindowInsets(insets);
    } else {
        return newDispatchApplyWindowInsets(insets);
    }
}

private WindowInsets brokenDispatchApplyWindowInsets(WindowInsets insets) {
    final int count = getChildCount();
    for (int i = 0; i < count; i++) {
        // 分发给子View,看子View是否要消耗inset
        insets = getChildAt(i).dispatchApplyWindowInsets(insets);
        // 如果子View消耗了,跳出循环
        if (insets.isConsumed()) {
            break;
        }
    }
    return insets;
}

ViewGroup 的 dispatchApplyWindowInsets 方法首先会判断当前的 inset 有没有被消耗,如果没有被消耗,就遍历子 View,执行 View 的 dispatchApplyWindowInsets

Java 复制代码
public WindowInsets dispatchApplyWindowInsets(WindowInsets insets) {
    try {
        mPrivateFlags3 |= PFLAG3_APPLYING_INSETS;
        // 如果设置了OnApplyWindowInsetsListener,直接回调给listener的onApplyWindowInsets方法
        if (mListenerInfo != null && mListenerInfo.mOnApplyWindowInsetsListener != null) {
            return mListenerInfo.mOnApplyWindowInsetsListener.onApplyWindowInsets(this, insets);
        } else {
            // 没有设置过listener的话,执行View的onApplyWindowInsets方法
            return onApplyWindowInsets(insets);
        }
    } finally {
        mPrivateFlags3 &= ~PFLAG3_APPLYING_INSETS;
    }
}

View 的处理也很简单,如果我们自己设置了 OnApplyWindowInsetsListener,就直接回调给我们自己来处理 inset,如果没有设置 listener,就走 View 的默认方法 onApplyWindowInsets 方法。那默认肯定是走 onApplyWindowInsets 了,接着看:

Java 复制代码
public WindowInsets onApplyWindowInsets(WindowInsets insets) {
    if ((mPrivateFlags4 & PFLAG4_FRAMEWORK_OPTIONAL_FITS_SYSTEM_WINDOWS) != 0
            && (mViewFlags & FITS_SYSTEM_WINDOWS) != 0) {
        return onApplyFrameworkOptionalFitSystemWindows(insets);
    }
    if ((mPrivateFlags3 & PFLAG3_FITTING_SYSTEM_WINDOWS) == 0) {
        if (fitSystemWindows(insets.getSystemWindowInsetsAsRect())) {
            return insets.consumeSystemWindowInsets();
        }
    } else {
        // 默认走这个逻辑,直接看这个方法
        if (fitSystemWindowsInt(insets.getSystemWindowInsetsAsRect())) {
            return insets.consumeSystemWindowInsets();
        }
    }
    return insets;
}

private boolean fitSystemWindowsInt(Rect insets) {
    // 如果设置了 fitSystemWindows == true,就会设置 FITS_SYSTEM_WINDOWS 标记位
    if ((mViewFlags & FITS_SYSTEM_WINDOWS) == FITS_SYSTEM_WINDOWS) {
        Rect localInsets = sThreadLocal.get();
        boolean res = computeFitSystemWindows(insets, localInsets);
        // 设置了 fitSystemWindows 属性后,重新处理 padding
        applyInsets(localInsets);
        // View 消耗了 inset 就返回 true
        return res;
    }
    return false;
}

简单来说,进入 onApplyWindowInsets 后,默认会执行 fitSystemWindowsInt 方法,这个方法会判断有没有设置 FITS_SYSTEM_WINDOWS 这个标记位,设置了才是执行 fitSystemWindowsInt 内部逻辑的前提。看来这个标记位也要明白是个啥东西,这里我们先接着往下看。

Java 复制代码
private void applyInsets(Rect insets) {
    mUserPaddingStart = UNDEFINED_PADDING;
    mUserPaddingEnd = UNDEFINED_PADDING;
    mUserPaddingLeftInitial = insets.left;
    mUserPaddingRightInitial = insets.right;
    // 重新设置padding
    internalSetPadding(insets.left, insets.top, insets.right, insets.bottom);
}

如果设置了 FITS_SYSTEM_WINDOWS 这个标记位,会通过 applyInsets() 方法处理 padding,并设置 View 已经消耗了该 inset。

4. FITS_SYSTEM_WINDOWS 这个标记位是怎么设置的呢?

上一个问题中我们说了如果想让系统帮我们重新设置 padding,前提是设置了 FITS_SYSTEM_WINDOWS 这个标记位,这样当分发 inset 的逻辑走到 fitSystemWindowsInt 方法中的时候,才会执行设置 padding 的操作。那么 FITS_SYSTEM_WINDOWS 这个标记位是怎么设置的呢?

揭晓答案,当设置了 View 的 fitsSystemWindows 属性为 true 的时候,就相当于设置了 FITS_SYSTEM_WINDOWS 这个标记位。

java 复制代码
// View.java
public void setFitsSystemWindows(boolean fitSystemWindows) {
    // fitSystemWindows 属性的设置,主要就是设置了一个标记位
    setFlags(fitSystemWindows ? FITS_SYSTEM_WINDOWS : 0, FITS_SYSTEM_WINDOWS);
}

5. 总结

梳理了一遍 fitsSystemWindows 之后,我们来总结一下。

首先,fitsSystemWindows 属性通常和沉浸式相关,我们的 UI 可能会被状态栏或者导航栏遮盖住,这时候就需要我们给布局加一个 padding,比如说这个 padding 的大小是状态栏或导航栏的高度,这样就能避免布局被盖住。Android 已经提供给我们这个功能了,就是 fitsSystemWindows。当设置该属性为 true 后,标记位 FITS_SYSTEM_WINDOWS 被设置,代表该 View 可以消耗 inset。

WindowInsets 也是像事件分发机制一样,在 performTraversals 的时候进行分发,通过 DecorView、ViewGroup 最后到达 View 的 dispatchApplyWindowInsets

需要注意的是,如果我们在自定义 View 中重写了 onApplyWindowInsets() 方法或者是设置了 setOnApplyWindowInsetsListener() 来监听 WindowInsets 的变化,那么在 View 的 dispatchApplyWindowInsets 方法中,就不会走系统默认的 onApplyWindowInsets() 方法了,而是由业务自己来处理,自己通过 inset 设置 padding。

setOnApplyWindowInsetsListener() 的优先级更高,当存在 OnApplyWindowInsetsListener 时不会执行 onApplyWindowInsets。而我们设置的 fitSystemWindows == true 其实就是通过系统默认的 onApplyWindowInsets 方法来实现的。即设置了 fitSystemWindows == true 后,onApplyWindowInsets 才会去自动调整 padding。

6. 最后回到最开始的问题

了解完了 fitsSystemWindows 后,回到我们前言里提到的问题,为啥已经设置过了 fitsSystemWindows=true,UI 还是被遮挡了。看了代码之后发现,原来有个地方偷偷使用了setOnApplyWindowInsetsListener,所以设置了 fitsSystemWindows 也没啥用,因为根本不会走系统的 onApplyWindowInsets 方法帮我自动调整 padding 了。所以我就改成统一使用 setOnApplyWindowInsetsListener 来处理 padding 了。之所以不用 fitsSystemWindows 属性,是因为这个属性用不好也会有坑的,毕竟它是深度优先,第一个设置 fitSystemWindows == true 的 View 消费完 inset,后面的 View 就不会再消费了,难保不会在某些情况下出现适配问题。所以果断使用 setOnApplyWindowInsetsListener

以上就是我对 fitsSystemWindows 的一些个人解析啦,有任何问题或者不足之处欢迎大家相互交流~

参考

medium.com/androiddeve...

nich.work/2017/window...

Android Detail:Window 篇------WindowInsets 与 fitsSystemWindow

Android 沉浸式状态栏必知必会

相关推荐
拭心11 小时前
Google 提供的 Android 端上大模型组件:MediaPipe LLM 介绍
android
带电的小王13 小时前
WhisperKit: Android 端测试 Whisper -- Android手机(Qualcomm GPU)部署音频大模型
android·智能手机·whisper·qualcomm
梦想平凡14 小时前
PHP 微信棋牌开发全解析:高级教程
android·数据库·oracle
元争栈道14 小时前
webview和H5来实现的android短视频(短剧)音视频播放依赖控件
android·音视频
阿甘知识库15 小时前
宝塔面板跨服务器数据同步教程:双机备份零停机
android·运维·服务器·备份·同步·宝塔面板·建站
元争栈道15 小时前
webview+H5来实现的android短视频(短剧)音视频播放依赖控件资源
android·音视频
MuYe16 小时前
Android Hook - 动态加载so库
android
居居飒16 小时前
Android学习(四)-Kotlin编程语言-for循环
android·学习·kotlin
Henry_He19 小时前
桌面列表小部件不能点击的问题分析
android
工程师老罗19 小时前
Android笔试面试题AI答之Android基础(1)
android