SystemUI 里做 Launcher App 列表:四种方案的 Framework 原理与工程取舍

从一个"不可能的需求"说起

前几天跟做车机开发的朋友讨论起一个问题,大意是:项目里 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 开发的影响,最近看了一点,改动比预期的大。

相关推荐
ao-weilai30 分钟前
MySQL数据库:复合查询
android·数据库·mysql
三少爷的鞋13 小时前
AI 时代,我们都将成为通才型开发者:只懂 Android,已经不够了
android
Dovis(誓平步青云)17 小时前
家里设备越来越多,如何用一张空间地图控制灯光和温度![
android·java·前端·javascript·人工智能·电脑
Together_CZ18 小时前
LLM-as-a-Verifier: A General-Purpose Verification Framework——一种通用验证框架
llm·framework·agent·verification·verifier·一种通用验证框架·llm-as-a-
晚风叙码19 小时前
MySQL 数据类型详解:从数值到字符串,一篇讲透
android·mysql·adb
传奇开心果编程19 小时前
【Compose Multiplatform 跨端开发学与练】第3课 布局与组件
android·windows·学习·ui·ios·kotlin·composer
传奇开心果编程1 天前
【Compose Multiplatform 跨端开发学与练】第8课 资源管理与主题
android·windows·学习·ios·kotlin·web·composer
传奇开心果编程1 天前
【Compose Multiplatform 跨端开发学与练】第9课 测试与调试
android·学习·macos·ios·kotlin·web·composer
传奇开心果编程1 天前
【Compose Multiplatform 跨端开发学与练】第4课 导航与路由
android·windows·学习·ui·ios·kotlin·composer
传奇开心果编程1 天前
【Compose Multiplatform 跨端开发学与练】第6课 状态管理与架构
android·学习·ui·ios·架构·kotlin·composer