开始之前:这个需求到底卡在哪
做AOSP定制的朋友应该都遇到过这类需求:客户拿了一台设备,说要锁屏页面不能点通知。看起来就是“把点击事件干掉”这么简单,但真上手改的时候才发现,通知栏的点击链路从SystemUI的NotificationStackScrollLayout一直延伸到StatusBar的通知点击处理器,中间还隔着锁屏状态判断、通知可见性控制、手势冲突处理这些环节。改错一层,轻则通知还是能点,重则锁屏下拉手势直接失灵,甚至正常解锁后通知也点不了了。
这篇文章我以Android 10.0的AOSP源码为基础,把这个需求从定位、改法到排坑完整梳理一遍。适合正在做Rom定制、SystemUI二次开发,或者对锁屏通知交互机制感兴趣的开发者参考。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
1. 需求场景与整体设计思路
1.1 这个需求从哪来
“锁屏页面禁止点击通知栏通知”这个需求,通常出现在几类场景里。
第一类是行业定制设备,比如教育平板、展览展示终端、医院床头屏、银行排队机。这类设备的锁屏页面本质上是信息展示面板,只允许用户看时间、日期、天气或者几条重要提醒,但不希望用户在不解锁的情况下点进通知对应的App。
第二类是出于防误触考虑。有些设备锁屏状态下放在口袋里或者挂在墙面支架上,如果通知来了用户一碰就弹出应用,不仅体验割裂,还可能引发误操作。
第三类则和隐私管控有关。锁屏状态下通知默认只展示摘要,但点击后进入通知详情甚至直接拉起应用,这相当于绕过了解锁界面,在人脸或指纹识别策略不严格的设备上尤其不合适。
知道需求从哪来之后,我们才能明确改造的边界:要禁的是“锁屏状态下点击通知的行为”,而不是把通知展示本身给禁掉。通知该出还得出,只是不能点。那这个边界在代码层面落在哪,就成了整个方案的关键。
1.2 锁屏通知的点击链路到底长什么样
要回答“在哪改”这个问题,先得把Android 10.0里锁屏通知的点击链路走一遍。
锁屏状态下通知的展示区域是KeyguardNotificationContainer,它挂在NotificationPanelView下面,和普通状态栏下拉后的通知区域用的是同一套列表容器NotificationStackScrollLayout。但注意,锁屏和展开状态的通知项使用的是同一个ExpandableNotificationRow视图。
用户在锁屏通知上点一下,触摸事件经过NotificationStackScrollLayout被识别为点击,然后通过ExpandableNotificationRow内部的监听器回调给NotificationClicker。这个NotificationClicker会检查通知是否可点击、是否被拦截,最后调用StatusBar的onNotificationClick相关方法去启动应用或者展开通知详情。
这段链路里有两个关键点:
第一,事件入口是统一的。无论锁屏还是正常状态,点击逻辑都走NotificationClicker这一层,这反而给了我们一个很好的改造切入点。
第二,锁屏和非锁屏的差异实际上由状态标志控制。StatusBarStateController维护着StatusBarState.KEYGUARD这个状态,而KeyguardStateController则维护面板锁屏状态(isKeyguardShowing)。这两个标志在点击链路里都有现成判断,我们要做的就是让“点”这个动作在特定条件下直接失效。
2. 关键源码定位与点击入口分析
2.1 Android 10.0 SystemUI 相关源码路径
在动手改之前,先把源码里要碰的文件找齐。不同版本路径会有些差异,我这里列的是Android 10.0(API 29)AOSP的常规路径。
第一个是/frameworks/base/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusBar.java。这是整个SystemUI的枢纽类,通知点击后最终拉起应用的一系列方法基本都从它这里走。
第二个是/frameworks/base/packages/SystemUI/src/com/android/systemui/statusbar/notification/NotificationClicker.java。看名字就知道,它是通知点击动作的直接处理者,会在点击时校验通知是否有效、是否需要发出NotificationClickListener的回调。
第三个是/frameworks/base/packages/SystemUI/src/com/android/systemui/statusbar/phone/NotificationPanelView.java。它是通知面板的根容器,既承载通知列表,也处理很多手势逻辑。
第四个是/frameworks/base/packages/SystemUI/src/com/android/systemui/statusbar/stack/NotificationStackScrollLayout.java。这里负责通知列表的布局、滑动、栈式堆叠,点击的子视图排列也是在这个类里完成。
第五个是/frameworks/base/packages/SystemUI/src/com/android/systemui/statusbar/KeyguardNotificationContainer.java。字面意思就是锁屏通知容器,锁屏状态下通知行的外层容器就是它。
这里有个容易踩的坑:很多人以为改锁屏通知就在KeyguardNotificationContainer里改就行,但实际上点击事件的最终消费根本不在这个容器里,它只是负责容纳和布局。改错文件等于白改,所以一定要顺着点击链路的代码往下追。
2.2 通知点击事件的流转过程
正常状态下,用户点击一条通知,整体流程大致是这样的:
触摸事件先到达NotificationStackScrollLayout的onInterceptTouchEvent,它判断这次触摸是点击还是滑动。如果是滑动,就交给列表自己滚动;如果是点击,则继续分发到具体的ExpandableNotificationRow。
ExpandableNotificationRow通过setOnClickListener注册了点击监听,点击触发后会调用NotificationClicker的onClick方法。NotificationClicker内部会做几件事:判断通知有没有关联的PendingIntent,检查通知是否设置了FullScreenIntent,如果这些都满足,那就回调NotificationClickListener,最终由StatusBar去执行startNotificationIntent拉起对应内容。
在Android 10里,NotificationClicker的判断逻辑中已经存在对锁屏的某些考量。比如onClick里会检查通知的可见性,锁屏下只显示敏感通知的摘要,但这些检查是为了决定“显示多少内容”,而不是为了“让点击失效”。所以我们要插入的,正是这个判断缺口。
2.3 锁屏状态下点击链路的特殊分支
锁屏状态下,链路上有几个特殊的地方要注意。
第一,StatusBarStateController会将状态切换到StatusBarState.KEYGUARD,此时通知面板处于“锁屏展示”模式,NotificationPanelView的高度、展开逻辑都是按锁屏规格处理。
第二,KeyguardStateController的isKeyguardShowing在锁屏期间返回true。这个标志在很多交互入口都有检查,比如下拉快捷设置开关、点按通知、点击快捷操作按钮等等。
第三,锁屏通知在KeyguardNotificationContainer里展示时,ExpandableNotificationRow仍然保留点击能力。如果不加控制,点击直接就触发了。
所以整个需求的本质就变成了:在点击链路的某个位置,判断当前是否处于锁屏状态,如果处于锁屏状态则直接消费掉这次点击,不继续传递。
3. 禁用通知点击的三种实现方案与对比
顺着上面对链路的分析,实际改造可以选三个地方动手,各有取舍。
3.1 方案一:在NotificationClicker中屏蔽锁屏点击
这个方案最直观,直接找NotificationClicker.java的onClick方法,在方法开头插入锁屏状态判断,如果当前是锁屏就return。
优点很明显:改动集中、逻辑清晰、不影响其他触摸事件,锁屏下拉刷新手势、通知滑动删除这些都不受影响。因为你只是禁用了“点击后启动应用”这个动作,列表本身的滑动和手势照常。
缺点也有:如果某些场景你连“通知行本身的按压反馈”都不想要,比如点击时的高亮水波纹还在,那这个方案没法完全消除视觉反馈,用户还是能看到自己按了一下,只是没有后续跳转。另外,它依赖点击链路的现有结构,如果后续Android版本重构了点击分发逻辑,需要重新适配。
3.2 方案二:在布局视图中动态关闭可点击属性
第二个思路是在锁屏状态下遍历通知列表,把所有ExpandableNotificationRow的可点击属性关掉,等解锁后再恢复。具体可以在KeyguardNotificationContainer的设置通知列表时,或者在NotificationStackScrollLayout中遍历子项调用setClickable(false)。
这个方案的好处是视觉和事件一起禁用,用户点上去不会出现按压高亮,也不会触发任何点击回调。
但问题也明显:首先,你要保证通知列表在锁屏和解锁状态之间切换时能正确地恢复点击能力,这就引入了一个状态同步问题,做不好会导致解锁后通知点不了。其次,通知是动态增删的,新来的通知行可能没有及时设置setClickable(false),会出现漏网之鱼。所以如果要用这个方案,必须在NotificationListController之类的监听器里对通知增删做完整处理,代码量反而更大。
3.3 方案三:在事件分发层面拦截锁屏触摸事件
第三个方案是在NotificationPanelView或KeyguardNotificationContainer的onInterceptTouchEvent里做拦截,锁屏状态下直接拦截所有触摸事件,不让它往下分发。
这个方案力度最大,暴力且彻底,不管点击目标是通知行、快捷设置还是其他组件,统统接收不到触摸。
但力度大同时意味着风险高。NotificationPanelView的触摸事件还承担着下拉展开面板、滑动关闭通知等任务,一旦不分青红皂白地全拦截,锁屏页面可能就真的“锁死”了。想保留下拉手势就必须对事件类型做精细判断,比如根据坐标判断手指落在通知区域还是面板其他区域,或者根据事件动作判断是DOWN、MOVE还是FLING。实际调起来比较费时间,而且每个设备屏幕尺寸不同,坐标判断的兼容性也要单独测。
3.4 方案对比与选型建议
我把三个方案的关键差异整理成了一张表,方便对照。
| 对比维度 | 方案一:NotificationClicker拦截 | 方案二:视图属性关闭 | 方案三:事件分发拦截 |
|---|---|---|---|
| 修改范围 | 单个文件,改动极小 | 需要遍历列表和监听通知增删 | 手势判断逻辑复杂 |
| 影响面 | 仅影响“点击跳转”动作 | 影响可点击状态,需注意恢复 | 影响所有触摸事件 |
| 锁屏下拉手势 | 不受影响 | 不受影响 | 可能受影响,需额外判断 |
| 点击按压反馈 | 仍然存在 | 完全消除 | 完全消除 |
| 实现复杂度 | 低 | 中 | 高 |
| 推荐程度 | 非常推荐 | 备选 | 不太推荐 |
从我实际做定制的经验看,大部分需求用方案一就够了。因为它最贴合“禁止点击生效”这个动作语义,改动面最小,回归测试范围也最容易控制。方案二适合那些连按压缩放反馈都要求去掉的特殊定制的需求,但要把状态同步做好。方案三我一般只在系统UI整个锁屏交互都要重做的模块里才会考虑,普通需求用它属于杀鸡用牛刀,还容易误伤。
4. 实操实现:基于方案一的完整改造过程
4.1 改造前的源码准备与编译环境
先说环境。AOSP源码编译我建议用Ubuntu 18.04或20.04的64位系统,内存至少16GB,磁盘剩余空间至少200GB,让出8核以上的CPU给编译任务会舒服很多。Android 10.0对应的源码分支一般是android-10.0.0_r1或更新的android-10.0.0_r*,具体看你们项目基线基于哪个tag。
拉取源码后先编译一次完整镜像,确保环境没问题,再开始改代码。第一次编译时间比较久,但这一步千万别省,不然你改了代码都不知道是自己环境坏了还是改动坏了。
我这里以android-10.0.0_r1为例,后续的源码路径和类名都基于这个版本。
4.2 核心代码改动细节
打开NotificationClicker.java,找到onClick方法。在Android 10.0的源码里,这个方法开头大概是这样的:
java复制public void onClick(View v) {
if (mStatusBar == null) {
return;
}
...
}
我们要做的就是在进入真正的点击处理逻辑之前,插入一个锁屏状态判断。这里可以用KeyguardStateController来判断当前是否处于锁屏显示状态。
java复制public void onClick(View v) {
if (mStatusBar == null) {
return;
}
// 新增:锁屏状态禁止点击通知
if (mKeyguardStateController != null && mKeyguardStateController.isKeyguardShowing()) {
v.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY);
return;
}
...
}
关键是怎么拿到mKeyguardStateController。在Android 10.0的NotificationClicker类里,StatusBar通过构造方法传入,所以我们可以通过mStatusBar向外暴露的接口来获取KeyguardStateController。查看源码会发现StatusBar本身实现了KeyguardStateController.Callback,而且其内部有mKeyguardStateController字段。
更稳妥的做法是给NotificationClicker类增加一个KeyguardStateController字段,并在StatusBar里构造这个点击器时,把已有的KeyguardStateController实例传进去。具体改动是这样:
在NotificationClicker.java中加入字段:
java复制private final KeyguardStateController mKeyguardStateController;
public NotificationClicker(StatusBar statusBar,
NotificationClickLogger logger,
KeyguardStateController keyguardStateController,
HeadlessNotificationClickListener headlessNotificationClickListener) {
...
mKeyguardStateController = keyguardStateController;
...
}
然后在onClick开头使用它。
这里有个细节要特别提醒:isKeyguardShowing()在以下情况会返回true——锁屏界面正显示、过渡动画还没结束、或者在锁屏上弹出了某些系统窗口。这正好覆盖了我们的需求范围,但如果你的定制场景里存在“锁屏状态但希望某些特殊通知可点击”的需求,就需要在判断条件里额外加排除逻辑。比如允许系统级紧急通知点击:
java复制if (mKeyguardStateController != null
&& mKeyguardStateController.isKeyguardShowing()
&& !isEmergencyNotification(v)) {
return;
}
实际项目里常用的排除场景包括:闹钟提醒、来电通知、紧急警报。这些通知本身带有setFullScreenIntent或者较高的优先级,锁屏下往往需要立即响应,一刀切禁掉反而出问题。
4.3 配合改动的辅助处理
方案一其实改完NotificationClicker就够了,但为了让行为更完整,我建议顺手在StatusBar.java里把锁屏状态下通知点击的实际启动入口也加固一下。
找StatusBar里处理通知点击的方法,通常是onNotificationClick,在方法开头加同样的判断:
java复制private void onNotificationClick(String key, NotificationEntry entry) {
if (mKeyguardStateController.isKeyguardShowing()) {
return;
}
...
}
为什么主判断做完还要再来一层?因为NotificationClicker只是点击链路的其中一环,某些特殊通知类型可能会绕过它直接走StatusBar的启动逻辑。加上这层保险,能确保锁屏状态下无论从哪个入口触发,通知都启动不了。
另外还有个容易被忽略的地方:锁屏通知项的setOnClickListener在ExpandableNotificationRow里也有注册。虽然NotificationClicker已经把事件消费了,但在某些系统界面进程重启后、状态未完全恢复的瞬间,点击事件可能直接走到行控件默认的点击监听。稳妥起见,我在KeyguardNotificationContainer里对锁屏状态下的通知行做了二次确认,禁止重新绑定点击监听。这个不算必需,但做定制久了你就知道,多一层防护少一个夜半告警。
4.4 编译与烧录验证
代码改完后,编译SystemUI模块就行,不用整机编译。在AOSP根目录执行:
bash复制source build/envsetup.sh
lunch <你的产品名>-userdebug
make SystemUI -j16
编译完成后,产物在out/target/product/<产品名>/system/priv-app/SystemUIGoogle/或者.../SystemUI/下,具体看产品配置是用原生SystemUI还是加Google定制版。
验证方式有两种。第一种是整机烧录,适合分配给测试团队做完整回归。第二种是快速替换,适合自己开发调试验证:
bash复制adb root
adb remount
adb push out/target/product/<产品名>/system/priv-app/SystemUI/SystemUI.apk /system/priv-app/SystemUI/
adb reboot
这里提醒一句,SystemUI是系统核心进程,替换后系统UI会重启,这是正常的。如果adb remount失败,看看adb root有没有执行,还是不行就检查是否有dm-verity限制,可能需要adb disable-verity后重启再remount。
验证点注意看三点:锁屏界面通知是否正常展示;锁屏界面点击通知是否有按压反馈但没有跳转;解锁后通知是否恢复可点击。如果这三项都正常,基本就收工了。
5. 常见问题与排查技巧实录
5.1 问题一:通知仍然可以点击弹出详情
这个是最常见的。改完NotificationClicker后有人发现锁屏下点击通知,虽然没有跳转App,但通知项仍然展开了气泡详情面板。
原因在于通知的展开逻辑不一定走NotificationClicker。在Android 10里,点击通知行如果检测到该通知带有展开内容,ExpandableNotificationRow会直接展开详情视图,这个请求在进入NotificationClicker之前就被消费了。
解决方法是把判断往前推进一层,在ExpandableNotificationRow的点击事件分发里做处理。具体在NotificationStackScrollLayout中为每个ExpandableNotificationRow设置点击监听时,增加一个锁屏判断包装。或者更简单,在KeyguardNotificationContainer的onFinishInflate之后遍历通知行设置setClickable(false)。但遍历要注意动态增删,所以我一般在NotificationClicker之上再加一个层级的判断,也就是StatusBar的通知点击入口拦截,这样展开逻辑也不会生效。
5.2 问题二:锁屏界面无法下拉打开通知面板
方案一理论上不应该影响手势,但如果你同时在NotificationPanelView里动过onInterceptTouchEvent,就很容易把下拉手势一起干废。
我自己就踩过这个坑。当时为了禁用锁屏下所有点击事件,直接在NotificationPanelView的onInterceptTouchEvent里写了锁屏返回true的代码,结果状态栏怎么都拉不下来。后来把判断改成只拦截坐标落在通知行区域内的ACTION_DOWN事件,并且对ACTION_MOVE和ACTION_UP放行,问题才解决。
经验教训就是:不要图省事拦截整个面板的触摸。Android的手势判断是基于一系列事件流的,你拦了ACTION_DOWN,后续的ACTION_MOVE就算放行,系统的手势识别也已经乱了。
5.3 问题三:解锁后通知点不了
凡是遇到“改完锁屏,解锁后也点不了”的情况,大概率是你在视图属性或者事件拦截上做了“永久性”修改,而不是跟着状态切换的“条件性”修改。
用方案二(setClickable(false))最容易出这个问题。解锁时虽然KeyguardStateController.isKeyguardShowing()已经变成false,但之前给通知行设置的setClickable(false)还在,系统不会自动帮你改回去。
解决办法是在状态切换的回调里重新刷新通知列表。NotificationPanelView有setKeyguardStatusBar或onStateChanged这类回调,在锁屏消失时调用NotificationStackScrollLayout的updateNotificationViews方法重新绑定点击状态。如果你用了我的方案一,正常不会出这个问题,因为NotificationClicker每次点击都会实时判断锁屏状态,不存在状态残留。
5.4 问题四:第三方桌面锁屏点击仍然能拉起应用
市面上很多设备用的是第三方桌面应用自带的锁屏,或者厂商自己魔改的Launcher锁屏。这种锁屏根本不在SystemUI的KeyguardNotificationContainer里展示,通知点击逻辑也由桌面App自己处理,SystemUI层面的改动完全覆盖不到。
这属于边界问题。判断需求是仅针对SystemUI原生的锁屏,还是所有系统场景的锁屏。如果客户要求后者,那方案就变了,要么限制第三方桌面锁屏的使用(配置DevicePolicyManager锁定为系统锁屏),要么直接跟第三方桌面厂商协作做定制。这个不在本文的SystemUI方案范围内,但提出来是希望大家先确认需求边界再动手,别改了半天发现需求都对不上。
5.5 问题五:通知点击水波纹还在,觉得不够“禁用”
方案一保留按压反馈,这是正常的。因为点击事件虽然被逻辑拦截了,但视图仍然会响应触摸的按下动作。
如果产品上要求完全无反馈,我建议不要试图在NotificationClicker里消除反馈,直接在锁屏状态下禁止行视图进入pressed状态。具体做法可以重写ExpandableNotificationRow的setPressed方法,在锁屏状态下不调用父类方法。但这个改动比较大,要评估是否值得,毕竟大多数客户并不会在意水波纹这个细节。
我个人遇到过真有客户提这个需求的场景,那是一台医疗设备,护士在锁屏上操作时误触通知的视觉反馈会干扰读取数据,所以最后是重写了按压处理。但一般设备,我觉得方案一已经足够。
写在最后的一点经验
这个需求看起来一句话能说完,但真正在SystemUI源码里走一圈之后,你会发现它牵涉到事件分发、状态管理、视图生命周期好几层东西。我最开始图省事直接在KeyguardNotificationContainer里加点击屏蔽,结果发现根本不生效,后来顺着日志一层层追到NotificationClicker才明白怎么回事。
所以我给初次做这类定制的朋友一个建议:遇到SystemUI的交互需求,先别急着写代码,先花时间把点击链路的日志打出来,理清楚事件到底从哪个类经过、最终由哪个方法消费,再决定改哪里。这个前置工作花的时间,通常比盲目改错再回滚的时间少得多。
锁屏通知禁止点击只是SystemUI交互定制中很基础的一个点。类似的还有锁屏禁止下拉、锁屏隐藏通知内容、锁屏禁止快捷设置开关等等,本质上都是围绕StatusBarState和KeyguardStateController做状态判断。把这条思路摸透了,后面再做任何锁屏交互定制,你都能快速定位到正确的改造层,少走弯路。
