1. 从一次下拉通知栏卡死说起:SystemUI 状态栏与导航栏的控制入口
做过 Android 系统定制的人,大概率都遇到过这样的场景:设备是给特定场景用的,比如数字标牌、自助终端、工业手持机,客户明确要求“把顶部状态栏和底部导航栏都藏掉,只留我自己的应用全屏跑”。你第一反应可能是去应用层调WindowManager.LayoutParams的FLAG_FULLSCREEN,或者用SYSTEM_UI_FLAG_HIDE_NAVIGATION。结果发现——应用切到后台再回来,状态栏又冒出来了;或者下拉通知栏一划,整个界面直接卡死,导航栏回不来。
这类问题的根子不在应用层,而在SystemUI 这个系统级应用本身。状态栏(StatusBar)和导航栏(NavigationBar)是 SystemUI 进程里两个独立的窗口,应用层的 flag 只能“请求”它们隐藏,真正决定它们能不能被隐藏、隐藏后怎么恢复的,是 SystemUI 内部的策略。Android 7.0(Nougat,API 24)在 SystemUI 的架构上做了一次不小的调整,尤其是把状态栏和导航栏的窗口管理逻辑进一步收拢到了PhoneStatusBar和NavigationBarView这条线上,同时引入了更严格的disable flags机制。
这篇内容就是围绕 Android 7.0 的 SystemUI,把状态栏和导航栏的隐藏与显示这件事从头拆一遍。我会讲清楚三个层面:应用层能做什么、SystemUI 内部怎么响应、以及系统定制时真正该改哪里。适合正在做 ROM 定制、设备固件开发,或者被全屏需求折磨过的 Android 系统开发者。读完你至少能明白:为什么有些隐藏方式一碰就崩,为什么有些方式重启后失效,以及怎么改才能既稳又可维护。
先给一个最核心的结论,免得你走弯路:在 Android 7.0 上,想稳定控制状态栏和导航栏,优先走 SystemUI 的disable标志位体系,而不是应用层的 immersive flag。应用层 flag 是“临时请求”,SystemUI 的 disable flag 才是“系统级状态”。这两者的优先级和生命周期完全不同,后面会详细展开。
2. 状态栏与导航栏在 SystemUI 里到底是两个什么东西
2.1 它们不是普通的 View,而是独立的系统窗口
很多人第一次看 SystemUI 源码会懵:状态栏和导航栏在布局文件里明明就是status_bar.xml和navigation_bar.xml,看起来跟普通界面没区别。但实际运行时,它们各自被addView到一个独立的WindowManager窗口里,窗口类型分别是TYPE_STATUS_BAR和TYPE_NAVIGATION_BAR。
这意味着什么?意味着它们的显示与隐藏,本质上是WindowManagerService(WMS)层面的窗口可见性变化,而不是简单的View.setVisibility()。你在应用层调SYSTEM_UI_FLAG_FULLSCREEN,最终走的是WindowManager.LayoutParams里的systemUiVisibility字段,由 WMS 转发给 SystemUI 去处理。链路很长,任何一环出问题,表现就是“隐藏不干净”或者“恢复不回来”。
在 Android 7.0 里,状态栏窗口由PhoneStatusBar管理,导航栏窗口由NavigationBarView配合PhoneStatusBar一起管理。注意,导航栏在 7.0 里并没有完全独立成一个服务,它和状态栏共享同一个PhoneStatusBar的生命周期,这也是为什么很多隐藏逻辑要写在PhoneStatusBar里才生效。
2.2 窗口的可见性由谁说了算:disable flags 体系
SystemUI 内部维护了一组disable flags,定义在StatusBarManager里,常见的有:
| Flag 名称 | 数值 | 作用 |
|---|---|---|
DISABLE_EXPAND | 0x0001 | 禁止下拉展开通知面板 |
DISABLE_NOTIFICATION_ICONS | 0x0002 | 隐藏通知图标 |
DISABLE_NOTIFICATION_ALERTS | 0x0004 | 禁止通知提示音/震动 |
DISABLE_SYSTEM_INFO | 0x0008 | 隐藏系统信息区域 |
DISABLE_HOME | 0x0010 | 禁用 Home 键 |
DISABLE_RECENT | 0x0020 | 禁用最近任务键 |
DISABLE_BACK | 0x0040 | 禁用返回键 |
DISABLE_CLOCK | 0x0080 | 隐藏时钟 |
DISABLE_SEARCH | 0x0100 | 禁用搜索键 |
DISABLE_NAVIGATION | 0x0200 | 隐藏导航栏 |
这些 flag 通过StatusBarManager.disable()方法设置,最终会回调到 SystemUI 的PhoneStatusBar.disable()里。关键点来了:DISABLE_NAVIGATION这个 flag 在 7.0 里是真正能让导航栏窗口隐藏的开关,而状态栏的隐藏则更多依赖DISABLE_SYSTEM_INFO配合窗口透明度/高度调整。
注意:
DISABLE_NAVIGATION隐藏导航栏后,系统默认的返回、Home、最近任务操作会一并失效。如果你的设备还需要物理按键或者手势操作,必须自己补上,否则用户会“困”在当前界面。
2.3 为什么应用层的 immersive flag 不可靠
应用层常用的SYSTEM_UI_FLAG_HIDE_NAVIGATION和SYSTEM_UI_FLAG_FULLSCREEN,在 7.0 上依然可用,但它们有几个天然缺陷:
第一,它们是“请求”而非“命令”。SystemUI 收到请求后,会结合当前 disable flags 决定是否响应。如果系统已经设置了DISABLE_NAVIGATION,应用的请求会被忽略;反过来,应用请求隐藏后,一旦有通知弹出或者用户触摸屏幕边缘,SystemUI 可能主动恢复导航栏。
第二,生命周期跟着 Activity 走。应用切后台、Activity 重建、配置变化,flag 都可能丢失。对于需要长期全屏的终端设备,这简直是灾难。
第三,沉浸模式在 7.0 上有已知的窗口重绘问题。部分定制 ROM 上,频繁切换 immersive flag 会导致状态栏窗口和导航栏窗口的 Z-order 错乱,表现就是状态栏“半透明地浮在应用上面”,点又点不到,非常难受。
所以,如果你的目标是设备级、持久化的隐藏,必须下沉到 SystemUI 的 disable flags 体系,而不是在应用里反复调 flag。
3. 用 StatusBarManager 控制隐藏与显示的完整链路
3.1 从调用 disable() 到窗口真正隐藏,中间发生了什么
假设你在一个系统级应用或者通过adb shell service call statusbar去设置 disable flag,调用的是StatusBarManager.disable(int what)。这个调用会跨进程走到StatusBarManagerService,然后StatusBarManagerService通过IStatusBar回调到 SystemUI 进程的CommandQueue,最终在PhoneStatusBar的主线程里执行disable()方法。
PhoneStatusBar.disable()里做的事情大致分几步:
- 把传入的
what和内部维护的mDisabled做位运算合并,得到新的 disable 状态。 - 根据新的状态,逐个判断各个 flag 的变化,比如
DISABLE_NAVIGATION从无到有,就触发导航栏隐藏。 - 导航栏隐藏走的是
mNavigationBarView.setVisibility()配合WindowManager.LayoutParams的高度调整,最终让 WMS 把导航栏窗口移除或设为不可见。 - 状态栏部分则更复杂,
DISABLE_SYSTEM_INFO会隐藏系统信息区域,但状态栏窗口本身可能还在,只是内容变空、高度变 0。
这里有个容易踩的坑:disable()是增量式的,不是覆盖式的。你调disable(DISABLE_NAVIGATION)隐藏导航栏后,再调disable(DISABLE_CLOCK),导航栏依然是隐藏的,因为mDisabled是累积的。想恢复导航栏,必须调disable(0)或者显式清除对应位。很多人在定制时反复调 disable 却忘了清除,结果导航栏再也回不来。
3.2 一个可直接复用的隐藏/显示封装
在实际定制中,我一般会封装一个简单的工具类,避免直接裸调StatusBarManager。下面这段代码基于 Android 7.0 的 API,放在系统应用里可以直接用:
import android.app.StatusBarManager; import android.content.Context; public class SystemBarController { private final StatusBarManager mStatusBarManager; public SystemBarController(Context context) { mStatusBarManager = (StatusBarManager) context.getSystemService(Context.STATUS_BAR_SERVICE); } // 隐藏导航栏,同时保留状态栏 public void hideNavigationBar() { if (mStatusBarManager != null) { mStatusBarManager.disable(StatusBarManager.DISABLE_NAVIGATION); } } // 隐藏状态栏系统信息区域 public void hideStatusBarInfo() { if (mStatusBarManager != null) { mStatusBarManager.disable(StatusBarManager.DISABLE_SYSTEM_INFO | StatusBarManager.DISABLE_CLOCK | StatusBarManager.DISABLE_NOTIFICATION_ICONS); } } // 全部恢复 public void restoreAll() { if (mStatusBarManager != null) { mStatusBarManager.disable(0); } } }这段代码的关键在于disable(0)那行。0表示清除所有 disable flag,SystemUI 会把状态栏和导航栏恢复到默认可见状态。实测下来,这个恢复动作在 7.0 上是同步生效的,不需要额外发广播或者重启 SystemUI。
提示:
StatusBarManager是@hideAPI,普通应用拿不到。你的应用必须放在/system/priv-app下,并且在AndroidManifest.xml里声明android.permission.STATUS_BAR权限,否则getSystemService返回 null。
3.3 隐藏导航栏后,按键事件去哪了
导航栏隐藏后,原本由导航栏处理的返回、Home、最近任务事件并不会自动转移到别处。系统层面,这些按键的输入事件依然会分发,但导航栏窗口不可见,就没有 View 去消费它们。结果就是:用户按了返回,系统没反应。
如果你做的是自助终端,通常需要自己接管这些操作。常见做法有两种:
第一种,在应用层监听onKeyDown,自己处理KEYCODE_BACK、KEYCODE_HOME。但KEYCODE_HOME在应用层是收不到的,它被系统拦截了。
第二种,也是更彻底的做法,是在 SystemUI 或者PhoneWindowManager里拦截按键,然后转发给你自己的逻辑。比如在PhoneWindowManager.interceptKeyBeforeDispatching()里判断当前是否处于导航栏隐藏状态,如果是,就把 Home 键事件转成启动你的主界面。
这里要提醒一句:不要试图在导航栏隐藏后去“模拟”导航栏按键,比如用InputManager.injectInputEvent()注入返回键。这种做法在 7.0 上很容易触发权限校验失败,而且事件时序很难控制,实测稳定性很差。
4. 系统定制场景下真正该改的几处代码
4.1 让导航栏默认就不显示:改 PhoneStatusBar 的初始化逻辑
如果你的设备从开机起就不需要导航栏,最干净的做法是在PhoneStatusBar初始化时就把DISABLE_NAVIGATION加上,而不是等某个应用来调。具体位置在PhoneStatusBar.start()或者makeStatusBarView()之后。
在PhoneStatusBar里有一个mDisabled字段,初始值是 0。你可以在start()方法末尾加一行:
// 默认隐藏导航栏 disable(StatusBarManager.DISABLE_NAVIGATION);但注意,disable()内部会触发一系列窗口操作,如果加得太早,mNavigationBarView可能还没初始化完,会空指针。稳妥的做法是监听mNavigationBarView的初始化完成,或者直接在makeStatusBarView()返回之后、start()的后期调用。
我自己的习惯是重写PhoneStatusBar的disable()方法,在里面加一个判断:如果当前是开机首次初始化,就强制把DISABLE_NAVIGATION位加上。这样既不影响后续的动态控制,又保证了默认状态。
4.2 状态栏的“隐藏”和导航栏不是一回事
很多人以为状态栏隐藏就是DISABLE_SYSTEM_INFO,其实不完全对。DISABLE_SYSTEM_INFO隐藏的是状态栏里的系统信息区域(比如信号、电池那一块),但状态栏窗口本身可能还在,只是高度变 0 或者内容为空。
如果你要的是状态栏窗口彻底不占空间,需要改的是PhoneStatusBar里状态栏窗口的LayoutParams。具体来说,在PhoneStatusBar的updateStatusBarLayout()或者类似方法里,把状态栏窗口的高度设为 0,并且设置FLAG_NOT_TOUCHABLE。
但这里有个权衡:状态栏窗口高度设为 0 后,下拉通知栏的手势也会失效。如果你的设备还需要下拉通知,就不能把高度设 0,而应该用DISABLE_EXPAND禁止展开,同时把状态栏内容隐藏。
下面这个表格对比了几种状态栏隐藏方式的实际效果:
| 方式 | 状态栏是否占空间 | 下拉通知是否可用 | 恢复难度 |
|---|---|---|---|
DISABLE_SYSTEM_INFO | 占空间,内容为空 | 可用 | 低 |
| 窗口高度设 0 | 不占空间 | 不可用 | 中 |
| 窗口设为不可见 | 不占空间 | 不可用 | 中 |
应用层FLAG_FULLSCREEN | 不占空间 | 不可用 | 低,但易失效 |
选择哪种,取决于你的设备是否还需要通知功能。工业设备通常不需要,那就直接窗口高度设 0;消费类设备需要保留通知,那就用DISABLE_SYSTEM_INFO。
4.3 导航栏隐藏后的窗口重布局问题
导航栏隐藏后,应用窗口并不会自动扩展到原来导航栏占用的区域。这是因为 WMS 在计算应用窗口的可用区域时,会参考DisplayContent里的mDisplayFrames,而导航栏隐藏后,mDisplayFrames的更新有延迟。
表现就是:导航栏没了,但应用底部留了一条黑边。解决方法是,在导航栏隐藏后,主动触发一次DisplayContent的重新布局。在 SystemUI 侧,可以调用mWindowManager.reLayoutWindow()或者发一个配置变更广播。
更简单的做法是在PhoneStatusBar的disable()里,导航栏状态变化后,调用mNavigationBarView.getViewTreeObserver()监听布局变化,然后手动请求一次requestLayout()。实测这个方式在 7.0 上能解决大部分黑边问题。
注意:频繁触发重布局会导致界面闪烁,所以一定要加状态判断,只在导航栏可见性真正变化时才触发,而不是每次
disable()都触发。
5. 那些年踩过的坑:隐藏与显示中的典型故障排查
5.1 隐藏后下拉通知栏卡死
这是最经典的问题。现象是:导航栏隐藏后,从屏幕顶部下拉,通知栏划出来一半就卡住,整个 SystemUI 无响应,几秒后可能 ANR。
根因通常出在PhoneStatusBar的panels管理上。导航栏隐藏后,mNavigationBarView为 null 或者不可见,但PhoneStatusBar里的mStatusBarWindow还在处理触摸事件。下拉手势触发PanelView展开时,会去计算导航栏的高度作为偏移量,如果导航栏已经隐藏,这个计算可能拿到异常值,导致布局死循环。
排查链路是这样的:
- 先看
logcat里有没有PhoneStatusBar相关的NullPointerException或者IllegalStateException。 - 如果没有异常,用
dumpsys window看当前窗口的mDisplayFrames和mNavigationBar的状态。 - 重点检查
PhoneStatusBar.makeStatusBarView()里对mNavigationBarView的引用,是否在隐藏后没有置空或者做了错误的条件判断。
修复方案:在PhoneStatusBar处理下拉手势的地方,加一个判断——如果DISABLE_NAVIGATION已设置,就跳过所有依赖导航栏高度的计算,直接用屏幕高度作为面板展开的基准。
5.2 恢复显示后导航栏位置错乱
另一个常见问题是:隐藏导航栏一段时间后再恢复,导航栏跑到屏幕中间或者右边去了。这通常是NavigationBarView的LayoutParams在隐藏时被改过,恢复时没有正确还原。
在 7.0 里,导航栏的LayoutParams里有一个privateFlags字段,隐藏时可能会被设置成FLAG_LAYOUT_NO_LIMITS之类的值。恢复时如果只调setVisibility(VISIBLE),这些 flag 不会自动清除。
我的做法是在恢复导航栏时,重新构造一次LayoutParams,而不是复用旧的。具体是在PhoneStatusBar的disable()里,当DISABLE_NAVIGATION被清除时,调用mNavigationBarView.updateLayoutParams(),强制重新计算位置和大小。
5.3 开机后第一次隐藏失效
有些定制 ROM 上,开机后第一次调disable(DISABLE_NAVIGATION)没反应,第二次才生效。这是因为 SystemUI 启动时,StatusBarManagerService和PhoneStatusBar的绑定有延迟,第一次调用时CommandQueue还没准备好。
解决办法是在PhoneStatusBar的start()里加一个延迟,或者监听StatusBarManagerService的systemReady()回调,确保绑定完成后再执行默认的 disable 设置。我一般会在PhoneStatusBar里加一个mBootCompleted标志,只有 boot 完成后才允许 disable 操作生效。
6. 一套可维护的隐藏/显示策略该怎么设计
6.1 用状态机管理,而不是散落的 flag 调用
如果你只是偶尔调一下disable(),那怎么写都行。但如果是设备级定制,状态栏和导航栏的可见性会随着不同应用、不同场景变化,散落的 flag 调用很快就会失控。
我的建议是引入一个简单的状态机,把“状态栏可见性”和“导航栏可见性”作为两个独立状态,每个状态有明确的进入和退出条件。比如:
- 导航栏状态:
VISIBLE、HIDDEN_BY_SYSTEM、HIDDEN_BY_APP - 状态栏状态:
VISIBLE、INFO_HIDDEN、FULLY_HIDDEN
每次状态变化时,统一走一个applyState()方法,在里面计算最终的 disable flag 组合,然后一次性调disable()。这样避免了多次调用导致的累积错误,也方便调试。
6.2 给应用层留一个受控的接口
完全禁止应用控制状态栏和导航栏也不现实,有些应用确实需要全屏。更好的做法是提供一个受控接口,比如通过Settings.Global或者自定义的ContentProvider,让应用可以“申请”隐藏,但最终决定权在系统。
具体实现上,可以在 SystemUI 里监听一个自定义的Settings.Global键值,应用修改这个键值后,SystemUI 收到ContentObserver回调,再决定是否响应。这样既给了应用灵活性,又保证了系统级状态不会被应用随意破坏。
6.3 日志和调试开关不能省
SystemUI 的调试比普通应用麻烦得多,因为它的日志默认不输出到logcat。我在定制时一定会加一个调试开关,通过Settings.Global控制,打开后把每次disable()的调用栈、当前mDisabled值、导航栏和状态栏的窗口状态都打到日志里。
这个习惯帮我省了大量排查时间。尤其是那种“偶发失效”的问题,没有日志根本无从下手。日志里重点记录三个东西:调用来源(哪个进程、哪个方法)、调用前后的mDisabled值、窗口的实际可见性。三者对不上,问题基本就定位了。
7. 关于稳定性,我自己的几条经验
做 Android 7.0 SystemUI 定制这些年,踩的坑比写的代码多。最后分享几条我个人觉得最值钱的经验,都是文档里不会写的。
第一条,任何对disable()的调用都要成对出现。隐藏和恢复必须配对,最好用try-finally或者状态机保证。我见过太多因为异常路径导致导航栏永久隐藏的案例,最后只能重启设备。
第二条,不要在PhoneStatusBar的主线程里做耗时操作。disable()本身是轻量的,但如果你在里面加了窗口重布局、资源加载之类的逻辑,很容易触发 ANR。需要耗时操作就 post 到单独的 HandlerThread。
第三条,测试一定要覆盖“隐藏-恢复-再隐藏”的循环。单次隐藏往往没问题,问题都出在反复切换上。我一般会写一个自动化脚本,循环切换 100 次,观察内存和窗口状态。7.0 上如果循环切换有泄漏,通常 50 次左右就能看到WindowManager的窗口数异常增长。
第四条,不同芯片平台的 SystemUI 可能有差异。同样是 Android 7.0,不同厂商的 BSP 里PhoneStatusBar的实现可能被改过。改代码前先用git log看看这个文件的历史,确认没有平台特有的补丁,否则你的修改可能被覆盖或者冲突。
这套东西说起来不复杂,但真正在设备上跑稳,需要把应用层、SystemUI、WMS 三层的关系理清楚。希望这篇内容能帮你少走几个我当年走过的弯路。