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_BARTYPE_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 StackActivity 生命周期,系统在按 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 开发的影响,最近看了一点,改动比预期的大。

相关推荐
2501_915106323 小时前
iOS 多技术栈混淆实现,跨平台 App 混淆拆解与组合
android·ios·小程序·https·uni-app·iphone·webview
ii_best3 小时前
自动化开发软件[按键精灵] 安卓/iOS脚本,变量作用域细节介绍
android·运维·ios·自动化
00后程序员张3 小时前
有些卡顿不是 CPU 的问题,还要排查磁盘 I/O
android·ios·小程序·https·uni-app·iphone·webview
_李小白3 小时前
【OSG学习笔记】Day 24: Texture2D 与 Image
android·笔记·学习
FinTech老王3 小时前
告别“sql_mode“噩梦:MySQL 8.0 vs 5.7兼容性全对比与升级避坑指南
android·sql·mysql
匆忙拥挤repeat4 小时前
Android Compose 渲染 UI 帧的三个阶段:组合、布局、绘制
android·ui
帅得不敢出门4 小时前
Android Studio同一个工程根据不同芯片平台加载不同的framework.jar及使用不同的代码
android·android studio·jar
xiangxiongfly9154 小时前
Android LeakCanary源码分析
android·leakcanary