简介:《SystemUI架构设计》是一份面向Android系统开发、ROM定制与系统UI模块维护人员的架构梳理型课件,帮助读者建立对SystemUI整体职责、模块边界与关键流程的系统认知。课件围绕SystemUI的定义与源码位置展开,依次讲解状态栏、通知、锁屏、最近任务、音量与电量UI、画中画、生物识别解锁等模块的划分方式,并配以结构图、类图和启动流程,便于理解模块间关系与类继承脉络;通知部分进一步覆盖通知位置、注册流程、从系统发送到SystemUI的链路及SystemUI内部处理流程。压缩包共1个pptx文件,约11.64MB,以图文幻灯片承载架构图与流程说明,适合按章节检索和对照学习。目前已有403人学习,可为SystemUI入门、模块定位与通知链路梳理提供较完整的参考框架。
1. SystemUI 的反直觉之处:一堆「系统界面」其实全是普通 APK
很多人第一次翻 Android 源码,看到frameworks/base/packages/SystemUI这个路径,下意识会觉得它跟 AMS、WMS 那种系统服务是一类东西——跑在 system_server 里,被 Zygote 特殊对待。真去adb shell ps看一眼会发现,它就是一个独立进程里的普通应用,有AndroidManifest.xml、有 Activity、有 Service,打成 apk 装着,出了问题也是走应用崩溃那一套。
这个认知差很关键。SystemUI 承担的是「系统级信息显示与交互」:锁屏、状态栏、导航栏、最近任务、通知面板、快捷设置、音量条、画中画,日常摸到手机的每一寸表面几乎都归它管。可它偏偏不是特权服务,而是一个体积巨大、模块极多、生命周期被系统反复拉扯的应用。搞懂这层「应用外壳 + 系统语义」的错位,才谈得上理解它为什么这么划分模块、为什么通知链路要绕一大圈、为什么改一行状态栏代码能把锁屏搞崩。下面按「它是什么 → 怎么切模块 → 怎么启动 → 通知怎么走」的顺序往下拆。
2. 模块划分:为什么一个大 APK 要拆成十几个互不认识的子模块
2.1 从「功能包罗万象」到「按需启动」的必然
SystemUI 的功能清单本身就决定了它不可能写成一个大 Activity。锁屏、状态栏、导航栏、最近任务、通知、快捷设置、音量、电量、铃声播放、画中画、生物识别弹窗,这些能力之间几乎不存在调用关系——状态栏不需要知道铃声怎么播,画中画不需要知道锁屏密码怎么校验。它们共享的只有两样东西:同一个进程,和同一套系统事件源。
于是设计上就走向了「每个子类一个功能模块」的路子。原文列出的九个模块,本质上就是九条独立的关注点:
| 模块类 | 承担的职责 | 触发/存活特征 |
|---|---|---|
| Status bars | 状态栏图标、时间、电量、信号展示 | 常驻,随配置变化重建 |
| NotificationChannels | 通知的接收、排序、展示 | 常驻,事件驱动 |
| KeyguardViewMediator | 锁屏的显示、解锁、遮挡关系 | 常驻,状态机驱动 |
| Recents | 最近任务面板 | 按需,进入时创建 |
| VolumeUI | 音量调节面板 | 按需,按键触发 |
| PowerUI | 低电量提示、充电状态 | 常驻,监听电池广播 |
| RingtonePlayer | 铃声播放 | 按需,随叫随起 |
| PipUI | 画中画窗口控制 | 按需,仅在画中画存在时 |
| BiometricDialogImpl | 指纹/人脸/虹膜的解锁弹窗 | 按需,认证时拉起 |
这张表真正想说明的是「按需启动」四个字的分量。SystemUI 进程是常驻的,但常驻不等于全量加载。一个刚开机还没插卡、没通知、没进过多任务的状态下,Recents 的视图树根本不存在,VolumeUI 也没被实例化。系统只在对应场景发生时,才通过一个统一的入口把模块拉起来。
常见的实现思路是:SystemUI 启动时先创建一个「总控」对象,由它持有一份模块注册表,每个模块实现同一套生命周期回调(启动、销毁、配置变更)。总控不关心模块内部干什么,只负责在合适的时机调用start()和destroy()。这套模式在 Android 源码里反复出现,本质是用一个薄壳换来了模块之间的完全解耦。
2.2 依赖注入在这里扮演的角色
模块之间要解耦,最直接的办法是别让它们互相new。SystemUI 里大量使用依赖注入框架来管理组件图,原因很实在:一个模块可能需要Context、需要主线程 Handler、需要某个系统服务的代理对象,如果每个模块自己去拿,测试没法做,替换实现也没法做。
依赖注入在这里解决的核心问题是「谁在什么时候把依赖塞进去」。启动阶段,总控把全局单例(上下文、线程调度器、各类 Manager)注册进组件图;某个模块被按需拉起时,框架负责把该模块声明的依赖解析出来注入。模块自己只写「我需要什么」,不写「我去哪拿」。
提示:分析这类代码时,先找组件图的定义处,再看模块的构造参数。依赖关系读懂了,模块边界基本就清楚了。
2.3 模块边界带来的真实代价
拆模块不是没有成本的。最典型的问题是跨模块通信。状态栏要显示「正在播放的音乐」,这个信息可能来自通知模块;锁屏要显示「有几条未读」,同样绕不开通知。如果两个模块直接互相持有引用,解耦就白做了。
实践中一般走两条路:一是通过系统级的广播、服务回调做单向数据流,模块只订阅不反查;二是把一个共享的「状态仓库」注入给双方,各自读自己需要的字段。前者延迟高但彻底解耦,后者实时性好但要求状态模型设计得足够干净。SystemUI 里两种都能见到,选择依据往往是「这个状态变化频率高不高」——电量、信号这种高频变化的,走仓库;解锁成功这种一次性事件,走回调。
理解这层取舍,比记住九个模块的名字有用得多。改需求时你面临的第一个问题永远是「这段逻辑该放哪个模块」,而答案取决于它和谁共享状态、以什么频率变化。
3. 启动流程:SystemUI 是怎么被系统「叫醒」并装配起来的
3.1 进程启动的触发点
SystemUI 不是用户点图标启动的,它由系统在开机流程的特定阶段拉起。触发链条大致是:系统服务启动完成后,需要一个组件来承载系统级 UI,于是通过组件管理服务启动 SystemUI 的入口组件;入口组件跑起来,进程才算真正存在。
这里有个容易忽略的细节:SystemUI 的入口往往不是一个可视界面,而是一个无界面的服务或应用组件。它存在的意义是「把进程撑起来并完成装配」,界面是后续按需挂上去的。这就是为什么你在一些设备上能看到 SystemUI 进程活着但屏幕上什么都没有——装配完成了,但没有任何模块处于可见状态。
启动时序可以粗略画成四步:
- 系统侧发起点名,创建 SystemUI 进程;
- 入口组件执行,初始化全局上下文与组件图;
- 总控按配置决定哪些模块需要常驻启动;
- 各常驻模块完成自身初始化,向系统注册监听。
第 3 步是理解 SystemUI 行为的关键。哪些模块常驻,取决于设备形态和编译配置——手机、平板、车机、折叠屏,常驻集合都不一样。同一份源码在不同产品上表现差异巨大,根子往往在这里,而不是某个模块自己写了产品判断。
3.2 装配阶段的代码骨架
把上面的时序落成伪代码,大致是这么个形状。这段不是源码原文,是按这套架构最自然的实现方式写的等价结构,用来理解装配顺序:
// 入口组件:只负责把进程和全局环境准备好 public class SystemUIService extends Service { @Override public void onCreate() { super.onCreate(); // 1. 构建全局组件图,注册 Context、主线程调度器、系统服务代理 Dependency.init(getApplicationContext()); // 2. 交给总控,由它决定启哪些模块 mController = new SystemUIController(getApplicationContext()); mController.start(); } @Override public int onStartCommand(Intent intent, int flags, int startId) { // 系统可能多次点名,这里必须幂等,重复启动只做增量处理 mController.onSystemRequest(intent); return START_STICKY; } }onCreate只做两件事:建依赖图、启动总控。注意onStartCommand的幂等要求——系统在配置变更、进程重建等场景下可能重复点名,如果这里无脑再start()一遍,就会出现两套状态栏视图叠在一起这种经典翻车现场。合格的实现里一定有「已启动则跳过」的判断。
// 总控:持有模块注册表,负责生命周期调度 public class SystemUIController { private final Map<Class<?>, SystemUIModule> mModules = new LinkedHashMap<>(); public void start() { // 常驻模块:开机即起 startModule(StatusBarModule.class); startModule(KeyguardModule.class); startModule(NotificationModule.class); // 按需模块不在这里起,等事件触发 } void startModule(Class<?> clazz) { if (mModules.containsKey(clazz)) return; // 幂等保护 SystemUIModule module = (SystemUIModule) Dependency.create(clazz); module.start(); // 内部完成视图挂载与监听注册 mModules.put(clazz, module); } }这里有两个设计点值得琢磨。第一,用LinkedHashMap而不是HashMap,是因为模块的启动顺序可能影响监听注册的先后,顺序稳定能避免一批偶现的初始化竞态。第二,startModule里的重复判断放在创建之前,而不是创建之后再判断——避免构造过程本身产生的副作用被执行两次。
3.3 配置变更时的重建路径
屏幕旋转、深色模式切换、语言变更、分辨率调整,这些配置变更会让 SystemUI 的一部分视图需要重建。重建路径和首次启动路径不完全一样:首次启动是「从无到有」,重建是「先销毁再创建」,中间还夹着一个短暂的空窗期。
这个空窗期是很多诡异问题的来源。比如转屏瞬间状态栏闪一下白、锁屏短暂露出桌面,本质都是重建时序没处理好。常见做法是把「销毁旧视图」和「创建新视图」之间的时间压到最短,或者干脆复用容器、只替换内容。哪条路都行,唯一不能做的是在销毁回调里去做异步操作——那个回调返回之后,旧视图随时可能被系统回收。
4. 通知链路:一条通知从应用发出到画在屏幕上,中间经历了什么
4.1 链路全景与关键角色
通知是 SystemUI 里最值得拆的一条链路,因为它横跨了几乎整个系统:应用侧发通知,系统服务侧接收、排序、做权限和渠道校验,最后才轮到 SystemUI 侧渲染。理解这条链路,就理解了 SystemUI 与系统服务之间的边界在哪。
粗略分成三段:
- 生产段:应用构造通知、绑定渠道、调用发送接口;
- 中转段:系统通知服务接收,做渠道校验、优先级计算、分组、拦截规则判断,落库并广播变化;
- 消费段:SystemUI 收到变化通知,拉取当前通知集合,构建视图展示。
注意中转段的存在。SystemUI 从来不是「接收应用发来的通知」,而是「向系统服务查询当前应该展示哪些通知」。这个方向差异决定了 SystemUI 侧的通知模块必须是「拉模式 + 增量更新」,而不是被动接收一条条消息往里塞。
4.2 通知注册:渠道与监听者
通知注册有两层含义,原文里把这两层放在一起讲,实际得分开看。
第一层是应用侧的渠道注册。应用在发通知前必须先声明通知渠道(重要性、声音、震动、是否绕过免打扰),渠道一旦创建,属性就基本固定下来,后续只能由用户修改。这个设计的动机很明确:把「打扰强度」的控制权从应用手里收归用户。以前应用可以随意改自己通知的响铃行为,用户完全被动;渠道机制上线后,应用只能声明一次,之后用户说了算。
第二层是 SystemUI 侧的监听者注册。通知模块启动时,需要向系统通知服务注册一个监听回调,声明「我要接收通知集合的变化」。这个回调不是逐条推送,而是带节流的「集合已变更」信号。SystemUI 收到信号后,重新拉取全量列表做差分,决定新增哪些、移除哪些、更新哪些。
// 通知模块启动时的监听注册(结构等价写法) public class NotificationModule implements SystemUIModule { private NotificationListener mListener; @Override public void start() { mListener = new NotificationListener() { @Override public void onNotificationsChanged() { // 收到的是"变了"的信号,不是具体通知 // 必须回主线程,避免在 binder 线程操作视图 mHandler.post(() -> refreshAll()); } }; mService.registerListener(mListener); } private void refreshAll() { List<NotificationRecord> latest = mService.queryActive(); mAdapter.diff(mCurrent, latest); // 差分,不整体重建 mCurrent = latest; } }这段里有两个必须点出来的机制。其一,回调发生在 Binder 线程,直接碰视图必然崩,所以post到主线程是硬性要求,不是风格偏好。其二,diff而不是全量重建,是因为通知列表可能几十条,全量重建会导致列表滚动位置丢失、动画中断、正在展开的通知被强制收起——用户侧感知就是「通知面板老是自己弹回去」。
4.3 渲染阶段的分组与排序
拿到全量列表之后,展示前还有两步处理:分组和排序。
分组是把同一个应用的多个通知、或者同一会话的连续消息折叠成一个可展开的组。动机是控制信息密度——一个聊天应用连发二十条,不折叠的话整个面板就废了。分组的依据一般包括应用标识、会话标识、以及应用自己声明的分组键。
排序则是优先级计算,参与因素包括通知的重要性等级、时间戳、是否常驻、是否正在播放媒体等。这里有个容易踩的坑:排序不能每次刷新都重算一遍,否则一条新通知到达会让整个列表跳动。常见做法是把排序结果做稳定化处理,新通知插到它应该待的位置,已有的相对顺序尽量不动。
注意:调试通知展示问题时,先确认是「系统服务侧没给」还是「SystemUI 侧没渲染」。两者的排查路径完全不同,前者看渠道配置和拦截规则,后者看监听是否注册成功、差分逻辑是否漏项。
4.4 通知与锁屏的交叉点
通知链路还有一个绕不开的交叉点:锁屏。锁屏上要不要显示通知内容、显示多少、敏感内容是否打码,这些判断发生在 SystemUI 内部,但依据的配置来自系统设置和每个渠道的可见性声明。
这意味着通知模块和锁屏模块必须共享一部分状态,又不能互相强依赖。实践中常见的处理是:通知模块只管「有哪些通知、内容是什么」,锁屏模块自己决定「按当前解锁状态该展示成什么样」。职责切在「数据」和「呈现策略」之间,而不是切在「锁屏通知」和「面板通知」之间。这个切法让两边的改动互不牵连——改锁屏的隐私策略不需要动通知的拉取逻辑。
5. 避坑与排查:改 SystemUI 时最容易翻车的几处
5.1 现象:状态栏出现两份,点哪个都没反应
原因几乎总是重复启动。系统在进程重建、配置变更、或者某些系统服务重启后会再次点名入口组件,如果start()路径没有幂等保护,就会创建第二套视图树挂在窗口上。上面那份视图覆盖了下面那份,但触摸事件的分发目标还是旧的那套,于是表现成「看得见、点不动」。
解决思路是把幂等做成硬约束:模块注册表既是缓存也是开关,startModule入口先查表,命中直接返回。同时入口组件的重复点名要走增量处理分支,不复用首次启动的完整路径。
5.2 现象:转屏后锁屏短暂露出桌面内容
这是重建时序问题。配置变更触发销毁旧视图、创建新视图,中间存在一个窗口期,此时锁屏窗口已经移除但新窗口还没盖上,底下的桌面就露出来了。转屏越快、机器越慢,这个空窗越明显。
排查方法是打时间戳,把「旧视图销毁」「新视图 attach 到窗口」「窗口可见性变更」三个点串起来看,找出哪个环节拖长了。解决一般是调整销毁顺序——先让新视图准备好再拆旧的,或者给窗口加一层过渡遮罩顶到新视图就位。
5.3 现象:通知列表滚动位置在收到新消息后跳回顶部
差分逻辑写成了全量重建。每次集合变化都清空适配器重新灌数据,列表自然回到顶部。更隐蔽的情况是差分做了,但用的标识不稳定——同一条通知每次拉取拿到的键不一样,差分就认为「全部删除 + 全部新增」。
排查时打印两轮的标识集合做对比,如果同一批通知的键在两次拉取间不一致,问题在标识生成;如果键一致但仍在重建,问题在差分的应用方式。修法是保证标识来自通知本身的稳定字段,而不是列表下标或对象哈希。
5.4 现象:低电量提示弹出后不消失,插上充电器也没用
PowerUI 这类模块依赖电池状态广播驱动,状态机如果少写了一个「退出条件」,就会卡在某个状态出不来。典型漏项是只处理了「电量低」的进入,没处理「开始充电」的退出。
排查方式很朴素:把模块收到的每一条状态变更都打日志,对照状态机的转移表看哪条边缺失。这类问题的血泪经验是,状态机一定要把「异常退出路径」当成正常路径来写,而不是等用户反馈了再补。
5.5 现象:某个模块在某些设备上从不启动
先别怀疑模块代码。按第 3 章讲的,常驻模块集合由配置决定,不同产品形态的配置不一样。某些模块在特定形态上压根没被注册进启动列表,代码写得再对也不会跑。
确认方法是去总控的启动列表里搜这个模块的类名,看它是否在常驻集合中、是否挂在某个条件分支下。这一步能省掉大量「在模块内部找 bug」的无用功。
6. 设计取舍:为什么 SystemUI 宁可背着一身包袱也不重构
把上面几条线收拢,会发现 SystemUI 的架构选择几乎都是被约束逼出来的,而不是设计者偏爱某种模式。
最核心的矛盾是「一个进程」与「几十个功能」的对立。理论上最干净的方案是把状态栏、锁屏、通知各拆成独立进程,崩溃隔离、内存可回收、独立升级。但现实是这些界面共享同一个窗口层级、同一套输入分发、同一份配置变更事件,拆进程意味着跨进程同步窗口状态和输入焦点,延迟和复杂度都会暴涨。于是选择了单进程多模块,用模块化换来了逻辑上的隔离,代价是任何一个模块的崩溃都会带走整个 SystemUI——这也是为什么实际产品里必须给 SystemUI 配一套进程监控与自动拉起机制,相当于给自己买了一份后悔药。
第二个取舍是「按需启动」与「首次响应速度」的平衡。常驻模块越多,开机越慢、内存占用越高;常驻越少,首次触发某个功能时越容易出现可见的延迟。哪些模块进常驻集合,本质上是一道产品题而非技术题,取决于该功能被唤起的频率。这解释了为什么不同设备上同一份源码的表现差异那么大。
第三个取舍在通知链路的方向上。让 SystemUI 主动拉取而不是被动接收,牺牲了实时性(多一次查询),换来了状态一致性——SystemUI 任何时候拉到的都是系统的权威快照,不会因为漏收一条推送就和真实状态脱节。在通知这种「状态会频繁被外部修改」的场景里,一致性比实时性值钱得多。
至于验证方法,我的习惯是改完任何一处 SystemUI 逻辑,至少跑三遍场景:冷启动一遍、配置变更一遍、进程被杀后自动拉起一遍。第三个场景最容易被忽略,却恰恰是线上问题的高发区——很多在开发机上「怎么都复现不了」的 bug,本质都是进程重建路径没走对。从那以后我每次碰这类常驻系统应用,都强制把「杀掉再拉起」当成必测项写进自测清单,不再指望它自己恢复。希望帮到你。
本文还有配套的精品资源,点击获取