1. 项目概述:为什么我们需要强制全屏?
最近在做一个车载中控或者商显一体机的项目,你是不是也遇到了这样的头疼事?系统升级到Android 11之后,那些“不听话”的第三方应用,比如某个视频App或者导航软件,总喜欢在顶部留出一条状态栏,或者在底部弹出导航栏,把整个屏幕的沉浸感破坏得一干二净。对于追求一体化视觉体验的设备来说,这简直是灾难。用户希望看到的是一个完整的、无干扰的界面,而不是被系统UI元素切割得支离破碎的画面。
“强制所有第三方应用全屏”这个需求,听起来简单粗暴,但背后涉及的是Android系统从应用沙盒到系统框架层的权限博弈。特别是从Android 10(API 29)开始,Google为了安全和用户体验,对全屏沉浸式体验的管理越来越严格。传统的SYSTEM_UI_FLAG_FULLSCREEN和SYSTEM_UI_FLAG_HIDE_NAVIGATION这些标志位,在第三方应用里越来越不好使,系统会出于防止误操作(比如用户找不到返回键)的考虑,频繁地让导航栏重新显示出来。
所以,这个项目的核心目标,就是从系统层面找到一个稳定、可靠的方法,让任何第三方应用,无论它自身是否支持沉浸式模式,都能在我们指定的设备上以真正的全屏状态运行,隐藏状态栏和导航栏,且不会被用户手势或系统策略轻易打断。这不仅仅是改个UI标志位那么简单,它需要我们深入理解Android的窗口管理机制(WindowManager)、权限边界以及不同Android版本的行为差异。
2. 核心思路与技术方案选型
要实现全局强制全屏,我们不能依赖每个应用自己去适配。作为设备制造商或系统集成商,我们需要一个“上帝视角”的解决方案。经过多次实践和踩坑,我梳理出几条可行的技术路径,并分析其优劣。
2.1 方案一:修改系统Framework层(最彻底,但门槛高)
这是最根本的解决方案。思路是修改Android系统的WindowManagerService或相关策略类,在系统为第三方应用创建窗口时,强制为其添加全屏标志。
实现原理: Android中,窗口的属性(如LayoutParams)决定了它的显示行为。我们可以找到处理窗口添加和属性应用的关键代码位置(例如WindowManagerService.addWindow方法或PhoneWindowManager的策略逻辑),在这里进行拦截。对于第三方应用的窗口(通过包名或Uid判断),我们可以在其窗口的LayoutParams中动态添加FLAG_FULLSCREEN、FLAG_LAYOUT_IN_SCREEN、FLAG_LAYOUT_NO_LIMITS等标志,并清除可能引起系统栏显示的标志。
优势:
- 一劳永逸:修改后,所有应用无需任何改动,开机即全屏。
- 稳定性最高:从系统源头控制,不受应用自身行为影响,手势干扰也最少。
- 权限最高:可以做到真正的“强制”,系统级权限。
劣势与挑战:
- 需要系统源码:你必须拥有设备的Android系统源码,并且有编译和烧录系统镜像的能力。
- 技术门槛高:需要深入理解Android Framework,特别是窗口管理系统,代码修改和调试复杂。
- 兼容性风险:对系统代码的修改可能带来不可预见的稳定性问题,且升级系统版本时需要重新适配。
- 适用于:自有设备生产、ROM定制等场景。
注意:此方案涉及系统核心服务修改,务必进行充分的测试,确保不会影响系统稳定性、电源键、紧急呼叫等关键功能。
2.2 方案二:开发一个常驻的辅助服务(AccessibilityService)(折中方案)
这是一个不需要修改系统源码的“黑科技”方案。利用无障碍服务(AccessibilityService)的高权限,我们可以实时监控前台窗口的变化,并通过模拟按键或注入事件的方式,尝试触发系统的全屏模式。
实现原理:
- 创建一个无障碍服务,在配置中声明它能监听窗口状态变化。
- 在服务的
onAccessibilityEvent回调中,监听TYPE_WINDOW_STATE_CHANGED事件。 - 当检测到前台窗口是第三方应用时,通过
AccessibilityNodeInfo查找可能存在的“全屏”按钮或使用performGlobalAction模拟按下“导航栏隐藏”手势(如GLOBAL_ACTION_TOGGLE_SPLIT_SCREEN在某些版本上可能有效,但并非标准做法)。 - 更高级的做法是,利用无障碍服务的权限,直接获取窗口的
AccessibilityWindowInfo,然后通过反射调用WindowManager的相关方法去调整窗口属性(此方法极不稳定,且随版本变化大)。
优势:
- 无需系统源码:只需要一个APK,获取用户授权即可。
- 相对灵活:可以针对不同应用做差异化策略。
劣势与挑战:
- 依赖用户授权:首次使用必须用户手动在无障碍设置中开启,对于商用设备,需要引导或预置默认开启,体验不完美。
- 不稳定且被动:这是一种“事后补救”机制,全屏动作可能有延迟,且无法阻止应用自己重新绘制系统栏。
- 版本兼容性差:Android不同版本对无障碍服务的权限限制越来越严格,很多反射方法在新版本上已经失效。模拟按键的方式也不是一个可靠的通用方案。
- 影响性能:常驻监听所有窗口事件,对系统性能有轻微开销。
2.3 方案三:使用设备管理员(Device Owner)或配置文件管理器(DPC)应用(企业级方案)
这是Google为企业设备管理(EMM)提供的合法合规的高权限方案。通过将你的应用设置为设备所有者(Device Owner),你可以获得极高的系统权限,包括调用一些隐藏的API来管理策略。
实现原理:
- 将你的应用通过ADB命令或出厂预置的方式设置为设备所有者。
- 应用可以使用
DevicePolicyManager的setUserRestriction方法,设置DISALLOW_SYSTEM_BARS等限制(但请注意,这个限制可能并非直接隐藏,而是禁用交互)。 - 更常见的做法是结合
RoleManager来接管系统UI的角色,或者使用Overlay(悬浮窗)在全屏应用上方绘制一个遮罩层来遮挡系统栏(此法比较“山寨”)。
优势:
- 权限合法且高:是Google官方支持的企业设备管理方式。
- 可集中管理:可以远程下发策略,适合企业设备群控。
劣势与挑战:
- 配置复杂:需要特定的配置流程(如NFC碰触、二维码扫描或ADB命令)来启用设备所有者,普通用户无法自行完成。
- 功能限制:
DevicePolicyManager提供的公开API可能无法直接实现“强制全屏”这种精细的UI控制,通常用于更宏观的策略管理,如禁用状态栏下拉。 - 适用于:企业定制设备、Kiosk模式(信息亭模式)等受管场景。
2.4 方案四:定制Launcher并控制应用启动(针对特定场景)
如果你的目标是让设备只运行有限的几个全屏应用(如自助终端),那么可以简化问题:做一个定制化的Launcher(桌面),在这个Launcher里启动任何应用时,都为其设置好全屏的Intent参数或窗口属性。
实现原理:
- 开发一个全屏显示的Launcher应用,将其设为默认桌面。
- 在Launcher中,所有应用图标点击的启动逻辑,都由你控制。你可以通过
Intent携带额外的标志位,或者在你自己的Activity中启动目标应用,并利用ActivityOptions来设置启动动画和窗口模式。 - 更深入一点,可以结合
Activity的Lifecycle回调,在目标应用onResume时,通过Window接口尝试设置其窗口标志。
优势:
- 实现相对简单:主要集中在应用层逻辑。
- 可控性强:可以管理哪些应用全屏,哪些不全屏。
劣势与挑战:
- 非全局性:只能控制从你的Launcher启动的应用。如果应用内部通过
startActivity跳转,或者被系统其他组件(如通知)唤醒,则会脱离控制。 - 无法根治:和应用内设置全屏一样,可能被系统策略或应用自身行为覆盖。
综合选型建议: 对于消费级设备或追求完美体验的产品,方案一(修改Framework)是唯一可靠的选择。对于企业级设备或可以接受一定配置成本的场景,可以尝试方案三(DPC)。方案二(无障碍服务)作为一个临时或备选方案,但其稳定性和体验一般。方案四(定制Launcher)适合功能极度简化的专用设备。
接下来的实操,我们将以门槛最高但效果最好的方案一为例,深入讲解如何在Android 11的AOSP源码上进行修改。
3. 深入Framework层:关键代码修改实操
假设我们基于Android 11 (API 30) 的AOSP源码进行开发。这里的关键是找到窗口添加和属性计算的地方。我以PhoneWindowManager这个核心策略类作为切入点,因为它负责决定系统UI(状态栏、导航栏)的显示与隐藏。
3.1 定位与修改PhoneWindowManager
PhoneWindowManager的adjustWindowParamsLw方法是一个黄金切入点。该方法在窗口布局参数(LayoutParams)提交给WindowManagerService之前被调用,允许系统策略层对其进行最后的调整。
修改步骤:
找到源码文件:
frameworks/base/services/core/java/com/android/server/policy/PhoneWindowManager.java分析并修改
adjustWindowParamsLw方法: 我们需要在这个方法里添加逻辑:判断即将显示的窗口是否属于第三方应用,如果是,则强制为其添加全屏标志,并移除可能引起系统栏显示的标志。// 在 PhoneWindowManager.java 中 public void adjustWindowParamsLw(WindowManager.LayoutParams attrs, int callingUid, int callingPid) { // ... 原有的其他逻辑 ... // --- 新增强制全屏逻辑开始 --- final int type = attrs.type; // 我们主要关注应用窗口类型,例如 TYPE_BASE_APPLICATION, TYPE_APPLICATION等 if (type >= WindowManager.LayoutParams.FIRST_APPLICATION_WINDOW && type <= WindowManager.LayoutParams.LAST_APPLICATION_WINDOW) { // 获取窗口对应的包名 String packageName = attrs.packageName; // 这里需要实现一个工具方法来判断是否为需要强制的“第三方应用” // 通常我们可以定义一个系统白名单(如Launcher、Settings等系统核心应用不强制) if (packageName != null && isThirdPartyAppForcedFullscreen(packageName)) { // 添加全屏相关标志 attrs.flags |= WindowManager.LayoutParams.FLAG_FULLSCREEN; attrs.flags |= WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN; attrs.flags |= WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS; // 移除可能引起导航栏/状态栏显示的标志 attrs.flags &= ~WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN; attrs.flags &= ~WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS; // 非常重要:设置系统UI可见性(SystemUiVisibility) // 这个值会传递给ViewRootImpl,影响应用内部的UI布局 // 注意:直接修改attrs.systemUiVisibility可能不总是有效,因为应用自身会重置它。 // 更可靠的方法是同时修改LayoutParams,并确保系统策略层尊重这些修改。 // 我们可以尝试设置一个初始值,但最终控制权在系统策略的layoutWindowLw等方法中。 // attrs.systemUiVisibility |= View.SYSTEM_UI_FLAG_FULLSCREEN // | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION // | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY; // 更关键的是,要防止系统栏因为用户交互而重新显示。 // 这需要在策略层其他地方(如beginLayoutLw)也进行配合。 } } // --- 新增强制全屏逻辑结束 --- // ... 原有的其他逻辑,例如对系统窗口的特殊处理 ... } /** * 判断一个包名是否属于需要强制全屏的第三方应用 * @param packageName 应用包名 * @return true 需要强制全屏, false 不需要(通常是系统应用) */ private boolean isThirdPartyAppForcedFullscreen(String packageName) { // 这里实现你的判断逻辑 // 示例:定义一个系统应用白名单 Set<String> systemAppWhitelist = new ArraySet<>(); systemAppWhitelist.add("com.android.launcher3"); // 桌面 systemAppWhitelist.add("com.android.settings"); // 设置 systemAppWhitelist.add("com.android.systemui"); // 系统UI // ... 添加其他你不想强制全屏的核心系统应用 // 如果包名不在白名单内,则认为是需要强制全屏的第三方应用 return !systemAppWhitelist.contains(packageName); }处理系统UI重新显示的问题: 仅仅在
adjustWindowParamsLw中设置标志可能不够,因为用户从屏幕边缘滑动的手势(ImmersiveMode的防误触机制)或者应用自身的某些操作(如弹出输入法)可能会触发系统栏的临时显示。为了更彻底地隐藏,我们还需要修改布局策略。找到
beginLayoutLw或layoutWindowLw方法,这些方法决定了窗口和系统栏的最终位置。我们需要在这里确保,对于被标记为强制全屏的应用窗口,系统栏(状态栏、导航栏)的布局高度被设置为0,或者其窗口被放置在屏幕最底层。// 在 layoutWindowLw 或相关的布局方法中 public void layoutWindowLw(WindowManager.LayoutParams attrs, WindowState win, WindowFrames frames, DisplayFrames displayFrames) { // ... 原有布局计算逻辑 ... // 判断当前正在布局的窗口是否是强制全屏的应用窗口 if (win != null && isThirdPartyAppForcedFullscreen(win.getAttrs().packageName)) { // 强制将系统栏的显示区域置零,或将其窗口推到后面 // 这需要更精细地操作 displayFrames 中的 stableFullscreen, systemUi 等矩形区域 // 例如,将 displayFrames.mStableFullscreen 设置为整个屏幕,而不是减去系统栏的区域 displayFrames.mStableFullscreen.set(displayFrames.mUnrestricted); displayFrames.mSystemUis = displayFrames.mUnrestricted; // 谨慎操作,可能影响其他系统UI // 更安全的做法是,在计算应用窗口的框架时,直接使用最大的无限制区域 frames.mParentFrame.set(displayFrames.mUnrestricted); frames.mDisplayFrame.set(displayFrames.mUnrestricted); } // ... 继续原有布局逻辑 ... }
3.2 编译与刷机
修改完成后,需要重新编译系统镜像。
- 设置编译环境:在AOSP根目录下执行
source build/envsetup.sh和lunch选择你的目标设备。 - 编译模块:由于我们修改了
services核心模块,需要编译整个模块或系统镜像。# 编译 services 模块 mmm frameworks/base/services/ # 或者直接编译整个系统(更稳妥) make -j$(nproc) - 刷入设备:将生成的
system.img、boot.img等镜像文件刷入你的测试设备。务必提前备份数据。
3.3 实操心得与避坑指南
- 白名单机制至关重要:不要一股脑地把所有应用都强制全屏。像系统设置、Launcher、文件管理器这类需要用户进行系统级操作的应用,如果被强制全屏,用户将无法退出应用或进行设置,导致设备“变砖”。务必精心维护一个系统核心应用白名单。
- 版本差异巨大:Android 10、11、12、13在窗口管理和全屏策略上都有调整。例如,Android 12引入了新的“沉浸模式”手势和策略。我们的修改方案在Android 11上有效,但换到其他版本可能需要重新适配关键代码位置和API。
- 输入法(IME)窗口:当第三方应用弹出输入法时,输入法窗口本身也是一个应用窗口。你需要决定是否也对输入法进行强制全屏。通常不建议,因为全屏的输入法会遮挡所有内容,无法使用。需要在
adjustWindowParamsLw中排除TYPE_INPUT_METHOD类型的窗口。 - 权限窗口:类似悬浮窗权限请求、安装未知来源应用等系统对话框,它们也是应用窗口(
TYPE_APPLICATION_OVERLAY或TYPE_SYSTEM_ALERT)。强制它们全屏可能导致界面错乱。需要仔细处理这些特殊窗口类型。 - 测试,测试,再测试:修改Framework风险极高。必须对以下场景进行 exhaustive testing:
- 正常启动第三方应用(游戏、视频、地图等)。
- 横竖屏切换。
- 弹出系统对话框(如权限申请、日期选择器)。
- 弹出输入法。
- 通知栏下拉(尝试在
PhoneWindowManager中拦截STATUS_BAR相关事件)。 - 多任务切换(Recent Apps)。
- 关机、重启菜单。
4. 替代方案与补充技巧
如果你没有条件修改系统源码,这里提供一些在应用层尽可能逼近“强制全屏”效果的技巧,可以作为方案二或四的补充。
4.1 在应用内使用真正的沉浸式模式
虽然不能强制别人,但可以做好自己。如果你的Launcher或控制器应用需要全屏,请使用最新的沉浸式API。
// 在 Activity 的 onCreate 或 onResume 中调用 private fun hideSystemBars() { val windowInsetsController = ViewCompat.getWindowInsetsController(window.decorView) windowInsetsController?.let { controller -> // 隐藏状态栏和导航栏 controller.hide(WindowInsetsCompat.Type.systemBars()) // 设置沉浸式粘性行为:用户滑动时栏位临时显示,无交互则自动隐藏 controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE } // 兼容旧API的备选方案 window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_LAYOUT_STABLE) }关键点:使用WindowInsetsControllerCompat(Jetpack库)是向前兼容的最佳实践。BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE这个行为模式是防止系统栏永久性重新显示的关键。
4.2 使用adb命令进行临时调试
在开发阶段,你可以通过ADB命令模拟一些全屏效果,用于快速验证。
# 隐藏状态栏和导航栏(需要设备有root权限或开发人员选项中的“模拟辅助显示设备”支持?) adb shell settings put global policy_control immersive.full=* # 恢复显示 adb shell settings put global policy_control null # 仅针对特定应用开启沉浸模式(例如包名为com.example.app) adb shell settings put global policy_control immersive.full=com.example.app注意:policy_control这个设置项并非官方公开API,在不同厂商和Android版本上的行为可能不一致,甚至不存在。它更适合作为调试工具,而非生产方案。
4.3 利用Presentation类驱动副屏
如果你的设备有多个显示区域(如车机的主屏和副屏),Presentation类可以在副屏上显示一个完全独立的全屏界面。你可以创建一个透明的、1像素的Activity在主屏,然后在副屏上通过Presentation展示全屏内容。这算是一种“曲线救国”的思路,将需要全屏的内容转移到另一个可完全控制的显示输出上。
5. 常见问题排查与解决实录
在实际操作中,我遇到了不少坑,这里把典型问题和解决方法记录下来。
问题1:修改了adjustWindowParamsLw,但应用启动后导航栏依然会偶尔闪现。
- 原因:应用自身的
ContentView或Window在onResume时可能会重新设置systemUiVisibility,覆盖了我们的设置。或者,系统策略层(如StatusBarManagerService)在其他地方重新计算了可见性。 - 排查:在
PhoneWindowManager的layoutWindowLw和beginLayoutLw方法中加Log,观察系统栏的显示区域是如何被计算的。同时,监控目标应用窗口的systemUiVisibility变化。 - 解决:除了在
adjustWindowParamsLw设置标志,必须在beginLayoutLw中确保对于强制全屏的应用,displayFrames中用于计算系统栏位置的矩形(如mStableFullscreen)就是整个屏幕区域。可能需要更激进地修改DisplayPolicy中关于isImmersiveMode的判断逻辑,让系统认为该应用始终处于沉浸模式。
问题2:横屏应用强制全屏后,画面被拉伸或显示不全。
- 原因:我们强制设置了
FLAG_LAYOUT_NO_LIMITS等标志,并可能修改了布局框架,这影响了应用窗口的裁剪区域和内容缩放。 - 排查:检查
layoutWindowLw中赋予frames.mContentFrame和frames.mDisplayFrame的值。确保它们与应用的宽高比匹配。 - 解决:在强制全屏时,要小心处理
LayoutParams的gravity和softInputMode。对于横屏游戏,可能需要保持其特定的宽高比,而不是简单地给与全屏区域。可以尝试只隐藏系统栏,但不修改窗口的layoutInDisplayCutoutMode等与刘海屏相关的设置。
问题3:系统设置(Settings)被意外强制全屏,用户无法操作返回。
- 原因:白名单配置遗漏或错误。
- 排查:检查
isThirdPartyAppForcedFullscreen方法中的白名单列表,确认包含了所有必要的系统应用包名。不同设备(如小米MIUI、华为EMUI)的系统应用包名可能不同。 - 解决:建立一个更健壮的白名单机制。除了静态列表,还可以通过
PackageManager查询应用的ApplicationInfo,检查其flags是否包含ApplicationInfo.FLAG_SYSTEM或ApplicationInfo.FLAG_UPDATED_SYSTEM_APP来判断是否为系统应用。对于自定义ROM,需要与系统应用列表同步更新。
问题4:在Android 12及以上版本,修改失效。
- 原因:Android 12对沉浸式模式、手势导航和窗口管理做了较大改动。例如,引入了新的
WindowManager属性setHideOverlayWindows等。 - 排查:对比Android 11和Android 12的
PhoneWindowManager和DisplayPolicy源码差异。 - 解决:需要针对新版本重新分析关键逻辑点。可能需要在
DisplayPolicy的getWindowLayoutParamsForSystemBars或adjustWindowParamsLw的调用链上游进行修改。关注与InsetsController和WindowInsets相关的新的策略逻辑。
强制第三方应用全屏是一个从“表面请求”深入到“系统策略”的过程。在Android日益收紧权限和强调用户体验一致性的背景下,这个需求实现起来越来越有挑战性。最可靠的永远是掌握系统底层的修改权。如果做不到,就需要在有限的权限空间内,通过组合策略(如设备管理+无障碍服务+应用层适配)来达成近似目标,并坦然接受其局限性和兼容性成本。每一次对系统行为的深度定制,都是一次与Android设计哲学的对话,需要我们在功能、稳定性和用户体验之间找到最精妙的平衡点。