Android 10 的通知体验和 8、9 差别很大,通知渠道(NotificationChannel)、通知分组、免打扰策略在 10 上已经趋于成熟,但真正让我差点被逼疯的,不是怎么调 API,而是系统侧那个负责所有通知"生杀大权"的服务——NotificationManagerService,业内直呼 NMS。App 发了一条通知,状态栏没影;同一个应用,用户把通知渠道调成了静默,系统却还是弹出了声音;后台进程明明没死,通知就是排到了最后……这些奇奇怪怪的现场,几乎都能在 NMS 这一层找到解释。
如果你跟我一样,平时会翻 framework 源码、做 ROM 定制、优化系统通知体验,或者单纯只是想搞清楚"我的应用通知为什么总被系统收拾",这篇分析就是为你准备的。下面我把 Android 10 上 NMS 的判断逻辑、数据结构、排名规则、免打扰限制这些核心内容,按我实际调试时的思路逐层拆开。
1. NMS在Android 10中的定位与启动流程
1.1 先搞清楚NMS到底是个啥
很多人会把 NotificationManager、NotificationManagerService、NotificationListenerService 三个概念搞混。我刚开始看源码时也绕了一大圈。简单说:App 层调用的 NotificationManager 是一个客户端代理,内部持有 Binder 对象指向系统进程里的 INotificationManager 接口,真正干活的实现就是 NotificationManagerService。意思就是,通知从应用进程到系统进程之间,走的是跨进程的 Binder 调用,而 NMS 就是那个坐在系统进程里的"大管家",负责接收、验证、排序、存储、分发、展示、提醒……几乎所有跟通知生命周期有关的事。
而 NotificationListenerService 是监听器,像系统 UI、手环厂商、第三方通知管理 App,都是通过它来订阅通知事件流。这不改变 NMS 的核心行为,但如果你在定制 ROM,经常会跟它打交道。这一章必须先把"谁负责什么"理清楚,后面分析代码时才不会被各种类名绕晕。
顺带说一句,Android 10 的 framework 里已经把很多"服务"下沉到 SystemServiceManager 统一管理,NMS 不再是孤立的一个类,它还和 ActivityManagerService、AppOpsManager、PowerManagerService 等有大量交互。真正排查问题时,往往需要跨服务联动分析,这也是为什么我后面会反复提到 dumpsys 和各种系统日志。
1.2 从SystemServer看它的出生
在 Android 10 中,NMS 是在 SystemServer 的 startOtherServices() 里被创建并注册的。代码大致是这样:
// frameworks/base/services/java/com/android/server/SystemServer.java private void startOtherServices() { ... if (!disableSystem) { ... if (!disableNotificationManager) { traceBeginAndSlog("StartNotificationManager"); mSystemServiceManager.startService(NotificationManagerService.class); traceEnd(); } ... } }注意它是通过 SystemServiceManager.startService() 这样一条标准的系统服务启动路径进来的,所以会走 SystemService 的标准生命周期:构造、onStart、onBootPhase。实际点说,NMS 的构造函数里会创建自己专用的 HandlerThread 和 Worker 线程,把很重的 IO 或定时操作(比如清理过期通知、重新排名)放在后台线程里跑,不会阻塞 system_server 的主线程。
我当时跟项目时,最有印象的是 NMS 构造时会初始化几个硬件相关的 Light,也就是那颗通知呼吸灯和 AttentionLight。很多板子根本不带呼吸灯,但还是会注册,代码里也有不少条件判断。你在移植 AOSP 到非标准设备时,这些灯相关代码经常要仔细排查,否则明明没有灯设备,系统还在那里循环驱动,极个别情况会引起休眠异常。
1.3 多Handler线程模型
NMS 内部有 mHandler、mWorker、mRankingHandler 好几条线索。有必要说下它为什么这么设计。
消息队列是异步的,如果所有回调都压在 system_server 主线程,一旦有应用频繁发通知,或者通知排名需要频繁计算,主线程很容易出现卡顿甚至 ANR。Android 10 的 framework 里已经开始强调"不要让服务主线程替 App 干活",NMS 的 mRankingHandler 专门负责通知排序,mWorker 处理和通知服务相关的后台任务,比如清理无用的 NotificationRecord、处理延迟操作等。
看代码的时候有一条关键经验:你如果想追踪一条通知从发出到展示在状态栏的完整链路,不能只盯一个类,要把 enqueue → handleRankingReconsideration → notifyPostedLocked → maybeSendForegroundNotificationRemoved 这条链路串起来。我最早排查"通知有时候弹出来、有时候不弹"的 bug 时,就是没注意 mWorker 和 mHandler 之间的调度关系,导致顺序压根对不上。
2. 通知生命周期:从 notify() 到状态栏
2.1 enqueue 的那一瞬间
App 里最常见的写法是:
NotificationManager nm = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); nm.notify("test_tag", 1001, builder.build());这一行最终会走 Binder 到 system_server,进入 NMS 的 enqueueNotificationWithTag()。Android 10 源码里,这个方法做了非常多前置检查:包名是否允许、uid 是否匹配、通知是否已过时、是否处于免打扰模式、App 是否被冻结、渠道是否存在…… 一系列检查通过后,才会真正把这条通知包装成 NotificationRecord 放进 mNotifications 这个以"包名:id:tag"为 key 的 ArrayMap 里。
记得当时有个线上 case,某应用在前台发通知没问题,切后台以后立刻不弹,查了半天是 App 进程被系统冻结,导致 PendingIntent 无法启动 Activity。这里 NMS 里有一个 mAppOps 相关权限检查逻辑,还有一整套包冻结判断,初学者很容易忽略。
2.2 NotificationRecord 的核心字段
很多人在读 NMS 时,最困惑的就是往 mNotifications 里加各种包装类。告诉你我实际调试时最常看的几个字段:
- pkg:哪个应用发的通知
- uid:调用方的 uid,用来判断权限归属
- notification:原始的 Notification 对象,里面基本全是你 App 的设置
- channel:对应的渠道对象,重要性、震动声音都在这
- score:排名分数,代表了这条通知在当前排名体系里的权重
- sortKey:排序 key,决定状态栏里的排布
如果你用adb shell dumpsys notification看到一堆 NotificationRecord,第一眼找不到这些字段很正常,因为 dump 输出里是压缩格式,需要对着源码看字段名。字段太多的时候我一般直接 grep 出目标那一条,然后逐字段对着源码注释看,比人脑硬记靠谱得多。
2.3 渠道(Channel)在Android 10的重要性
Android 8.0 已经引入了 NotificationChannel,但到 Android 10 才被真正当作"基础设施"在用,原因在于排名、免打扰、通知分组这些都是围绕渠道在做策略。渠道的重要性(IMPORTANCE_HIGH、DEFAULT、LOW、MIN、NONE)几乎决定了通知的表现形式。
有个坑必须提醒:用户一旦修改了渠道重要性,App 侧后续调用 createNotificationChannel 不能静默覆盖回默认值,代码里会有比较逻辑,如果新值比用户设置低,系统会忽略你的修改。这就是为什么很多 App 在升级后想"偷偷调回默认重要性"根本不生效。我见过不少开发在这里反复确认"为什么我改了 channel 代码,线上用户还是旧行为",根因就在这里。
2.4 分组的逻辑
通知分组(NotificationGroup)在 Android 10 也有演进。一条通知可以通过 setGroup(groupKey) 归组,组摘要 setGroupSummary(true) 可以让系统在状态栏用一条汇总代替多条子通知。这里实际调试时的难点是:如果你设置了 groupKey 但没有正确设置 participants/children,排序经常会出现"摘要反复跳动"的 bug。厂商 ROM 在这个问题上更是千奇百怪。
特别是用了前台服务通知(Foreground Service Notification)时,组摘要的表现很容易被误判。我曾遇到一个 case:音乐类应用播放时不断更新进度,结果状态栏里组摘要和进度通知来回抢位置,看着就像在闪烁。最后定位到是 groupKey 在多次 update 时发生了不一致,系统把它当成两组通知在处理。
3. 排名系统与展示顺序
3.1 RankingHelper 的职责
Android 10 里有一个 RankingHelper,负责计算每条通知的分数和 rank。它内部维护了一个 NotificationComparator,这套排序逻辑不是单纯的"按时间正序",而是综合了渠道重要性、用户对应用的直接反馈、是否被归类为"提升"(promoted)等因子。
大概的流程是:每次有通知增删改、或者渠道重要性变化时,NMS 会调用 mRankingHandler.requestReRank(),然后 RankingHelper 重新计算所有 NotificationRecord 的 score,并按序排好,最后通过 notifyRankingUpdateLocked() 把变更通知给监听器。
如果你在自定义 ROM 或者做桌面级通知 UI,可以直接在 NotificationComparator 里改排序权重,但要注意 Android 10 加了几个保护逻辑,改得不好可能导致状态栏通知重复跳位。之前我试着把"媒体通知"的权重调高,结果音乐应用的通知几乎永远置顶,用户打电话过来投诉说看不到消息通知,这就说明权重不能拍脑袋改。
3.2 排名分怎么算出来的
这部分我不贴大段代码,挑重点说。每条通知有一个 rankingScore 和 rankingTimeMs,前者表示业务上的排名分数,后者用于时间降序的"兜底"。在实际展示时,NMS 先把 score 大的放前面,score 相同再用时间,时间相同再比 sortKey。
有同学问:为什么我设置了高重要性的渠道,通知还是排在很后面?大概率是你的 App 发通知时用了同一个 tag 反复更新,系统会认为这是"同一条通知的更新",而不是新的通知,从而降低了它在用户注意力上的权重。这种细节,文档上找不到,全是排查时一条条比对日志看出来的。
在 Android 10 的源码里,还有一个相关性判断:如果通知类别设置成 CATEGORY_PROGRESS,系统会认为它属于进度型通知,排名权重也会自动降一档。你不设置正确的 category,不仅影响排名,还可能在定制 UI 上出现奇怪的动画或图标行为。
4. 免打扰、电源与权限的限制
4.1 ZenMode 免打扰在 Android 10 的策略
Android 10 的免打扰(Do Not Disturb)被分成了三个模式:完全关闭、仅闹钟、全部禁止。系统通过 ZenModeHelper 来管理状态,并且有一套复杂的规则引擎,包括时间计划、事件规则、例外联系人等。
免打扰模式对通知的影响非常直接:在"全部禁止"时,非例外渠道的通知会被压制,并且状态栏会有特殊的提示图形。NMS 里会调用 isNotificationAllowedInZenMode() 来判断这条通知是否被允许弹出。这里最容易忽略的一点是:音量和振动有时候会被静音,但通知仍然进入状态栏,除非你去查看通知流日志,否则很难察觉。
我自己在复现问题的时候,习惯性先看当前系统是不是处于免打扰,因为十次有八次,用户说"通知不响了",最后都是免打扰或者音量问题背锅。别一上来就翻源码逻辑,先确认环境状态。
4.2 与电源相关的限制
Android 10 对后台通知的管控明显加强了。像 App Standby Buckets、电池优化白名单、进程冻结这些机制会间接影响通知的到达率。NMS 里有专门针对"非活跃应用"的通知隔离逻辑,如果一个应用长时间未被用户使用,系统会将其通知归类为低优先级,甚至直接丢弃。
这是很多人反应"为什么我的 App 后台收不到推送"的重灾区,很多时候问题不在推送通道,而在系统侧的通知限制策略。某开发者的应用做成了 Todo 类,用户下载后几天没打开,后台通知就常常丢,这就是被系统分进了 Standy Buckets 的 Restricted 桶。优化方向是引导用户调高应用的后台优先级,或者在合适的时机申请电池优化白名单,而不是在推送通道层面反复折腾。
4.3 权限那块:POST_NOTIFICATIONS 还没入运行时
这里要特别提醒:Android 10 还没有把通知权限变成运行时权限,也就是说你不能像申请定位那样向用户申请通知权限。系统默认所有应用都具备发通知的能力,但用户可以通过系统设置关闭某个应用的"允许通知"。NMS 里是通过 checkNotificationPermission 等逻辑判断应用是否有权限。
这跟 Android 13 以后差别很大。如果你在做手机卫士、通知管理类应用,要注意这些差异,尽早把目标平台和要求调整好。Android 10 时代很多厂商还会默认把"推送"和"通知权限"打包到自带管家应用里,第三方应用只能在用户手动操作后才能正常弹出通知,这类环境差异在你做兼容验证时特别恼人。
5. 实操调试方法与关键经验
5.1 dumpsys notification 的正确姿势
我最常用的调试方式,是在每次复现场景后执行adb shell dumpsys notification | tee /tmp/notification_dump.txt,然后用文本编辑器搜索包名或通知 id。输出里的 NotificationRecord 行会告诉你这条通知当前存储在 NMS 里还是已经被移除,以及它的 rankingScore、channel 的 importance、flags 等。
如果记录还在但状态栏不显示,多半是 UI 侧的问题或者渠道重要性为 NONE;如果记录都没了,说明通知在 NMS 层就被拦截了,重点观察"mDisabledNotifications"、"mEnqueuedToClearNotificationAlerts"等字段。
我一般还会把 dumpsys 输出里相关的包名行单独截出来,做成一个小的速查表格,方便在不同版本之间对比。很多 bug 是厂商基于 AOSP 改了策略之后才出现的,别人测不出来,就是因为原厂没有那些策略差异。
5.2 日志观察技巧
在 Android 10 上,系统日志里会出现大量与通知服务有关的输出,关键是过滤:
adb logcat -s NotificationManagerService:V NotificationService:V NotificationListeners:V我一般还会加上ActivityManager:I来确认应用进程状态。如果通知确实发到了 NMS,但状态栏没弹,大概率在 UI 一侧;反过来如果日志里根本没有 NMS 的 enqueue 记录,则问题在前面链路,就要去查 Binder 或应用进程死没死。
有一点要提醒:日志太多的时候不要只看 Event 标签,要结合 pid 和 uid 一起看。有些厂商定制 ROM 会把通知相关日志打得很碎,单纯搜包名可能搜不到,需要搜 uid 或者 notification id,这是实操经验,不是理论推导。
5.3 断点调试 NMS
源码调试更适合做系统开发的人。我用 IDE 或者直接改 AOSP 源码添加 Log 的方式比较多。推荐在 enqueueNotificationInternal() 的入口、addNotification() 输出相关日志时加断点,沿着调用栈就能看到从 Binder 到 ranking 的完整路径。
如果你是在模拟器里调试,直接把源码替换后编出 userdebug 包,速度会快很多。如果是真实设备,记得保留 dumpsys 和 logcat 的权限,否则你改了代码也看不到运行日志。这类调试需要一定耐心,因为 system_server 进程一旦挂了会直接重启,断点不能在关键锁住的方法上停留太久。
5.4 工具组合,事半功倍
- dumpsys notification 是主力;
- logcat 过滤更快的定位;
- systrace 用于看 UI 渲染卡顿;
- binder 抓取 Binder 调用耗时。
这个组合用下来,绝大多数通知相关疑难杂症都能搞定。我特别想强调 binder 抓取这一步,很多人不知道adb shell bd可以查看当前 Binder 调用状态。当通知发送出现明显延迟时,这个工具能帮你判断是 Binder 排队太久,还是 NMS 内部某个锁在阻塞。
6. 常见问题与排查技巧实录
6.1 我发了一条通知,状态栏没反应
把上面的排查顺序背下来:先查渠道、再查权限、再查进程、最后看 NMS 日志。大概率不是 NMS 本身的问题,而是以下几个之一:
- 渠道重要性被用户调成了 NONE;
- 应用被用户关掉了"允许通知";
- 应用被系统冻结或处于低电耗模式;
- 通知被免打扰拦截。
下面是一个我整理过的速查表,实际定位问题时帮助很大:
| 现象 | 检查点 | 常见根因 |
|---|---|---|
| 状态栏完全不显示 | 渠道重要性、应用通知权限 | 渠道为 NONE 或权限被关 |
| 有通知记录但不提示音 | 渠道声音设置、DEFAULT_ALL 覆盖 | setDefaults 覆盖了渠道配置 |
| 通知排在很后面 | rankingScore、tag 重复更新 | 同 tag 高频更新导致权重下降 |
| 组摘要反复跳动 | groupKey、participants | groupKey 不一致 |
| 后台通知丢失 | App Standby、Doze、电池优化 | 系统后台限制策略 |
6.2 通知有声音,但渠道明明设了静音
反过来也有。渠道设的是 IMPORTANCE_LOW,但实际却出声了,通常是因为 App 调用 notify 时用了 setDefaults(Notification.DEFAULT_ALL),这会覆盖渠道中的声音设置。Android 10 上依然是这个逻辑,要小心。
我在给某个即时通讯应用做适配时遇到过:用户已经在系统设置里把渠道改成静音,但来消息还是响,就是因为 App 在 code 里强制 setDefaults。其实用户侧看到的"静音"只是渠道重要性,声音和震动是否跟随 DEFAULT_ALL 要看系统实现,不同厂商处理还不完全一样。
6.3 分组摘要一直闪跳
组内通知太多时,摘要会被反复重排。建议仔细检查 groupKey 的值是否一致,尤其是使用动态前台服务通知时,如果摘要和子通知的 groupKey 没有保持稳定,系统可能时不时把它们拆成多个组。
我的做法是:把 groupKey 固定写死成一个常量,不要用时间戳或随机数拼接,除非你真的想每次更新都创建新组。很多"通知闪跳"其实不是 NMS 的问题,而是数据源侧 key 不稳定造成的,改完 key 就安静了。
6.4 通知总是排到最后
重点看 rankingScore 和 rankingTimeMs。App 如果用同一个 tag 频繁更新,系统会认为这是更新而不是新通知,权重自然低。
如果这条通知真的需要每次提醒用户,可以考虑换唯一的 tag,或者不要把同一个 tag 复用太多次。另一个风险点是:如果应用长期在后台被系统标记为"不活跃",那么即便你换了 tag,排名依然会被系统性压低。
6.5 修改 NMS 源码后的编译和测试问题
改动后需要编译 services 相关的模块,常在产物路径 services.jar,push 后最好做一次"冷启动",因为很多服务状态是在启动时初始化的。改动 RankingHelper 后,还要验证一下 dumpsys 里的排序是否符合预期。
另外提醒一句:改 system_server 相关代码,一定要保留 userdebug 或 eng 版本的现场,否则线上 devices 上你抓不到足够日志。我有一次只改了排序规则,结果在发布版本上出现崩溃,就是因为没做足够的并发验证。NMS 内部很多方法都加了 synchronized,改排序逻辑时稍不注意就会造成死锁或性能回退。
说到底,NMS 在 Android 10 里更像一个"分诊台"——它决定通知该不该出现、以什么形式出现、出现在什么位置。如果你想深入了解它,我的建议是别上来就啃整份源码,先把一条最简单的通知从 notify 到状态栏的链路走通,再逐步扩展。那些逻辑分支和边界情况,多是在实际设备、真实用户数据里才会冒出来。你可能会在某次排查后发现,真正出问题的地方往往不是你以为的渠道、浮层那个组件,而是被系统服务悄悄替换掉的 NotificationRecord 里的某个字段。这种体验,还是在反复踩坑之后才感受最深的。如果有机会,也建议你把这套分析思路迁移到 Android 新版本上看看,差异比想象中大。