从一个"不可能的需求"说起
前几天跟做车机开发的朋友讨论起一个问题,大意是:项目里 SystemUI 全是 Window,没有 Activity,但产品要求把 Launcher 的应用列表做进去,两者强行合并之后问题一大堆,感觉走进了死胡同。
我看完的第一反应是:这个需求本身就自带矛盾。SystemUI 和 Launcher 是 Android Framework 里两个完全不同的层,一个靠 Window 直接贴在系统 UI 层,一个是完整的 Activity 应用。要把它们"做在一起",得先搞清楚各自是什么,为什么会冲突,然后才能谈怎么解决。
这篇文章就从 Framework 原理开始拆,讲清楚四种方案各自的实现方式、适用场景和代价。
先搞清楚:SystemUI 的 Window 和 Launcher 的 Activity 是什么
SystemUI 的 Window 模型
SystemUI 是一个系统级进程,它通过 WindowManager 直接向系统申请 Window,把 UI 贴在屏幕上。典型的有 StatusBar、NavigationBar、NotificationShade、VolumePanel 等。
这些 Window 的 type 通常是 TYPE_STATUS_BAR、TYPE_NAVIGATION_BAR 这类系统级 Window Type,需要 SYSTEM_ALERT_WINDOW 或更高权限。
核心特点:
• 没有 Activity 生命周期(没有 onCreate/onResume/onPause)
• 没有 Back Stack,系统不知道它的存在(从 Task 管理角度看)
• View 树直接 attach 到 WindowManager,通过 WindowManagerImpl.addView() 添加
• 生命周期由 SystemUI 自己管理,通常跟 SystemUI 进程同生死
看一下 SystemUI 里典型的 Window 创建方式(以 NavigationBar 为例):
// NavigationBarController.java(简化)
private void createNavigationBar(Display display) {
WindowManager.LayoutParams lp =
new WindowManager.LayoutParams(
LayoutParams.MATCH_PARENT,
navBarHeight,
WindowManager.LayoutParams
.TYPE_NAVIGATION_BAR,
WindowManager.LayoutParams
.FLAG_NOT_FOCUSABLE
| WindowManager.LayoutParams
.FLAG_TOUCHABLE_WHEN_WAKING,
PixelFormat.TRANSLUCENT
);
mWindowManager.addView(mNavigationBarView, lp);
}
Launcher 的 Activity 模型
Launcher 本质上是一个普通 App(虽然是系统 App),它的核心是一个声明了 ACTION_MAIN + CATEGORY_HOME 的 Activity:
这意味着 Launcher 有完整的 Task 管理 、Back Stack 、Activity 生命周期,系统在按 Home 键时能正确回到它,Recents 也能正确处理。
冲突的根本原因
把 App 列表放进 SystemUI Window,就像要求一个保安在门口既站岗又在里面当前台------两件事的职责和系统都不一样,硬塞在一起迟早出问题。具体矛盾在于:
① Activity Context 依赖 :startActivity() 从非 Activity Context 调用时,必须加 FLAG_ACTIVITY_NEW_TASK,否则直接 crash。而且某些系统限制(比如后台启动限制)是绑定到 Activity/Task 的,Window 里绕不过去。
② Recents / 多任务异常:从 Window 里启动的 App 没有正确的 calling task,Recents 会出现任务栈混乱。
③ 焦点和输入法:Window 的 flags 控制焦点行为,做 App 列表搜索框时输入法的弹出/收起会影响整个 Window 布局。
④ 系统级权限检查 :部分 API(比如获取 App 使用时长 UsageStatsManager)在非 Launcher 进程里调用需要额外权限。
方案一:SystemUI 内嵌透明 Activity
原理
SystemUI 是一个完整的 APK,虽然通常不用 Activity,但 Manifest 里完全可以注册 Activity。思路是加一个透明、无动画、对用户无感的 Activity,专门承载"需要 Activity Context"的操作,Window 负责 UI 展示,Activity 负责系统交互。
实现
方案一的逻辑,就像餐厅前台(Window)不能直接跑去厨房(系统层)拿菜,但可以叫一个专门的传菜员(透明 Activity)来跑这趟------用户根本看不到传菜员,感知到的只是"点菜了,菜来了"。
第一步,在 SystemUI 的 Manifest 注册透明 Activity:
true
@android:color/transparent
@null
第二步,Activity 本身只做"中转":
class LauncherProxyActivity : Activity() {
override fun onCreate(
savedInstanceState: Bundle?
) {
super.onCreate(savedInstanceState)
// 不 setContentView,Activity 完全透明
val action = intent.getStringExtra(
"action"
)
when (action) {
"launch_app" -> {
val pkg = intent.getStringExtra(
"package"
) ?: return finish()
launchApp(pkg)
}
"open_settings" -> {
startActivity(
Intent(
Settings.ACTION_SETTINGS
)
)
}
}
finish() // 立即结束,用户无感知
}
private fun launchApp(pkg: String) {
val launchIntent = packageManager
.getLaunchIntentForPackage(pkg)
?: return
// 从 Activity Context 启动,无需 NEW_TASK
startActivity(launchIntent)
}
}
第三步,SystemUI 的 Window(App 列表 UI)点击某个 App 时,通过 Intent 唤起这个 Activity:
// SystemUI Window 里的点击处理
fun onAppIconClick(packageName: String) {
val intent = Intent(
context,
LauncherProxyActivity::class.java
).apply {
putExtra("action", "launch_app")
putExtra("package", packageName)
// 从 Window 里启动 Activity 需要 NEW_TASK
addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK
)
}
context.startActivity(intent)
}
优缺点
| ✅ 优点 | ⚠️ 缺点 |
|---|---|
| 改动最小,不重构现有结构 | 有一次 Activity 启动的开销(通常 <50ms,用户无感) |
| 解除所有 Activity Context 依赖 | 复杂交互(如 drag & drop 到桌面)仍然难做 |
| 对用户完全透明无感知 | 不能被系统识别为 HOME,Home 键行为需额外处理 |
| 快速落地,适合工期紧的项目 | 架构上是打补丁,未从根本解决职责混淆 |
我的判断:这是工期紧时的最优解。Activity 的 onCreate + finish 整个链路加起来不到 50ms,用户感知不到,换来的是所有系统交互的正确性。
方案二:SystemUI + Launcher 分离,IPC 协作
原理
这个方案是承认 SystemUI 和 Launcher 就是两个不同的东西,然后让它们各司其职,通过进程间通信(IPC)协作。SystemUI 的 Window 做"壳"(动画、手势层),Launcher 的 Activity 做"核"(App 列表数据、启动逻辑)。用户眼里看到的是一个整体。
方案二的思路类似"前台+后厨分离":前台负责接待和展示(SystemUI Window 做手势动效层),后厨负责实际出餐(Launcher Activity 做 App 列表数据和启动逻辑),两者通过"点菜单"(IPC)沟通。用户眼里是一个整体,内部其实两套班子。
进程间通信可以选 AIDL、Messenger、或者直接 Intent/Broadcast,各有适用场景:
| 方式 | 适用场景 | 延迟 |
|---|---|---|
| AIDL | 高频双向通信,同步调用 | ~1ms |
| Messenger | 消息驱动,顺序处理 | ~2-5ms |
| LocalBroadcast(同进程) | 不跨进程,SystemUI 内部通信 | <1ms |
| ContentProvider | 共享数据(App 列表、配置) | ~5ms |
实现示例:AIDL 方式
定义 ILauncherService.aidl:
// ILauncherService.aidl
package com.example.launcher;
interface ILauncherService {
// SystemUI 调用,获取应用列表
List getInstalledApps();
// SystemUI 调用,启动应用
void launchApp(in String packageName);
// SystemUI 通知 Launcher 显示/隐藏
void setVisibility(boolean visible);
}
Launcher 侧实现 Service,提供给 SystemUI 绑定:
class LauncherBridgeService : Service() {
private val binder =
object : ILauncherService.Stub() {
override fun getInstalledApps():
List {
return queryInstalledApps()
}
override fun launchApp(
packageName: String
) {
// 从 Service 启动需要 NEW_TASK,
// 但 Launcher 进程本身就是 HOME Task
// 所以调用方式和普通 Launcher 一样
packageManager
.getLaunchIntentForPackage(
packageName
)?.also {
it.addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK
)
startActivity(it)
}
}
}
override fun onBind(
intent: Intent
): IBinder = binder
}
视觉融合技巧
SystemUI Window 负责手势动效层(毛玻璃背景、图标网格动画),Launcher Activity 做成全屏透明或部分透明,两者叠加在一起时用户看不出是两个进程在协作。关键参数:
// Launcher Activity 窗口配置
override fun onCreate(
savedInstanceState: Bundle?
) {
window.apply {
// 关键:不绘制背景,让 SystemUI
// 的 Window 透过来
setBackgroundDrawable(
ColorDrawable(Color.TRANSPARENT)
)
// 不截断触摸事件,两层都能收到
addFlags(
WindowManager.LayoutParams
.FLAG_NOT_TOUCH_MODAL
)
}
}
优缺点
| ✅ 优点 | ⚠️ 缺点 |
|---|---|
| 架构干净,职责清晰 | 工程量大,需要维护 AIDL 接口 |
| 两个进程独立,不会互相影响稳定性 | IPC 通信有延迟,高频刷新场景需要缓存 |
| Home 键行为完全正确 | 两个进程的动效同步是个难点 |
| 长期可维护性最好 | 进程间通信异常需要额外处理(Launcher 挂了怎么办) |
方案三:把 SystemUI 的 Window 升级成 HOME Activity
原理
这个方案最激进:直接在 SystemUI 里注册一个 HOME Activity,让系统把 SystemUI 当作 Launcher 来处理。这样 Home 键行为、Task 管理、Recents 全部正常,Window 和 Activity 共存在同一个进程里,通信成本为零。
AOSP 里 SystemUI 本身就有这个能力------在某些定制 ROM 和车载系统里,SystemUI 就是 Launcher(比如一些 HMI 系统)。
实现
注意 android:priority="1"------这让 SystemUI 的优先级高于其他 Launcher(如果系统里还有 Launcher3),避免每次 Home 键都弹选择框。
HomeActivity 里可以直接和同进程的 Window 通信:
class HomeActivity : Activity() {
override fun onResume() {
super.onResume()
// 直接调用同进程的 SystemUI 组件
// 无需 IPC,零延迟
Dependency.get(
AppListController::class.java
).show()
}
override fun onPause() {
super.onPause()
Dependency.get(
AppListController::class.java
).hide()
}
}
要注意的坑
📌 ⚠️ SystemUI 进程崩溃 = Launcher 崩溃
SystemUI 是系统核心进程,一旦 HOME Activity 出 bug 导致进程崩溃,整个 UI 层都会挂。Launcher 挂了系统会重启 Launcher,但 SystemUI 挂了是更严重的问题。所以这个方案对代码质量要求极高。
| ✅ 优点 | ⚠️ 缺点 |
|---|---|
| 同进程通信,零延迟 | 稳定性风险极高,SystemUI 崩溃后果严重 |
| Home 键、Task、Recents 全部原生正确 | 改动侵入性强,AOSP 升级时合并成本高 |
| 架构上最"整",不用维护多进程通信 | 不适合手机场景,更适合车载/TV 等封闭系统 |
方案四:RemoteViews / SurfaceView 渲染
原理
如果 App 列表的交互非常简单(只有点击启动,没有拖拽、长按、多选等),可以完全在 SystemUI Window 里渲染,用 PackageManager 获取 App 信息,用 RecyclerView 展示,点击时用 FLAG_ACTIVITY_NEW_TASK 启动。
这不是"伪装成 Launcher",而是承认自己不是 Launcher,只是在 Window 里展示一个"快速启动列表"。很多手机厂商的下拉控制中心里的"快速应用"就是这个方案。
关键代码
class AppListWindowView(
private val context: Context
) : FrameLayout(context) {
private val apps by lazy {
context.packageManager
.queryIntentActivities(
Intent(
Intent.ACTION_MAIN
).also {
it.addCategory(
Intent.CATEGORY_LAUNCHER
)
},
0
)
}
fun onAppClick(
resolveInfo: ResolveInfo
) {
val pkg = resolveInfo
.activityInfo.packageName
val cls = resolveInfo
.activityInfo.name
context.startActivity(
Intent().apply {
setClassName(pkg, cls)
// 从非 Activity Context 启动
// 必须加 NEW_TASK
addFlags(
Intent
.FLAG_ACTIVITY_NEW_TASK
)
}
)
}
}
限制非常明显 :从 Window Context 里 startActivity 必须带 FLAG_ACTIVITY_NEW_TASK,这会导致被启动的 App 的 Task 行为不完全符合 Launcher 语义(比如已经在前台的 App 点击图标不会 bringToFront 而是重新走 onCreate)。Android 12+ 还加了后台启动限制,Window 里触发的 startActivity 可能被系统拦截。
| ✅ 优点 | ⚠️ 缺点 |
|---|---|
| 实现最简单,不需要额外组件 | App 启动行为与真正的 Launcher 有差异 |
| 适合"快速启动面板"场景 | Android 12+ 后台启动限制可能导致失败 |
| 不依赖 Activity,Window 自包含 | 不能做完整 Launcher 功能(桌面、Widget 等) |
四种方案怎么选
说一下我的决策逻辑:
方案四适合"我只是要一个快速启动面板"的场景,不是真正的 Launcher,功能范围很小。如果需求明确只有展示和点击,用这个最省事。
方案一是工期紧时最务实的选择。透明 Activity 的开销可以忽略不计,能解除所有 Activity Context 限制,代码改动很小。我通常会先用这个方案让功能跑通,等后面有时间再考虑重构。
方案二是长期看最健康的架构。两个组件职责清晰,可以独立迭代,稳定性互不影响。代价是 IPC 通信的复杂性和两个进程动效同步的难题。适合有充足时间的项目。
方案三我会慎用。SystemUI 崩溃会导致整个系统 UI 挂掉,风险太高,除非是车载这类有专门系统团队维护、UI 层重要性压过稳定性的场景。
一个容易被忽略的细节:焦点和输入事件路由
不管选哪个方案,做 App 列表时都会碰到一个让人头疼的问题:搜索框的输入法。
SystemUI Window 的 flags 默认是 FLAG_NOT_FOCUSABLE,这意味着它不能接收键盘输入,也不会弹出输入法。如果 App 列表里要加搜索,需要动态切换 Window flags:
fun enableInput() {
val params = mWindowView
.layoutParams
as WindowManager.LayoutParams
// 移除 NOT_FOCUSABLE,允许接收输入
params.flags = params.flags and
WindowManager.LayoutParams
.FLAG_NOT_FOCUSABLE.inv()
mWindowManager.updateViewLayout(
mWindowView, params
)
// 主动请求焦点
mSearchBar.requestFocus()
val imm = getSystemService(
InputMethodManager::class.java
)
imm.showSoftInput(
mSearchBar,
InputMethodManager.SHOW_IMPLICIT
)
}
fun disableInput() {
val params = mWindowView
.layoutParams
as WindowManager.LayoutParams
// 恢复 NOT_FOCUSABLE,避免拦截
// 其他 Window 的输入事件
params.flags = params.flags or
WindowManager.LayoutParams
.FLAG_NOT_FOCUSABLE
mWindowManager.updateViewLayout(
mWindowView, params
)
}
这个切换本身不复杂,但和方案二/三的组合使用时,焦点的归属会变得复杂,需要仔细处理 Window 焦点和 Activity 焦点的优先级。
历史债就是历史债,先让它能用再说
回到最开始那个问题:SystemUI 全是 Window,要伪装成 Launcher。
说实话,这个需求本身就是设计阶段没想清楚的产物------早期把 SystemUI 和 Launcher 的职责边界搞混了,现在要还债。方案一能在一两天内让功能跑通,代价是欠下一笔小债(透明 Activity 是补丁不是根治)。方案二才是把债还清的路子,但需要时间。
很多项目里的技术决策不是"最优解 vs 次优解",而是"现在能交付的解 vs 以后再还债"。关键是要清楚你在做的是哪个,别用方案一的时间预期做方案二的事情,也别用方案四的思路去做需要完整 Launcher 行为的功能。
如果你也在做类似的事情,欢迎来聊。下一个我想研究的点是 Android 15 里 WindowManager 的变化对 SystemUI 开发的影响,最近看了一点,改动比预期的大。