1. 为什么要在 SystemUI 上动刀:需求场景与整体思路
Android 7.0 上的 SystemUI 状态栏和导航栏,是普通用户每天都要打交道的两条“边”。状态栏在顶部,显示时间、电量、信号、通知图标;导航栏在底部,承载返回、主页、多任务三个核心按键。绝大多数应用根本不需要碰它们,系统默认行为已经足够。但有一类场景绕不开:沉浸式展示。比如视频播放器、图片浏览器、游戏、车载中控、广告机、自助终端、教学一体机,这些场景里状态栏和导航栏是纯粹的干扰,用户希望画面铺满整块屏幕,手指一划才把系统栏唤出来,几秒不操作又自动隐藏。
我在几个定制设备项目里反复处理过这个需求,从 Android 5.1 一路做到 Android 9,Android 7.0(Nougat,API 24)是一个很典型的分水岭。它既不像 4.4 那样需要靠 WindowManager 的 flag 硬扛,也不像 8.0 之后对后台服务和窗口类型收得那么紧。7.0 上做系统栏隐藏,路子多、坑也多,选错方案后期维护会非常痛苦。
这篇文章面向三类人:一是做定制 ROM 或系统级应用的开发者,需要从 SystemUI 源码层面控制状态栏和导航栏;二是做沉浸式 App 的开发者,只想在自己的界面里隐藏系统栏,不想动系统;三是做设备定制的工程师,需要让设备开机就隐藏导航栏、只保留状态栏,或者反过来。我会把这几条路线都拆开讲清楚,包括它们各自的适用边界、核心代码、参数计算,以及我在真机上踩过的坑。
先给一个整体判断,方便你带着预期往下读:
| 方案层级 | 典型做法 | 影响范围 | 适用场景 |
|---|---|---|---|
| 应用层 | WindowInsetsController / View.SYSTEM_UI_FLAG | 仅当前应用窗口 | 普通 App 沉浸式 |
| 系统服务层 | 修改 PhoneWindowManager / StatusBarManagerService | 全局或指定包 | 定制 ROM |
| SystemUI 层 | 直接改 StatusBar / NavigationBar 的加载逻辑 | 全局 | 设备定制、广告机 |
| 属性/配置层 | 改 config.xml、系统属性 | 全局、开机生效 | 批量设备定制 |
这个表是全文的骨架。下面我会从“为什么这么分层”讲起,再逐层落到代码和实操。
2. 先搞清楚 Android 7.0 的系统栏架构
2.1 状态栏和导航栏到底是谁在管
很多人以为状态栏是“系统画的一层”,其实它就是一个普通的系统应用进程——com.android.systemui。这个进程里跑着两个关键的 View 容器:StatusBar(顶部)和NavigationBar(底部),它们分别由PhoneStatusBar和NavigationBarView承载。SystemUI 进程通过StatusBarManagerService和系统服务(system_server)通信,接收“显示/隐藏”“展开通知”“禁用导航”这类指令。
关键点在于:状态栏和导航栏的可见性,最终由 WindowManager 决定,而不是 SystemUI 自己说了算。SystemUI 只是把这两个 View 加到 WindowManager 里,真正控制它们显示隐藏的,是窗口的 flag 和系统服务下发的状态。理解这一点,你才知道为什么有些“隐藏代码”写了没反应——你改的地方根本不是决策点。
Android 7.0 里,状态栏窗口的 type 是TYPE_STATUS_BAR,导航栏是TYPE_NAVIGATION_BAR。这两个窗口类型在 WindowManagerPolicy 里有特殊处理,普通应用无法直接添加同类型窗口,这也是为什么应用层只能“请求”隐藏,而不能“强制”隐藏。
2.2 三条控制链路
系统栏的显示隐藏,实际存在三条并行的控制链路,它们会互相覆盖,这是很多诡异现象的根源:
- 应用窗口链路:应用通过
WindowInsets或SYSTEM_UI_FLAG向 WindowManager 请求隐藏系统栏,WindowManager 汇总所有窗口的请求后,通知 SystemUI 调整。 - 系统服务链路:
StatusBarManagerService提供disable()接口,可以禁用状态栏展开、禁用导航栏按键等,常用于锁屏、全屏通话。 - SystemUI 内部链路:SystemUI 自己根据当前状态(如是否锁屏、是否在最近任务)决定栏的透明度、位置和可见性。
三条链路里,应用链路的优先级最低,系统服务链路次之,SystemUI 内部逻辑在某些状态下会直接覆盖前两者。所以如果你在应用里隐藏了导航栏,一切到最近任务再切回来,导航栏又冒出来了,这不是 bug,是 SystemUI 在切换时重新应用了自己的状态。
2.3 为什么 Android 7.0 是个特殊节点
Android 7.0 引入了多窗口模式(Multi-Window),这是系统栏行为变化最大的版本。多窗口下,状态栏和导航栏的归属变得模糊:分屏时,两个应用共享同一套系统栏,谁都不能独占隐藏。这直接导致SYSTEM_UI_FLAG_FULLSCREEN在多窗口下可能失效。同时 7.0 对WindowInsets的分发做了重构,View.OnApplyWindowInsetsListener的行为和 6.0 有细微差别,很多在 6.0 上跑得好好的沉浸式代码,升到 7.0 就出问题。
另外,7.0 的 SystemUI 把导航栏的绘制逻辑做了调整,NavigationBarView的加载时机和 6.0 不同,直接改PhoneStatusBar里导航栏相关代码,可能发现根本走不到。这些细节后面会具体讲。
3. 应用层方案:不改系统也能隐藏系统栏
3.1 SYSTEM_UI_FLAG 的正确组合方式
应用层最常用的就是View.setSystemUiVisibility()。Android 7.0 上,隐藏状态栏和导航栏的经典组合是:
View decorView = getWindow().getDecorView(); int flags = View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_FULLSCREEN | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY; decorView.setSystemUiVisibility(flags);这几个 flag 不是随便堆的,每个都有明确职责:
LAYOUT_STABLE:让布局在系统栏显示隐藏时保持稳定,避免内容跳动。LAYOUT_HIDE_NAVIGATION和LAYOUT_FULLSCREEN:告诉系统“我的内容要延伸到系统栏区域下面”,这是内容真正铺满的前提。HIDE_NAVIGATION和FULLSCREEN:请求隐藏导航栏和状态栏。IMMERSIVE_STICKY:粘性沉浸模式,用户从边缘划出系统栏后,几秒不操作会自动隐藏,且系统栏是半透明的。
这里有个容易忽略的点:LAYOUT_*系列 flag 和HIDE_*系列 flag 必须成对出现。只写HIDE_NAVIGATION不写LAYOUT_HIDE_NAVIGATION,导航栏是隐藏了,但你的内容不会延伸到原来导航栏的位置,底部会留一条黑边。我见过不少项目栽在这里,以为是隐藏没生效,其实是布局没铺开。
3.2 IMMERSIVE 和 IMMERSIVE_STICKY 的区别
这两个 flag 经常被混用,实际行为差别很大:
IMMERSIVE:用户划出系统栏后,系统栏保持可见,直到用户主动把它划回去。适合需要用户明确操作系统栏的场景。IMMERSIVE_STICKY:用户划出系统栏后,如果几秒内没有交互,系统栏自动隐藏,且是半透明浮层,不挤压内容。适合视频、游戏这类不希望被系统栏打断的场景。
实测下来,视频类应用几乎都用IMMERSIVE_STICKY,因为用户偶尔划出状态栏看时间,看完自动消失,体验最顺。但要注意,IMMERSIVE_STICKY下系统栏是浮在内容上的,如果你的界面顶部有重要按钮,可能被状态栏盖住,需要自己做避让。
3.3 监听系统栏变化并重新隐藏
系统栏被用户划出后,即使设置了IMMERSIVE_STICKY,在某些机型上也不会自动隐藏,或者隐藏后你的布局需要重新调整。稳妥做法是监听OnSystemUiVisibilityChangeListener:
decorView.setOnSystemUiVisibilityChangeListener(new View.OnSystemUiVisibilityChangeListener() { @Override public void onSystemUiVisibilityChange(int visibility) { if ((visibility & View.SYSTEM_UI_FLAG_FULLSCREEN) == 0) { // 系统栏显示了,延迟后重新隐藏 decorView.postDelayed(new Runnable() { @Override public void run() { decorView.setSystemUiVisibility(flags); } }, 3000); } } });这个 3000 毫秒不是随便定的。太短,用户还没看完状态栏就消失了,体验差;太长,系统栏一直挂着,沉浸感没了。我试过 2000 到 5000 毫秒,3000 毫秒是大多数场景下比较舒服的值。如果你的应用有视频进度条之类的顶部控件,可以把这个值调大,或者干脆不自动隐藏,交给用户手动划回。
注意:
setSystemUiVisibility必须在主线程调用,且要在onWindowFocusChanged之后调用才稳定。在onCreate里直接调用,有时会被系统后续的窗口调整覆盖。
3.4 应用层方案的边界
应用层方案最大的问题是管不了别的应用,也管不了系统弹窗。当系统弹出权限对话框、来电界面、音量调节面板时,系统栏会被强制显示,你的 flag 会被临时覆盖。这是设计使然,应用层无法绕过。如果你的需求是“设备上永远不出现导航栏”,应用层方案做不到,必须往下走。
4. 系统服务层:从 PhoneWindowManager 入手
4.1 系统栏可见性的真正决策点
前面说过,系统栏的可见性最终由 WindowManager 决定。在 Android 7.0 里,这个决策逻辑集中在PhoneWindowManager(位于frameworks/base/services/core/java/com/android/server/policy/)。这个类里的systemUiVisibility字段和updateSystemUiVisibilityLw()方法是核心。所有窗口的SYSTEM_UI_FLAG请求会汇总到这里,再通过StatusBarManagerService下发给 SystemUI。
如果你想做“全局隐藏导航栏”,最干净的做法是在PhoneWindowManager里修改导航栏的可见性计算逻辑。比如在updateSystemUiVisibilityLw()里,强制把导航栏相关的 flag 置为隐藏:
// 伪代码示意,实际需结合版本源码 if (mNavigationBar != null) { mNavigationBar.setVisibilityLw(false); // false 表示隐藏 }但直接改这里风险很高,因为PhoneWindowManager还管着按键分发、手势、旋转等一堆逻辑,改错一行可能导致导航栏彻底不响应。我的建议是:优先用配置开关,而不是硬编码。在config.xml里加一个布尔值,比如config_forceHideNavigationBar,然后在PhoneWindowManager里读这个值来决定是否强制隐藏。这样不同项目可以复用同一套代码,只改配置。
4.2 通过 StatusBarManagerService 禁用系统栏
StatusBarManagerService提供了disable()接口,参数是DISABLE_EXPAND、DISABLE_HOME、DISABLE_RECENT、DISABLE_BACK等。这个接口原本是给锁屏、全屏通话用的,但也可以用来做设备定制。比如要禁用导航栏的三个按键:
StatusBarManager statusBarManager = (StatusBarManager) context.getSystemService(Context.STATUS_BAR_SERVICE); statusBarManager.disable(StatusBarManager.DISABLE_HOME | StatusBarManager.DISABLE_RECENT | StatusBarManager.DISABLE_BACK);这个方案的优点是不需要改 SystemUI 源码,通过系统服务就能生效。缺点是它只是“禁用”按键,导航栏本身还在,只是按键变灰不可点。如果你要的是导航栏整条消失,这个方案不够。而且disable()是全局的,一旦调用,所有应用都受影响,需要自己管理好调用时机。
4.3 修改 config.xml 实现开机默认隐藏
对于广告机、自助终端这类设备,最省事的方案是改frameworks/base/core/res/res/values/config.xml。Android 7.0 里有一个配置项config_showNavigationBar,控制导航栏是否显示:
<bool name="config_showNavigationBar">false</bool>改成false后,编译刷机,导航栏开机就不加载。这个方案的好处是彻底、干净、零运行时开销,导航栏根本不创建,也就不存在隐藏不隐藏的问题。缺点是改的是 framework 资源,需要重新编译系统镜像,且只适合批量定制的设备,不适合单个应用。
状态栏没有对应的config_showStatusBar,因为状态栏是系统必需组件。要隐藏状态栏,得改 SystemUI 的加载逻辑,或者用PhoneWindowManager强制隐藏。
5. SystemUI 层:直接改 StatusBar 和 NavigationBar
5.1 找到导航栏的创建入口
Android 7.0 的 SystemUI 里,导航栏的创建在PhoneStatusBar.java的makeStatusBarView()方法中。关键代码大致是:
// 伪代码,实际源码有差异 if (mNavigationBarView == null) { mNavigationBarView = (NavigationBarView) View.inflate(context, R.layout.navigation_bar, null); }要隐藏导航栏,最直接的做法是在这里加判断,不 inflate 导航栏,或者 inflate 后不 add 到 WindowManager。但要注意,NavigationBarView还被其他逻辑引用,比如setDisabledFlags()、notifyNavigationBarScreenOn(),如果直接不创建,这些调用会空指针。稳妥做法是创建一个空的占位 View,或者在这些调用处加空判断。
5.2 状态栏的隐藏与透明化
状态栏的隐藏比导航栏麻烦,因为状态栏承载了通知、时钟等系统信息,直接不创建会导致通知无法展示。常见的定制需求不是“完全隐藏状态栏”,而是“状态栏透明 + 内容铺到状态栏下面”。这需要两步:
第一步,在PhoneStatusBar里把状态栏窗口的背景设为透明:
// 伪代码 mStatusBarWindow.setBackgroundColor(Color.TRANSPARENT);第二步,在PhoneWindowManager里让状态栏窗口的layoutFlags包含FLAG_TRANSLUCENT_STATUS或FLAG_LAYOUT_IN_SCREEN,这样应用内容才能延伸到状态栏区域。
如果确实要完全隐藏状态栏,可以在PhoneStatusBar的addStatusBarWindow()里根据配置决定是否mWindowManager.addView()。但隐藏后,下拉通知栏、时钟显示这些功能都会失效,需要评估是否可接受。
5.3 动态显示隐藏的广播控制
对于设备定制,经常需要“平时隐藏,特定场景显示”。比如教学一体机,上课时全屏,老师需要时按个键唤出导航栏。这种需求适合用广播控制:在 SystemUI 里注册一个自定义广播,收到后调用mNavigationBarView.setVisibility()。
// SystemUI 内注册广播 IntentFilter filter = new IntentFilter("com.example.action.SHOW_NAV"); context.registerReceiver(new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { boolean show = intent.getBooleanExtra("show", false); mNavigationBarView.setVisibility(show ? View.VISIBLE : View.GONE); } }, filter);这个方案灵活,但要注意广播的权限控制,避免被第三方应用随意唤出系统栏。建议用sendBroadcast时指定权限,或者用LocalBroadcastManager限制在应用内。
6. 实操过程:从零实现一个可配置的隐藏方案
6.1 环境准备与源码定位
假设你拿到了一份 Android 7.0 的源码,要做一个“导航栏默认隐藏、可通过配置开启”的定制。第一步是定位文件:
frameworks/base/core/res/res/values/config.xml:加配置项。frameworks/base/services/core/java/com/android/server/policy/PhoneWindowManager.java:读配置,控制导航栏。frameworks/base/packages/SystemUI/src/com/android/systemui/statusbar/phone/PhoneStatusBar.java:导航栏创建逻辑。
编译前先确认你的源码能正常编出 SystemUI 和 framework,否则改完不知道是代码问题还是环境问题。我一般会先编一个原版镜像刷进去,确认基线正常,再动代码。
6.2 添加配置开关
在config.xml里加:
<bool name="config_hideNavigationBarByDefault">true</bool>然后在PhoneWindowManager里读取:
// 伪代码 boolean hideNav = context.getResources().getBoolean( com.android.internal.R.bool.config_hideNavigationBarByDefault);这个值可以在init()或systemReady()里读,存成成员变量,后续在updateSystemUiVisibilityLw()里使用。
6.3 修改导航栏可见性计算
在PhoneWindowManager的updateSystemUiVisibilityLw()里,找到计算导航栏可见性的地方,加入你的开关判断。Android 7.0 的这段逻辑大致是:
// 伪代码示意 int visibility = 0; if (mNavigationBar != null) { visibility = mNavigationBar.getVisibilityLw(); } // 加入强制隐藏 if (mHideNavigationBar) { visibility |= View.STATUS_BAR_DISABLE_NAVIGATION; // 示意 }实际源码里用的是mNavigationBar.setVisibilityLw()和mNavigationBar.setDisabledFlags(),需要结合具体版本调整。改完后,导航栏在开机后就不会显示,但按键逻辑还在,只是不可见。
6.4 编译与验证
编译命令(在源码根目录):
source build/envsetup.sh lunch aosp_arm-eng make -j8 SystemUI framework编完后刷入设备,验证三点:一是开机后导航栏是否隐藏;二是状态栏是否正常;三是应用全屏时是否正常。我遇到过改完导航栏隐藏后,状态栏下拉失效的情况,原因是PhoneWindowManager里导航栏和状态栏的 flag 有耦合,改导航栏时误伤了状态栏的 flag。所以改完一定要逐项验证,不要只看导航栏。
7. 常见问题与排查技巧实录
7.1 隐藏后导航栏又自己冒出来
这是最高频的问题。原因通常是 SystemUI 在某个时机(如屏幕旋转、应用切换、锁屏解锁)重新应用了自己的可见性状态,覆盖了你的设置。排查方法是看PhoneStatusBar里所有调用mNavigationBarView.setVisibility()的地方,找到那个“重新显示”的调用点。常见的是notifyNavigationBarScreenOn()和setDisabledFlags()。
解决思路不是删掉那些调用,而是在它们内部加判断,如果全局隐藏开关开着,就跳过显示逻辑。这样既保留了原有逻辑,又不会被覆盖。
7.2 沉浸式模式下内容被状态栏遮挡
IMMERSIVE_STICKY下系统栏是浮层,内容会延伸到状态栏下面。如果你的界面顶部有标题栏,会被状态栏盖住。解决办法是在布局里加fitsSystemWindows或者手动设置 padding。但fitsSystemWindows在 7.0 上行为不稳定,我一般用手动 padding:
View root = findViewById(R.id.root); root.setOnApplyWindowInsetsListener(new View.OnApplyWindowInsetsListener() { @Override public WindowInsets onApplyWindowInsets(View v, WindowInsets insets) { int top = insets.getSystemWindowInsetTop(); v.setPadding(0, top, 0, 0); return insets; } });注意,getSystemWindowInsetTop()在系统栏隐藏时返回 0,显示时返回状态栏高度。这样内容会自动避让,系统栏隐藏时又不留空白。
7.3 多窗口下隐藏失效
Android 7.0 的多窗口模式下,SYSTEM_UI_FLAG_FULLSCREEN可能被系统忽略,因为分屏时两个应用共享系统栏,谁都不能独占。如果你的应用要支持多窗口,需要在AndroidManifest.xml里声明android:resizeableActivity="false",禁止分屏,这样沉浸式 flag 才会生效。或者接受多窗口下系统栏常驻,只做内容避让。
7.4 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 隐藏代码无反应 | flag 组合不对 | 检查 LAYOUT_* 和 HIDE_* 是否成对 |
| 隐藏后又出现 | SystemUI 覆盖 | 查 setVisibility 调用点 |
| 内容被遮挡 | 未处理 WindowInsets | 加 OnApplyWindowInsetsListener |
| 多窗口失效 | 系统栏共享 | 禁用 resizeableActivity |
| 导航栏隐藏但按键还在 | 只改了可见性 | 同时改 disabledFlags |
| 状态栏透明但内容没铺开 | 缺 layoutFlags | 检查 FLAG_LAYOUT_IN_SCREEN |
7.5 几个我踩过的坑
第一个坑:在onCreate里调setSystemUiVisibility,结果被onWindowFocusChanged后的系统调整覆盖。后来改成在onWindowFocusChanged里调,稳定了。
第二个坑:改PhoneWindowManager时,把导航栏的 flag 和状态栏的 flag 写反了,导致状态栏下拉失效。排查了半天才发现是两个 flag 常量值接近,复制粘贴时没改。
第三个坑:用广播控制导航栏显示时,没加权限,结果被一个测试应用误触发,导航栏在演示时突然冒出来。后来加了android:protectionLevel="signature"的自定义权限才解决。
8. 方案选型建议与扩展思路
8.1 按需求选方案
如果你的需求是单个 App 沉浸式,用应用层SYSTEM_UI_FLAG就够了,不要动系统。如果是定制设备要全局隐藏导航栏,优先改config.xml的config_showNavigationBar,最干净。如果需要动态控制,用 SystemUI 广播方案。如果要在系统服务层做统一管控,改PhoneWindowManager,但要做好回归测试。
选型的核心判断标准是:改动层级越深,影响范围越大,维护成本越高。能用配置解决的,不要写代码;能在 SystemUI 层解决的,不要动 framework 服务。
8.2 后续可以扩展的方向
这套方案可以进一步扩展成“系统栏管理器”:把状态栏和导航栏的可见性、透明度、颜色都做成可配置项,通过系统属性或配置文件读取,开机时应用。这样一台设备可以适配多种使用场景,比如白天显示状态栏、晚上全屏,或者根据外接设备自动切换。
另一个方向是结合手势导航。Android 7.0 还没有原生的手势导航,但可以在 SystemUI 里自己实现边缘滑动唤出导航栏的逻辑,替代传统的三键导航。这在定制设备上很实用,能进一步释放屏幕空间。
我个人在实际操作中的体会是,Android 7.0 的系统栏控制,难点不在代码本身,而在理解“谁在什么时候覆盖了谁”。把三条控制链路的关系理清楚,大部分问题都能定位。另外,改系统代码一定要做完整的回归测试,尤其是状态栏下拉、锁屏、来电、旋转这几个场景,它们最容易触发系统栏的重新计算。最后分享一个小技巧:调试时可以用adb shell dumpsys window查看当前窗口的systemUiVisibility值,比在代码里打日志直观得多。