做Android开发的十有八九都要跟状态栏和导航栏打交道。尤其到了Android 13系统全面进入手势导航时代厂家定制机型又五花八门控制状态栏和导航栏的显隐、颜色、高度、交互区域已经从“改几个Flag”变成了“一套Insets体系”。我前阵子正好把几个项目统一迁移到Android 13的适配方案踩了不少坑这篇就把状态栏和导航栏控制的完整思路、核心代码、以及调试时容易忽略的细节一次性讲清楚。主要内容围绕android13状态栏导航栏控制展开包含从系统窗口机制原理到沉浸模式、底部导航栏控制、动态隐藏与恢复的实操方案还有我实测过的常见问题和排查思路。内容适合正在做Android 13适配、或者想理解系统级窗口控制机制的开发者参考不需要你有太多基础但至少用过Android Studio、写过Java或Kotlin。1. 先搞清楚状态栏和导航栏控制的几种常见需求1.1 沉浸模式与全屏切换我接触的大多数需求核心都是“让内容延伸到状态栏和导航栏底下”也就是所谓的沉浸模式。视频播放器、阅读器、拍照界面、游戏甚至一些带顶部大图详情页的应用都希望界面看起来更满、更纯粹。但沉浸模式又分好几种程度一种是“内容延伸到系统栏下面但系统栏仍然可见”这种场景下状态栏通常是半透明或全透明背景色和内容区域混合视觉上更融合另一种是“彻底隐藏系统栏”等用户点击屏幕或滑动边缘时再短暂唤出这种通常对应游戏和视频场景。Android 13之前很多人习惯用WindowInsetsController.hide()配合WindowInsets.Type.systemBars()来隐藏或者用systemUiVisibility这个老API。到了Android 13systemUiVisibility已经彻底废弃官方推荐统一走WindowInsets。如果你还在用老的View.SYSTEM_UI_FLAG_FULLSCREEN或者View.SYSTEM_UI_FLAG_HIDE_NAVIGATION编译能过但实际效果在某些Android 13设备上会直接失效这点要特别注意。1.2 控制导航栏按钮显隐导航栏控制比状态栏更敏感因为涉及用户最基本的返回、主页、最近任务操作。系统对导航栏的隐藏策略明显更严格尤其是在非手势导航模式下三键导航的导航栏一旦隐藏用户会失去明确的返回入口所以某些场景下系统会强制保留导航栏或者在你隐藏后自动重新显示。我在项目里遇到过的情况主要有几种底部有横向滑动的轮播图或卡片列表手指从底部向上滑时想隐藏导航栏交互完成后再恢复横屏播放页面希望底部导航栏隐藏让视频画面完全铺满电子签名、拍照取景这类应用需要排除所有系统干扰。Android 13的具体表现是如果你通过systemBars()隐藏全部系统栏状态栏可以长时间隐藏但导航栏在用户从底部上滑时必定会重新出现这是系统级的用户保护机制。实测在手势导航模式下隐藏导航栏的代码甚至可能不生效因为Android 13对手势指示条的隐藏策略单独做了控制。1.3 获取状态栏和导航栏高度除了显隐控制另一个高频需求是“获取状态栏和导航栏的高度”。典型场景包括自定义标题栏要基于状态栏高度做padding、底部按钮区域要避开导航栏高度、对话框要调整位置避免被导航栏遮挡。Android 13中导航栏的高度会根据手势导航和三键导航变化。手势导航下那条“横线指示条”区域的高度一般比三键导航小而三键导航里的返回、主页、最近任务按钮区域在设备上如果带物理按键高度还可能变成0。所以千万别写死某个dp值必须动态读取。获取高度最常见的方式是ViewCompat.getRootWindowInsets(window.decorView)然后getInsets(WindowInsets.Type.navigationBars()).bottom这种方案在Android 13上依然有效。要注意的是如果当前导航栏处于隐藏状态返回的bottom值可能是0这就意味着拿到的Insets是“当前可见系统栏的区域”而不是“系统栏实际占用的区域”两者很容易混淆。2. Android 13上控制方式的选型与原理拆解2.1 WindowInsets API 在 Android 13 的机制Android 13里系统窗口区域控制的核心是WindowInsetsController。它把系统栏按类型拆分状态栏对应statusBars()导航栏对应navigationBars()两者合起来叫systemBars()。隐藏和显示都是对“类型”做操作而不是直接对View做操作。这个机制有个重要特点Insets是分层的。状态栏和导航栏可以被系统拆分为多个Insets比如IME键盘也是一个Insets类型ime()。当你使用WindowInsets.Type.systemBars()时如果软键盘已经弹出系统可能把IME区域也当作系统栏处理导致界面底部多出一块空白这也是我实际开发中经常遇到的问题。Android 13还引入了WindowInsetsController.setSystemBarsBehavior()用来控制隐藏后用户交互的行为。可取的值包括BEHAVIOR_SHOW_BARS_BY_SWIPE滑动边缘显示、BEHAVIOR_SHOW_BARS_BY_TOUCH点击显示、以及BEHAVIOR_DEFAULT。配合隐藏系统栏时如果你希望用户轻扫系统栏区域才显示就设置BEHAVIOR_SHOW_BARS_BY_SWIPE如果希望点击任意区域就显示就设置BEHAVIOR_SHOW_BARS_BY_TOUCH。从行为上看Android 13对手势导航下的底部横条Indication Bar控制比较特殊。即使使用navigationBars()去隐藏在某些设备上也只是减少横条的背景范围横线本身依然会以微弱透明度出现在屏幕底部。这是厂商在GesureNavigation上做的适配层导致的app层往往无法完全消除。2.2 为什么Android 13对“隐藏导航栏”更严格很多朋友以为隐藏导航栏失败是代码写错了其实很多时候是系统策略。Android 13开始系统会强制保证“用户永远能通过一次滑动手势唤回导航栏”意味着你调用了hide()之后用户在底部上滑导航栏必定重新出现。这个行为不受setSystemBarsBehavior影响也不由你控制。更深一层Android 13的WindowInsets在计算时会把“系统可能临时显示导航栏”的情况纳入布局。你即使隐藏了导航栏insetsController返回的navigationBars区域也可能不是0因为系统要为即将唤出的导航栏预留空间。这会导致一个经典问题你隐藏导航栏后底部Button栏依然多了一段padding看起来像没隐藏成功。针对这种情况我一般会额外使用WindowInsets.Type.tappableElement()和WindowInsets.Type.displayCutout()来做精细控制而不是只盯着navigationBars()。尤其是挖孔屏和刘海屏cutout区域如果被当成系统栏处理顶部高度会算错状态栏透明后内容又被孔遮挡。2.3 我选择哪种方案我自己在Android 13项目里通常不用老的WindowManager.LayoutParams.FLAG_FULLSCREEN而是直接用WindowCompat.setDecorFitsSystemWindows(window, false)加WindowInsetsControllerCompat。这套组合的好处是兼容性稳定在Android 11、12、13上行为一致并且能通过Listener实时监听Insets变化。除非你说“只想隐藏导航栏状态栏保留”我才建议单独用navigationBars()去hide。但实际项目里隐藏导航栏通常是和全屏一起出现的。我会把这两项封装在一个SystemBarController里对外暴露enterFullscreen()和exitFullscreen()内部处理所有Insets逻辑。这样页面调用起来只需要关心业务不用关心系统层差异。另外要提一下WindowCompat.getInsetsController(window, view)和window.decorView.windowInsetsController在Android 13上都能拿到WindowInsetsControllerCompat。如果你的项目用了AppCompat建议优先用前者因为它内部已经兼容了不同版本的差异省得自己写if (Build.VERSION.SDK_INT 30)这种分支。3. 核心实现基于WindowInsets的沉浸模式与导航栏控制3.1 基础代码结构先给出一套可以直接用的基础代码。它实现了两个功能全屏模式下状态栏和导航栏全部隐藏内容延伸到底部退出全屏后恢复系统默认样式。class SystemBarController(private val window: Window) { private val controller: WindowInsetsControllerCompat by lazy { WindowCompat.getInsetsController(window, window.decorView) } fun enterFullscreen() { WindowCompat.setDecorFitsSystemWindows(window, false) controller.hide(WindowInsetsCompat.Type.systemBars()) controller.systemBarsBehavior WindowInsetsControllerCompat.BEHAVIOR_SHOW_BARS_BY_SWIPE } fun exitFullscreen() { WindowCompat.setDecorFitsSystemWindows(window, true) controller.show(WindowInsetsCompat.Type.systemBars()) } fun setStatusBarColor(color: Int) { window.statusBarColor color } fun setNavigationBarColor(color: Int) { window.navigationBarColor color } }我在项目里通常把setDecorFitsSystemWindows放在Activity.onCreate里先执行然后根据页面状态决定是否调用enterFullscreen。这里有个细节setDecorFitsSystemWindows(window, false)必须在setContentView之前调用否则部分设备上布局已经按“适应系统栏”的模式测量过一次后续切换会闪一下。如果你只需要隐藏状态栏、保留导航栏可以直接改成controller.hide(WindowInsetsCompat.Type.statusBars())。如果只需要隐藏导航栏同理改成navigationBars()。这两个类型可以任意组合也可以通过or运算组合比如WindowInsetsCompat.Type.statusBars() or WindowInsetsCompat.Type.displayCutout()。3.2 关键参数与计算过程沉浸模式做完之后最关键的就是padding计算。很多界面在切到全屏时顶部和底部的布局需要动态调整否则标题栏会被状态栏盖住底部按钮会被导航栏顶起或遮住。我在项目里统一用ViewCompat.setOnApplyWindowInsetsListener来处理ViewCompat.setOnApplyWindowInsetsListener(bottomActionBar) { view, insets - val sysBars insets.getInsets( WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout() ) val imeInsets insets.getInsets(WindowInsetsCompat.Type.ime()) // 底部布局需要同时避开导航栏和输入法 view.updatePadding( left sysBars.left, top sysBars.top, right sysBars.right, bottom maxOf(sysBars.bottom, imeInsets.bottom) ) insets }如果监听器返回的是insets表示这个View已经处理过Insets事件还会继续往父布局传如果返回WindowInsetsCompat.CONSUMED表示消费掉后面父布局不再处理。这个选择很有讲究如果你在根布局消费了子布局的监听可能收不到。大多数时候我建议返回insets让事件继续传递。另一个容易被坑的点是sysBars里包含的bottom在手势导航和全屏隐藏导航栏后都是0但导航栏实际区域仍然存在。这时你要是还在底部加了固定padding反而会造成内容与导航栏之间多出空隙。解决办法是在全屏状态下要单独存一个“系统栏未被隐藏时的真实高度”比如在进入全屏前先读取一次缓存起来。3.3 手势边缘区域的注意事项Android 13对手势导航的用户来说屏幕左边缘和右边缘是返回手势的触发区。如果你的页面里存在左右滑动返回或者侧边抽屉手势冲突会出现。特别是隐藏导航栏之后系统手势触发区域不会变你的自定义手势与系统手势会同时作用。处理方式通常有两种一是通过View.setSystemGestureExclusionRects把某些区域排除在系统手势之外二是通过WindowInsetsControllerCompat的setSystemGesturesEnabled完全禁用系统手势边缘但这样会让用户失去返回手势一般不推荐。if (Build.VERSION.SDK_INT Build.VERSION_CODES.Q) { val exclusionRect Rect(0, 0, view.width, view.height) view.rootView.setSystemGestureExclusionRects( listOf(android.graphics.Rect(0, 0, 200, 200)) ) }这个代码会把距离屏幕左边缘200px的正方形区域从系统手势中“抠掉”。实际项目里我不会全屏排除只在有触摸交互的View上做局部排除。注意Android 13对排除区域有一定限制每个View的排除区域不能无限叠加过多的排除区域会被系统忽略所以最好只在必要View上设置。还有一个细节setSystemGestureExclusionRects只能在SDK Q的设备上使用Android 13没问题但如果你想兼容老设备需要做版本判断。排除区域的坐标系是相对屏幕的不是相对View的所以我通常拿到View在屏幕上的绝对位置再计算Rect。4. 常见问题与排查技巧实录4.1 状态栏闪烁问题全屏切换时状态栏出现“先隐藏再重新显示”的闪烁多半是setDecorFitsSystemWindows在多个时机被重复设置。我见过一种错误写法在onResume和onWindowFocusChanged里都调用了enterFullscreen()并且每次调用都执行setDecorFitsSystemWindows(window, false)导致系统布局测量连续变更。正确做法是把setDecorFitsSystemWindows放在onCreate里设置一次然后只在需要切换全屏状态时调用hide()或show()。如果你的页面里存在多个Fragment不要在Fragment的onResume里重复调用enterFullscreen而是让Activity统一管理。还有一种闪烁原因是controller.hide()和controller.show()被连续调用。比如用户快速点击“全屏”和“退出全屏”按钮操作没有节流系统会把两个状态机指令交替执行。解决办法是加一层简单的状态判断或者配合window.decorView.doOnPreDraw做过度动画控制。4.2 导航栏重叠与布局错位导航栏重叠是接入沉浸模式后最容易看见的问题。明明设置setDecorFitsSystemWindows(window, false)页面的footer还是被导航栏遮住了。这是因为你没有给底部布局添加navigationBars的padding。我在3.2节里用setOnApplyWindowInsetsListener本质上就是在做这件事。但更隐蔽的问题是有些页面不是由你直接控制padding而是用了CoordinatorLayout、BottomSheet、RecyclerView这些系统控件它们内部有自己的Insets处理逻辑。尤其BottomSheet在Android 13上默认会吸收navigationBars的Insets导致你设置完updatePadding后又被覆盖。我的经验是根部布局用fitsSystemWindows子布局手动控制padding不要同时设置。比如底部按钮容器放在根部布局内根部设置android:fitsSystemWindowsfalse然后只在按钮容器上设置setOnApplyWindowInsetsListener。这样不会出现双向传递的重复叠加。如果页面顶部的标题栏需要避开状态栏但状态栏背景又是透明你需要在标题栏顶部加padding同时确保标题栏背景色延伸到状态栏后面。这时候window.statusBarColor Color.TRANSPARENT是必要的否则状态栏会是系统色而不是页面顶部的颜色。4.3 Android 13上手势导航不生效有朋友反馈在Android 13上三键导航模式下隐藏导航栏有效切到手势导航后调用controller.hide(WindowInsetsCompat.Type.navigationBars())底部横线还是会显示。这其实是Android 13的预期表现。手势导航下系统会保留那条“悬浮横条”作为导航指示第三方应用很难完全隐藏。如果你一定要隐藏这条横线可以尝试把目标区域那条横线对应的View放到导航栏区域内然后利用WindowInsets监听判断当前导航栏是否可见再做视觉上的“压盖”。但我不推荐这个方案因为用户在底部上滑时横线又会重新出现而且界面会出现奇怪的遮挡效果。更值得关注的是“导航栏区域被内容遮挡后点击事件失灵”的问题。当你隐藏导航栏后内容延伸到了底部但系统手势仍然在底部区域生效。如果底部是一个“发送消息”按钮用户点击时很容易误触发“回到桌面”手势。这个问题第三方无法直接解决只能在产品设计上避开底部极窄的触发区域或者在按钮下方增加额外的安全区域。4.4 底部弹窗键盘遮挡弹窗、底部菜单、输入框在沉浸模式下被软键盘遮挡是我排查最多的场景之一。WindowInsets.Type.ime()对应的是输入法区域它和navigationBars()的bottom很容易产生叠加。在Android 13上如果导航栏隐藏IME弹出时系统会把IME的Insets上报为一个较大的bottom如果你只处理navigationBars()会漏掉输入法的区域。我在项目里的通用处理是底部所有需要避开键盘的View用maxOf(sysBars.bottom, imeInsets.bottom)作为bottom padding。如果页面本身是滚动性质的比如评论列表加输入框建议使用WindowInsetsAnimationCompat来控制内容平移动画这样键盘弹出时不会瞬间跳变。有一个典型的误区设置了android:windowSoftInputModeadjustResize之后Activity根布局会随着键盘高度变化而resize此时如果又用手动padding去加高度就会出现双重偏移。正确做法是只保留adjustResize然后在Insets监听里处理padding不再额外修改尺寸。4.5 状态栏背景异常变黑Android 13的statusBarColor如果被设置成透明部分设备上会出现状态栏背景变黑的问题这通常和主题样式有关。Theme里如果用了windowDrawsSystemBarBackgroundsfalse应用就不会去绘制状态栏背景系统会使用默认的黑色或者主题色兜底。我推荐在Theme中显式声明item nameandroid:windowDrawsSystemBarBackgroundstrue/item item nameandroid:statusBarColorandroid:color/transparent/item item nameandroid:navigationBarColorandroid:color/transparent/item如果你希望状态栏背景跟页面顶部颜色一致就不要把statusBarColor写死为transparent而是在运行时通过window.statusBarColor动态改变。这样既可以让状态栏透明后露出背景也可以让状态栏变成一个纯净颜色两种需求都能满足。5. 状态栏导航栏控制方案的扩展思考5.1 对华为、小米、OPPO等定制系统的适配差异Android 13是开源系统但国内主流厂商往往在系统界面上做二次定制。状态栏和导航栏的高度、回调时机、隐藏行为都可能与原生稍有不同。我发现的最典型差异是某些定制ROM会默认开启“防误触”机制即使你隐藏了导航栏在屏幕底部仍然存在一个“热区”碰一下就会唤出导航栏。处理上我无法改变ROM层策略只能尽量在应用内把操作热区上移或者提供退出全屏的明确入口。如果你发现代码在原生模拟器上正常在真机上失效优先怀疑厂商适配不要盲目改代码。另一个实际差异是导航栏高度。原生Android 13三键导航高度约48dp而手势导航系统栏高度约24dp。但某些折叠屏和Pad设备底部系统栏高度可能更大。所有涉及导航栏高度的dp值都必须动态读取千万不要在dimens.xml里写死48dp。5.2 后续扩展动态区域划分配置如果你接手的是一个对状态栏导航栏控制非常严格的项目可以考虑做一个“动态区域配置”把每个页面需要的系统栏行为抽象成注解或配置项比如RequiresSystemBar( statusBarMode Mode.TRANSPARENT, navigationBarMode Mode.FULLSCREEN_HIDE, behavior Behavior.SWIPE_SHOW )然后在BaseActivity里统一解析注解将系统栏控制从页面代码中彻底剥离。这个方案适合页面多、需求杂、需要统一管理的项目。我实践下来维护成本会下降不少因为大部分页面只需要声明“我要什么模式”而不需要背一整段Insets逻辑。但要提醒的是注解化配置很容易在Fragment场景下失效。因为Activity的生命周期控制的是整体窗口而Fragment切换需要额外协调。如果你用了注解方案最好在BaseActivity里维护一个Fragment当前系统栏模式的栈切换时按栈顶模式还原。5.3 无障碍与用户操作习惯的权衡隐藏导航栏或状态栏本质上降低了系统的可见性对无障碍体验不友好。Android 13在无障碍模式下会自动阻止应用隐藏导航栏即使你调用hide()导航栏也会保持显示。正因为如此我在项目中预留了一个开关检测到系统无障碍服务开启或TalkBack运行时自动跳过全屏逻辑。实际测试中这个开关能避免很多“用户返回不了、操作不了”的投诉。尤其是拍照、游戏、阅读器这类长时间全屏的页面如果用户需要频繁操作系统UI强制隐藏系统栏反而会带来负体验。产品上应该允许用户自定义是否自动隐藏或者至少提供一个半透明的悬浮“退出全屏”按钮。直接隐藏状态栏和导航栏用WindowInsetsControllerCompat的hide()方法传入systemBars()类型。透明状态栏但保留导航栏设置statusBarColor为透明同时用setDecorFitsSystemWindows(window, false)让内容延伸导航栏保留原状。获取高度做动态布局通过ViewCompat.getRootWindowInsets读取statusBars()和navigationBars()的尺寸。处理输入法弹出在监听器里把ime()类型与systemBars()合并计算padding。监听系统栏可见状态变化使用WindowInsetsControllerCompat的addOnControllableInsetsChangedListener或isSystemBarVisible做UI联动。最后分享一个我自己的习惯所有系统栏控制逻辑我都会放在独立的类里不散落在Activity代码中。这样当厂商适配问题出现时我只要打开SystemBarController这一个文件就能排查。而且类里面所有方法都尽量有明确的“行为注释”标明“这是原生行为”还是“这是定制ROM适配”。这算不上多高级的技巧但能让你在半年后回看代码时少掉一大把头发。
