25、Launcher与SystemUI的交互:状态栏与导航栏的显示控制、手势导航的冲突处理、通知角标(Badge)
Launcher和SystemUI,说白了就是Android桌面上两个最核心的系统进程。一个管桌面布局和图标,一个管状态栏、导航栏和通知。它们之间的交互,直接决定了用户每天滑动、点击、返回的体验是否流畅。
我个人习惯把这种交互比作「前台和后台的默契配合」。Launcher是前台演员,SystemUI是幕后灯光师。灯光师不给光,演员再卖力也白搭。反过来,演员挡了灯光,观众也看不舒服。
今天我们就来拆解这三块硬骨头:状态栏与导航栏的显示控制、手势导航的冲突处理、以及通知角标(Badge)。
一、状态栏与导航栏的显示控制
先聊聊最基础也最容易出问题的一块:状态栏和导航栏的显示控制。
在Launcher中,我们经常需要根据场景动态控制系统栏的显示。比如桌面主界面显示状态栏,但进入全屏应用或壁纸预览时,需要隐藏状态栏甚至导航栏。
Android提供了两套机制来做这件事:一套是老的 SYSTEM_UI_FLAG 体系,另一套是新的 WindowInsetsController API。我个人建议新项目直接用后者,因为前者在Android 11以后已经被标记为deprecated了。
核心控制方式:
- SYSTEM_UI_FLAG_HIDE_NAVIGATION:隐藏导航栏(三键或手势指示条)
- SYSTEM_UI_FLAG_FULLSCREEN:隐藏状态栏
- SYSTEM_UI_FLAG_IMMERSIVE:沉浸模式,用户滑动可临时显示系统栏
- SYSTEM_UI_FLAG_IMMERSIVE_STICKY:粘性沉浸模式,滑动后自动隐藏
我在项目中遇到过一个问题:在Launcher的壁纸预览界面,我用了 SYSTEM_UI_FLAG_FULLSCREEN 隐藏状态栏,结果用户从顶部下滑时,状态栏弹出来就不回去了。后来发现是没配合 IMMERSIVE_STICKY 使用。嗯,这里要注意,沉浸模式一定要搭配对应的flag,否则会出现「状态栏卡住」的bug。
用新API的话,代码会更简洁:
// 使用 WindowInsetsController 控制状态栏
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
window.insetsController?.hide(WindowInsets.Type.statusBars())
window.insetsController?.systemBarsBehavior =
WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
为什么推荐新API?因为它更细粒度,而且能避免很多兼容性问题。你想想看,老flag在不同厂商ROM上的表现经常不一致,而 WindowInsetsController 是Google在Android 11中统一的行为。
二、手势导航的冲突处理
这块是真正的硬骨头。手势导航普及以后,Launcher和SystemUI之间的「地盘争夺战」就没停过。
典型场景:用户在桌面边缘左滑,本意是切换桌面页面,但SystemUI的手势导航却把它识别成了「返回」操作。这就是冲突。
解决思路其实不复杂:Launcher需要告诉SystemUI,「这块区域是我的,你别抢」。具体做法是通过 View.setSystemGestureExclusionRects() 来声明独占区域。
我的经验:
我曾经在适配全面屏手势时,发现Launcher的Dock栏底部滑动总是被系统手势拦截。排查了半天,发现是没给Dock区域设置排除区域。加上下面这段代码后,问题就解决了:
// 在 Launcher 的 Workspace 或 Dock 中声明手势排除区域
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val exclusionRects = listOf(
Rect(0, bottom - dockHeight, width, bottom) // Dock 区域
)
view.setSystemGestureExclusionRects(exclusionRects)
}
但这里有个坑:setSystemGestureExclusionRects 在Android 10上最多只能排除200dp的区域。如果Launcher的Dock太高,超出部分还是会被系统手势吃掉。我当时的做法是把Dock高度控制在200dp以内,或者分多段排除。
另外,手势导航的冲突还体现在「返回手势」和「侧滑抽屉」上。很多Launcher有左侧滑出抽屉的功能,但系统返回手势也是从左边缘滑入。怎么办?
我的做法是:在抽屉打开状态下,动态调整排除区域。抽屉关闭时,左边缘交给系统;抽屉打开时,左边缘交给Launcher。这样既不影响系统手势,也不影响抽屉操作。
注意:
不要试图完全屏蔽系统手势。用户已经习惯了系统级的手势操作,强行屏蔽会导致用户困惑。更好的做法是「协商」——让Launcher和SystemUI各自让一步。
三、通知角标(Badge)
通知角标,说白了就是图标右上角那个小红点或数字。它看起来简单,但实现起来涉及Launcher、SystemUI、NotificationManager三方的协作。
流程是这样的:
- 应用发送通知,携带
setNumber()或setBadgeIconType() - SystemUI的NotificationListener收到通知,计算角标数量
- SystemUI通过
LauncherApps或ShortcutManager回调Launcher - Launcher更新图标上的角标显示
在Launcher3中,角标的显示逻辑主要在 BadgeInfo 和 NotificationListener 中。我建议你重点关注 LauncherApps.Callback 中的 onPackageChanged 和 onNotificationsChanged 方法。
// Launcher 中监听通知变化
class BadgeUpdateHandler(private val context: Context) {
private val launcherApps = context.getSystemService(Context.LAUNCHER_APPS_SERVICE) as LauncherApps
fun register() {
launcherApps.registerCallback(object : LauncherApps.Callback() {
override fun onPackageChanged(packageName: String, user: UserHandle) {
// 刷新角标
updateBadge(packageName)
}
override fun onNotificationsChanged(packageName: String, user: UserHandle) {
// 通知数量变化时更新
val badgeCount = launcherApps.getBadgeCount(packageName, user)
updateBadgeCount(packageName, badgeCount)
}
})
}
}
这里有个容易踩的坑:角标计数同步延迟。我记得有一次,用户清除了通知,但Launcher上的角标还显示「1」。排查后发现是 onNotificationsChanged 回调的时机问题——SystemUI是先更新自己的通知面板,再回调Launcher。如果Launcher在回调时去查询通知数,可能查到的是旧数据。
我的解决方案是:在回调中延迟100ms再查询,或者直接依赖 getBadgeCount() 的返回值,而不是自己维护计数。
角标显示的几个关键点:
- 显示规则:默认显示数字,超过99显示「99+」
- 隐藏规则:用户点击通知、清除通知、或应用被卸载时隐藏
- 厂商差异:小米、华为等厂商有自己的角标实现,Launcher需要适配
- 性能优化:不要每次通知变化都刷新所有图标,只刷新受影响的应用
说到厂商差异,我遇到过最头疼的是某厂商ROM把角标计数逻辑写死在了SystemUI里,Launcher根本拿不到真实的通知数。最后只能通过监听 NotificationManager 的 getActiveNotifications() 来自己算。嗯,这种「自己动手丰衣足食」的情况,在Android碎片化生态里其实挺常见的。
好了,Launcher与SystemUI交互的这三个核心点,我们就聊到这里。状态栏控制要选对API,手势冲突要学会「协商」,角标同步要注意延迟。这些都是我在实际开发中反复踩过的坑,希望能帮你少走弯路。
公众号:蓝海资料掘金营,微信deep3321