做 Android Framework 或 SystemUI 开发的同学应该都见过这个字段KeyguardOccluded。它的字面意思是“锁屏被遮挡”但真正追过一遍从窗口系统到 SystemUI 的完整变化流程之后你会发现这个状态牵扯到的调用链比你想象的长得多而且在 Android RAndroid 11上做了一次不小的结构调整。最近我在一个项目中排查锁屏闪一下又消失的诡异问题时被迫把这条链路完整捋了一遍这篇文章就当作一次源码阅读笔记来写给同样被 occluded 折磨过的朋友做个参考。先交代结论所谓 occluded 变化流程本质上是“某个窗口覆盖在锁屏之上”这一事实如何从 WindowManager 的窗口列表里被识别出来再通过跨进程 binder 通知到 SystemUI最终驱动锁屏视图执行显示、隐藏或动画切换的完整链路。Android R 的改动重点集中在 SystemUI 侧StatusBar被拆分、窗口控制逻辑收拢到NotificationShadeWindowController连带把 occluded 的处理也重排了一遍。1. 从一次异常闪屏说起occluded 状态到底管什么项目背景其实很简单。某个内部项目里收到反馈设备在锁屏状态下用户点击第三方快捷方式启动了一个带showWhenLocked的全屏告警页面结果退出这个页面回到锁屏时锁屏界面会先闪一下再稳定显示。当时第一反应是锁屏重绘或者 Activity 转场的问题但把 transition 动画关掉之后现象依旧。最后跟着 log 一路查到系统侧的 occluded 状态变化才发现问题出在 SystemUI 对这个状态的响应逻辑上。先说清楚 occluded 到底在描述什么。锁屏 Keyguard 是系统窗口栈里一个特殊的存在正常情况下它位于所有应用窗口之上负责拦截触摸、展示通知和认证界面。但有一种例外应用窗口可以通过FLAG_SHOW_WHEN_LOCKED或者 Activity 的setShowWhenLocked(true)合法地出现在锁屏之上。当这种窗口存在时Keyguard 不是不可见了而是处于“被遮挡”的语义状态用occluded来表示。这里有个最常见的误解把 occluded 和 visible 等同起来。实际上这是两组完全独立的布尔量。状态组合含义典型场景showingtrue, occludedfalse锁屏正常显示在最顶层亮屏后未解锁showingtrue, occludedtrue锁屏被其他窗口覆盖但仍可部分或全部可见showWhenLocked Activity 覆盖其上showingfalse, occluded任意锁屏已解锁并不再显示解锁进入桌面goingToSleep, occludedtrue灭屏过程中锁屏处于遮挡态来电亮屏瞬间所以 occluded 解决的其实是“锁屏还活着但位置和交互策略得让位给另一个窗口”的状态管理问题。它最直接的影响体现在三件事上锁屏视图要不要真正隐藏或改变布局、通知内容还能不能展示、以及系统按键事件在锁屏和遮挡窗口之间如何分发。对 Android R 来说这个状态的处理逻辑还叠加了一次 SystemUI 重构的背景。在 R 之前PhoneStatusBar承担了大量锁屏和通知栏窗口的直接管理职责occluded 的变化也由它内部消化一部分。R 之后锁屏视图依然由KeyguardViewMediator和StatusBarKeyguardViewManager打理但窗口参数相关的控制逐步迁移到了NotificationShadeWindowController这套分工直接影响后面整条调用链的走向。2. 窗口系统的判断源头WMS 何时把 keyguard 标记为被遮挡要理解 occluded 的变化流程不能只盯着 SystemUI源头其实在系统服务层的 WindowManagerService。WMS 维护着全局窗口列表每个窗口的显示位置、可见性、遮挡关系它最清楚。所以在窗口添加、移除、布局变化时WMS 都要做一次“当前是否有人盖住了锁屏”的判断。2.1 进入 occluded 的判定条件触发 occluded 的窗口必须满足几个条件的交集。首先是窗口本身可见一个不可见的窗口哪怕标记了FLAG_SHOW_WHEN_LOCKED也不会影响锁屏状态。其次是窗口要处于一个能够遮挡 lock screen 的位置只有显示在 keyguard 窗口上方的窗口才有资格参与判定。最关键的是第三个条件窗口必须携带FLAG_SHOW_WHEN_LOCKED或者属于系统级窗口类型。注意区分两个历史 flag。早期的FLAG_SHOW_ON_LOCK_SCREEN在 Android 9 之后被废弃新代码统一走FLAG_SHOW_WHEN_LOCKED。两者的语义差异在于showWhenLocked 表示“这个窗口出现时锁屏要让路”而 showOnLockScreen 表示的是一种更古老的、与锁屏同屏展示的意图。Android R 里应用层再使用后者会得到一个 deprecation 警告系统内部判定遮挡时也不再依赖它。还有个容易被忽略的点窗口的透明特性。一个全透明或部分透明的 showWhenLocked 窗口出现在锁屏上方时occluded 同样会变成 true但锁屏视图不能彻底隐藏否则透过透明区域会看到一块黑屏。所以 WMS 判定窗口“能否遮挡锁屏”时并不要求窗口不透明只是后续 SystemUI 会根据实际情况决定锁屏是彻底离开屏幕还是仅仅让出交互焦点。这一点在后面的视图处理里会再展开。2.2 WMS 侧的遍历与状态通知WMS 在窗口状态发生变化时大致会执行这样一段逻辑// 伪代码基于 AOSP Android R WindowManagerService private void updateKeyguardOccluded() { // 1. 拿到当前 Display 的窗口列表 WindowList windows getDefaultDisplay().getWindowList(); boolean occluded false; // 2. 从视觉最上层的窗口开始向下遍历 for (int i windows.size() - 1; i 0; i--) { WindowState win windows.get(i); if (!win.isVisible()) continue; // 3. 锁屏窗口本身不算遮挡 if (win.mAttrs.type TYPE_KEYGUARD) continue; // 4. 判断这个窗口是否具备“覆盖锁屏”的资格 if (win.canBeOccludedByKeyguard() || win.isVisibleOnKeyguard()) { occluded true; break; } } // 5. 状态变化时才往下通知 if (mKeyguardOccluded ! occluded) { mKeyguardOccluded occluded; mPolicy.onKeyguardOccludedChanged(occluded); } }这段逻辑注意几个细节。遍历是从窗口栈顶往下找所以只要找到第一个可见且“有资格覆盖锁屏”的窗口就可以立刻得出结论。添加窗口、移除窗口、relayout 导致可见性变化时WMS 都会重新评估一次。另外mKeyguardOccluded的旧值必须参与比较只在状态翻转时才触发通知避免每帧都向上抛消息。2.3 为什么需要区分“透明遮挡”和“完全遮挡”如果你把 occluded 理解成一个简单的布尔开关就会错过 Android R 中一个精细的设计WMS 通知给 SystemUI 的 occluded 只表示“有没有窗口覆盖在锁屏上方”但并没有表达“这扇窗户是玻璃的还是砖墙的”。真正决定锁屏视图要不要完全退场还得靠 SystemUI 自己去检查覆盖窗口的透明度和类型。这不是 WMS 偷懒而是职责划分。WMS 关注的是窗口纪律和 z-order它只需要回答“锁屏是否让出了前面位置”SystemUI 关注的是视觉呈现它需要决定“锁屏在让位之后到底变成什么样”。所以 Android R 里即使 occludedtrueStatusBarKeyguardViewManager 内部依然会区分两种模式遮挡窗口是不透明的锁屏可以完全隐藏遮挡窗口是半透明的锁屏必须保留显示只是不能接收触摸。这种分工在实际调试中很容易造成困惑。你看到 log 里 occluded 已经变成 true但锁屏画面还挂在屏幕上于是怀疑状态没传到位。其实这个现象是正常的要看 SystemUI 侧拿到的窗口透明度参数才能判断锁屏最终的呈现方式。这个过程里的透明度信息并不是每次都通过 binder 单独传很多时候 SystemUI 要自己向 WindowManager 查询当前顶部窗口的布局参数这也是排查时最容易绕晕的地方。3. 跨进程搬运状态从 system_server 到 SystemUI 的 binder 链路WMS 属于 system_server 进程SystemUI 是独立进程。occluded 状态从前者搬到后者要跨过一条 binder 通道而且这条通道不是直连 WMS 和 SystemUI中间还夹着一个 KeyguardServiceDelegate。3.1 KeyguardServiceDelegate 与 IKeyguardService 的分工系统服务侧维护了一个名为 KeyguardServiceDelegate 的代理对象。它本身不实现锁屏逻辑而是持有 SystemUI 中 KeyguardService 的 binder 引用。SystemUI 启动时会通过 ServiceManager 将自己发布出去system_server 里的这个 Delegate 就像一把远程遥控器任何系统组件需要和锁屏通信都通过它按按钮。onKeyguardOccludedChanged(boolean)就是按钮之一。当 WMS 检测到 occluded 翻转调用 DisplayPolicy 或 WindowManagerPolicy 层的回调最终会到达这个 Delegate。Delegate 内部做一层简单的有效性检查比如 SystemUI 是否已经绑定、binder 是否还活着然后调用远程接口把值发给 SystemUI。// frameworks/base/services/core/java/com/android/server/policy/keyguard/KeyguardServiceDelegate.java public void onKeyguardOccludedChanged(boolean occluded) { if (mKeyguardService ! null) { try { mKeyguardService.onKeyguardOccludedChanged(occluded); } catch (RemoteException e) { Log.w(TAG, Failed to call onKeyguardOccludedChanged, e); } } }这段代码看着简单但它在整条链路里的位置很关键这是 occluded 信息第一次从窗口系统的语境切换到锁屏系统语境的转折点。窗口系统眼里这是“窗口遮挡关系”而从这行代码开始它变成了“锁屏状态变更通知”。3.2 到达 SystemUI 后的第一站KeyguardViewMediatorbinder 对端是 SystemUI 中的 KeyguardService它实现了 AIDL 接口。收到 onKeyguardOccludedChanged 后KeyguardService 没有自己做任何决策而是立刻转交给 KeyguardViewMediator。// packages/SystemUI/src/com/android/systemui/keyguard/KeyguardService.java Override public void onKeyguardOccludedChanged(boolean occluded) { mKeyguardViewMediator.setOccluded(occluded); }KeyguardViewMediator 是 SystemUI 里锁屏业务逻辑的中枢。它持有当前锁屏的显示状态、睡眠状态、以及各种业务上的标记位。setOccluded 进来之后它不只是记录一个 mOccluded 字段而是要综合当前的显示状态决定下一步动作如果锁屏正在显示需要通知视图层刷新如果锁屏还没准备好或者正在睡眠流程中这个值可能只是被保存下来等锁屏真正可见时再应用。3.3 这条链路最容易被忽略的时序问题binder 是异步的这意味着 occluded 状态通知和锁屏自身的显示状态通知可能以任意顺序到达。实际项目里最容易踩的坑就在这里Activity 的 showWhenLocked 窗口创建导致 occludedtrue 的通知和锁屏进入 show 状态的通知两者谁先到 SystemUI 并不保证。假设锁屏正在启动过程中occludedtrue 先到了而 KeyguardViewManager 内部还没有完成视图初始化setOccluded 可能不会对视图做任何操作。等锁屏视图创建完又不会重新检查一次 occluded 状态结果就是锁屏明明被遮挡却还挂在最上层挡住了后面的全屏告警页面。Android R 里的处理思路是让 KeyguardViewMediator 维护一个组合状态不单纯依赖某一条通知的先后而是在收到 setOccluded 时重新评估整体状态。我们后续排查那个闪屏问题最终也发现是某条分支只判断了 occluded 单值没有和 showing 状态做交叉判断导致视图在错误时机执行了重绘动画。4. SystemUI 侧的状态落地视图切换与动画细节跨过 binder信息终于到了 SystemUI 内部。这个阶段的重头戏是StatusBarKeyguardViewManager。它负责锁屏视图树的实际管理在 Android R 里仍然承担着核心角色但它的很多窗口级操作已经不再直接操作 PhoneStatusBar而是委托给NotificationShadeWindowController。4.1 StatusBarKeyguardViewManager 的 setOccluded 实现StatusBarKeyguardViewManager.setOccluded(boolean occluded, boolean animate)是整条链路上最核心的方法。它做的事情可以粗略拆成三块更新内部状态、根据遮挡方是否透明决定视图的最终形态、以及触发动画或直接变更可见性。// 伪代码体现 Android R 中 StatusBarKeyguardViewManager.setOccluded 的职责 public void setOccluded(boolean occluded, boolean animate) { // 1. 记录状态 boolean wasOccluded mOccluded; mOccluded occluded; // 2. 通知 KeyguardPresentation 或视图层刷新 if (occluded) { // 遮挡窗口不透明锁屏整体退出舞台 if (mIsOpaqueCover) { reset(true /* hideView */); } else { // 透明遮挡锁屏保留但让出交互 updateKeyguardPosition(); } } else { // 取消遮挡锁屏需要滑回视野 showKeyguard(); } // 3. 同步到窗口参数控制层 mNotificationShadeWindowController.setKeyguardOccluded(occluded); // 4. 通知其他依赖锁屏状态的组件 mKeyguardStateController.notifyKeyguardChanged(); }这里我标记的mIsOpaqueCover提示了刚才说的透明度之分。如果遮挡窗口是 opaque 的锁屏视图调用reset(true)直接把视图放进一个隐藏状态由窗口层接管一切如果遮挡窗口透明锁屏视图不会消失而是留在原处承担背景职责同时让触摸事件穿透或者保持不消费状态。4.2 NotificationShadeWindowController 如何配合更新窗口参数Android R 把NotificationShadeWindow的窗口属性管理统一放进了NotificationShadeWindowController。occluded 变化到这里的时候不只是锁屏视图自己的问题整个通知栏窗口的 touchable、focusable、以及和状态栏、导航栏的关联都要跟着调整。一个典型的场景occludedtrue 时通知栏窗口不能继续抢占焦点因为焦点应该属于覆盖在锁屏上方的那个应用。但状态栏本身可能还要显示否则用户无法看到时间、信号等信息。NotificationShadeWindowController要做的是把窗口参数中的可聚焦位去掉同时保留状态栏可视区域的显示逻辑。// 伪代码NotificationShadeWindowController 处理 occluded 相关的窗口状态 private void applyKeyguardOccludedState() { if (mKeyguardOccluded) { // 让出聚焦能力但保留状态栏可见 mWindowAttributes.flags ~FLAG_WATCH_OUTSIDE_TOUCH; mWindowAttributes.softInputMode ...; } else { // 回到锁屏接管输入的状态 mWindowAttributes.flags | FLAG_WATCH_OUTSIDE_TOUCH; } mWindowRootView.setVisibility(...); mWindowManager.updateViewLayout(mNotificationShadeWindow, mWindowAttributes); }这段逻辑最烦人的地方在于它不是每次 occluded 变化都无脑重设窗口参数因为参数变化本身会触发 WMS 的布局刷新反过来又可能影响 occluded 的判定形成隐式循环。Android R 的控制器里做了很多状态去重和延迟 apply 的处理调试时如果在这个地方打点经常能看到逻辑被调了多次但真正执行窗口更新的只有一次。4.3 动画执行中的“半遮挡”处理逻辑occluded 翻转为 false 的瞬间是动画问题的高发区。想象这个场景用户在全屏相机界面点击返回覆盖锁屏的窗口开始退出WMS 检测到遮挡解除occluded 变成 falseSystemUI 收到通知让锁屏重新滑入。如果此时窗口退出动画还没走完锁屏视图和正在退出的应用窗口会出现在同一帧里视觉上就是闪屏、重叠或者残影。Android R 的做法是让锁屏视图的显示不等到 occludedfalse 的那一次回调就立刻执行而是交回给 SystemUI 的动画调度器由它协调锁屏滑入和应用退出动画的先后。控制不了别人的动画但可以控制自己动画的起始延迟和时长用一套合理的插值让两个动画在时间上避开重叠。我在项目里遇到的闪屏问题最终定位就是在 occludedfalse 时锁屏视图立刻调用了setAlpha(1f)并同步触发重绘而此时相机窗口还未完全退场透明混合的结果就是锁屏突然闪出。后来把视图显隐改为等下一帧布局完成再应用问题才消失。这是典型的“状态对了但时机不对”的案例。5. 实战排查把 occluded 变化过程可视化追过源码之后最终还是要落实到调试手段上。occluded 链路跨了两个进程、三个模块如果没有一套自己的排查路径光靠 logcat 关键词搜会看到大量无关噪音。5.1 拿到完整调用链的日志与 dump我的习惯是先开三个 dump 并行观察adb shell dumpsys window windows adb shell dumpsys window policy adb shell dumpsys activity topdumpsys window windows看当前窗口列表和每层的可见性重点是找到那个覆盖锁屏的窗口是否真的在最上方以及它携带的 flag 是否是SHOW_WHEN_LOCKED。dumpsys window policy看系统策略层的状态包括 keyguard 当前是否显示、occluded 当前值。dumpsys activity top则帮我确认是哪个 Activity 在锁屏上搞事情。三个 dump 打出来的时间点要尽量靠近因为窗口状态变化很快隔了几秒可能已经不是同一现场了。5.2 通过关键字段判断当前状态在dumpsys window policy的输出里我通常会先找这样几行mKeyguardShowingtrue mKeyguardOccludedfalse mKeyguardSecuretrue这三个字段分别回答三个问题锁屏显不显示、有没有被盖住、是否需要安全认证。如果 occluded 和 showing 的组合与你在屏幕上看到的视觉结果对不上那问题多半出在 SystemUI 侧的视图处理而非 WMS 侧的判定。反之如果这两个字段本身就和你预期不符那要回到窗口列表里检查顶层窗口的属性。再配合 logcat 的标签过滤adb logcat -c adb logcat | grep -E KeyguardService|KeyguardViewMediator|StatusBarKeyguardViewManager|NotificationShadeWindowController这条命令能直接串出一条从 binder 入口到视图控制的调用链。实际运行时会发现系统里的 tag 可能还有其他模块的噪音但顺着这个顺序看总能找到 occluded 值翻转的转折点。5.3 我在实际项目中踩过的两个坑第一个坑是回调顺序导致的过度动画。某个内部版本里锁屏解锁前会连续收到两三次 occluded 状态翻转每次翻转都触发一次动画调度结果锁屏在解锁瞬间出现明显的卡顿和闪烁。根因是 WMS 在窗口 relayout 期间可能先后对同一个窗口的可见性做出多次评估而 SystemUI 侧没有对同一值的重复通知做拦截。后来在 KeyguardViewMediator 里加了状态去重才把多余动画压掉。第二个坑是 occluded 与 doKeyguard 的交互。在遮挡窗口存在的情况下调用lockNow()系统会重新走一遍 keyguard 的显示逻辑但此时 occluded 仍为 true。如果 SystemUI 在 doKeyguard 时无脑把锁屏视图推到最顶层就会把原本合法的遮挡窗口盖住导致全屏告警页失效。这个问题的正确姿势是先保持 occluded 状态等遮挡窗口自然退出后再让锁屏完全接管而不是在启动阶段就和窗口栈对抗。这两个坑在 Android R 上尤其值得注意因为重构后的窗口控制器把很多参数变化集中到了同一个入口状态去重和时序控制的成败往往就决定了一个设备在锁屏场景下的流畅度。我自己调试 occluded 相关问题时最后总会在StatusBarKeyguardViewManager.setOccluded的入口处加一行时间戳日志把 binder 到达时间、视图状态、窗口参数变化前后各记一笔。这套笨办法在排查跨进程问题时比任何花哨工具都有效因为它在关键时刻拉齐了两个进程各自的视角。如果你也在被锁屏显示异常折磨不妨先从这行日志开始。