文章内容
本文内容整理自享学团队直播课《App 启动流程源码分析(四)》
一、App启动性能标准设定
点击桌面图标,屏幕立刻被一整块纯白(或纯黑)铺满,几百毫秒后才切成 App 的真实界面。可以通过在冷启动过程中执行 adb shell dumpsys window windows | grep -i starting进行流程观测,通过上述命令能看到一个类型为 TYPE_APPLICATION_STARTING 的窗口挂在窗口列表里,它的 title 就是应用名。
那一瞬间 App 进程可能还没被 fork 出来,Activity.onCreate 一行都没执行过,DecorView 也不存在,那么是谁画的这块白屏?它的颜色是从哪读出来的?
结论:启动阶段的"黑白屏"(更准确的叫法是启动屏 starting window)是 system_server 侧的 WMS(PhoneWindowManager)依据 AndroidManifest 里声明的主题(windowBackground / windowSplashScreenContent)绘制并显示的一个系统窗口,窗口类型是 TYPE_APPLICATION_STARTING,和 App 进程无关。它能"立刻"拿到主题,是因为 PMS 在系统启动时就已经解析并缓存了所有 App 的清单文件。所以优化黑白屏的着力点在主题的启动屏内容上,而不是等 App 首帧。
沿用前几篇定的启动边界:从点击 icon 到主 Activity 显示。黑白屏正好整段落在这个区间里面,它的展示时长等于这段区间减去 Launcher 的响应时间。
二、点击 icon 到首帧之间,用户看到的是什么

把这段时间摊开在一条轴上,屏幕内容依次是:Launcher 画面 → 启动屏(黑白屏)→ App 首帧 → 主页面数据填充完成。启动屏出现的位置很关键——它在 App 进程创建之前就已经出现了。
"黑"还是"白"取决于入口 Activity 生效主题里的 android:windowBackground:继承 Theme.AppCompat.Light 系列,这个值是浅色,看到的是白屏;继承 Theme.AppCompat(深色)系列,看到的是黑屏。把它改成一张图片,看到的就是那张图片。这也是"黑白屏"这个叫法容易误导人的地方——它不是一种独立的系统状态,只是一个背景色被原样画出来了。
启动屏什么时候消失?等 App 侧真正有内容可画的时候。App 进程起来后,Activity 走完 onCreate / onResume,ViewRootImpl 拿到 Surface,Choreographer 的 doFrame 提交第一帧,WMS 一侧确认目标窗口已经可见,才把启动窗口移除。
因此,黑白屏的总时长可拆解为四个阶段:进程创建、Application 初始化、Activity 创建与布局、首屏数据加载。这四段耗时全部计入用户看到的那块纯色。
三、黑白屏为什么会出现:为了"立刻响应"
假设系统不画这块屏。用户点击图标之后,Launcher 的 startActivity 发出去,接下来是 AMS 决策、Zygote fork、进程初始化、Application、Activity——冷启动场景下这一串少说几百毫秒。这期间屏幕上还是 Launcher 的桌面,图标点下去像没反应。
系统的选择是先占一块位:只要 AMS 决定要启动这个 Activity,就立刻在屏幕上放一个和目标应用主题一致的窗口,告诉用户"点到了,正在进来"。这块窗口不需要 App 参与,也不需要等 App 进程——它的全部内容就是一个背景。
四、黑白屏显示:AMS/WMS

冷启动时,启动屏出现的时刻 App 进程还没有被 Zygote fork 出来,App 连进程都不存在。退一步说,就算是热启动、进程已经在,此刻入口 Activity 也还没走到 onCreate,PhoneWindow 和 DecorView 都没有创建,ViewRootImpl 也就没有向 WMS 申请过 Surface。没有 Surface 就没有可提交的图层,App 侧在这一刻不具备把任何像素送上屏幕的条件。
那画面只能来自 system_server。但 system_server 是个没有界面的系统服务进程,它凭什么能往屏幕上画东西?
答案在于"画窗口"这件事在 Android 上并不要求进程本身是一个 App。窗口的创建入口是 WindowManager.addView,任何持有合法 token 和窗口类型的进程都能加窗口;system_server 里就住着 WMS 和 PhoneWindowManager,它们本身就是窗口系统的管理者,给自己加一个窗口在权限上没有任何障碍。系统状态栏、导航栏、Toast、锁屏都是这么来的,启动屏是同一类东西。
顺序上,触发点在 AMS 一侧:ActivityRecord 在 Activity 即将启动时调用 addStartingWindow,把"给这个 Activity 补一个启动窗口"的请求交给 WMS,WMS 的 PhoneWindowManager.addSplashScreen 真正把窗口建出来。两个类都在 system_server 进程内,中间不需要跨进程。
五、前提:PMS 在系统启动时就把清单解析好(证据)

addStartingWindow 的参数里带着 themeRes——一个主题资源 id。AMS 是怎么在 App 一行代码都没跑过的情况下知道这个 App 用什么主题的?清单文件在 APK 里,难道每次启动都要现场解压解析一遍?
不需要,因为解析在开机时就做完了。
// PackageManagerService:系统启动初始化的早期,扫描并解析所有已安装 APK 的 AndroidManifest// 这一步远早于任何一个 App 的启动,主题(theme)等信息在此存入内存private void scanPackageDirtyLI(File scanFile, ...) { // 解析安装包,得到 ApplicationInfo / ActivityInfo / theme 等清单信息 PackageParser.Package pkg = parser.parsePackage(...); // 把解析结果写入 PackageManager 的缓存使用 // 这就是 AMS 在启动 Activity 时能"立刻"拿到主题的原因 —— 早就解析好了}输入是磁盘上的一个 APK 文件路径;输出是内存里的 PackageParser.Package,里面挂着 ApplicationInfo 和每个组件的 ActivityInfo,android:theme 就在这些结构里。状态变化发生在 PMS 的包信息缓存上——从此以后任何系统服务查询这个包的组件信息都走内存,不走磁盘。
这段代码证明的结论是:主题信息的可用时刻远早于 App 的启动时刻。AMS 在 addStartingWindow 里拿 themeRes 是一次内存查表,代价可以忽略,这才使得"点击瞬间就把启动屏画出来"在时间上成立。如果主题要等 App 进程起来再读,启动屏就失去了全部意义——它要解决的恰恰是 App 起来之前那段空白。
一个能顺带解释的现象:安装或覆盖安装一个 App 之后,PMS 会重新解析该包并更新缓存,所以改了 windowBackground 重新安装就立刻生效,不需要重启系统。
六、源码路径:从 addStartingWindow 到 SplashScreen

链路的起点在 ActivityRecord 上。这个类是某个 Activity 在 system_server 侧的运行期记录,Activity 要启动,先经过它。
// addStartingWindow:Activity 即将启动、但 App 进程可能还没画好首帧时,AMS 决定先补一个启动窗口// 运行在 system_server 内,属于 ActivityRecord(某个 Activity 的运行期记录)public void addStartingWindow(String pkg, int themeRes, ActivityInfo.WindowLayout windowLayout, Intent intent, String nonConfig, CompatibilityInfo compatInfo, CharSequence label) { // windowDisableStarting:某些场景(如透明/浮动窗口)会禁用启动窗口,直接跳过 if (windowDisableStarting) { // 命中禁用条件,本次不创建任何启动窗口 return; // 禁用分支结束 } // windowIsTranslucent / windowIsFloating 等判定通过后,才继续创建启动窗口 // 待核实:不同版本在这些判空/判定集合与字段名上略有差异 ...// addStartingWindow 方法体结束}输入里最关键的两个是 themeRes 和 label:主题决定画什么,label 决定窗口标题。输出是 void,流程上有两个分支要么直接 return,要么继续往下走到 WMS 建窗口。判断条件在于 ActivityRecord 是否关联上了一个 starting window。
这段代码说明两件事:
- 第一,启动窗口是有条件的:主题声明了
windowIsTranslucent或windowIsFloating(透明主题、对话框主题)时,windowDisableStarting会命中,启动窗口根本不会被创建。这解释了一个常见现象——有些 App 点击后一直停在桌面上、几百毫秒后界面才直接跳出来,那是它把入口 Activity 设成了透明主题。 - 第二,决策发生在 App 进程之外,参数全部来自 PMS 缓存,与 App 的执行状态无关。
条件通过之后,请求转到 WMS 一侧。
// addSplashScreen:WMS(PhoneWindowManager) 真正为 App 创建 Splash 启动窗口// 它能拿到的信息来自系统启动时已经缓存的、AndroidManifest 主题里的内容public void addSplashScreen(Context context, WindowManager wm, DisplayContent displayContent, ... , Theme theme, CharSequence label, ...) { // 主题里没有可显示的启动屏内容时不显示,避免画一个空窗口 if (theme == null || label == null) { // 缺少主题或标题,直接返回,不创建启动窗口 return; // 空值保护分支结束 } // 把启动屏相关信息封装成 SplashScreenStartingData,供后续创建表面使用 SplashScreenStartingData data = new SplashScreenStartingData(...); // 用数据创建并挂载一个启动窗口(后续调用 PhoneWindow + addView)}输入是一个已经解析好的 Theme 对象和 label;输出是一份 SplashScreenStartingData,它承载了后续建表面(surface)所需的全部信息。状态变化是启动屏从"一个主题 id"变成了"一份可用于建窗口的数据"。
数据准备好之后,窗口本身被组装出来。
// 在 addSplashScreen 之后,组装那个启动窗口本身的关键设置// 启动窗口使用一个独立的 PhoneWindowPhoneWindow window = new PhoneWindow(context);// 设置窗口标题,通常是入口 Activity 的 label(应用名)window.setTitle(label);// 关键:设置窗口类型为系统专用的启动窗口类型,普通 App 无权声明该类型WindowManager.LayoutParams params = new WindowManager.LayoutParams(WindowManager.LayoutParams.TYPE_APPLICATION_STARTING);// FLAG_NOT_TOUCHABLE:启动窗口不接收触摸,避免吞掉用户首次点击params.flags |= WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE;// FLAG_NOT_FOCUSABLE:不抢焦点,避免干扰随后出现的 App 主窗口params.flags |= WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE;// 启动窗口的内容:由主题的 windowSplashScreenContent / windowBackground 决定(logo 图或纯色)// 待核实:FLAG 组合与设置顺序、setDefaultIcon/setDefaultLogo 的具体调用在版本间略有差异输入是 context、label 和主题;输出是一个配置完整的 PhoneWindow 加一份 LayoutParams,最后由 WindowManager.addView 挂上屏幕,对应的图层就是 SplashScreenSurface。状态变化是屏幕上多了一个系统窗口,层级排在目标 App 窗口之上。
几个需要留意的细节:setTitle(label) 用的是清单里的应用名,这也是 dumpsys window 里能按应用名找到这个窗口的原因;两个 flag 让这个窗口在输入系统里完全"透明"——用户在启动屏期间的点击不会被它吃掉,焦点也不会被它抢走,随后 App 主窗口上台时不需要额外做焦点转移;窗口的实际内容是一张由主题背景生成的图层,Choreographer 在这一侧几乎没有工作量,这是它能画得这么快的原因。
整条链串起来是:ActivityRecord.addStartingWindow → PhoneWindowManager.addSplashScreen → new PhoneWindow + setTitle + setType(TYPE_APPLICATION_STARTING) → LayoutParams 加 flag → WindowManager.addView → SplashScreenSurface 上台 → App 首帧可画时移除。全程在 system_server 内完成,App 进程从头到尾没有参与。
七、为什么系统能建窗口而普通 App 不能(窗口类型与权限)
TYPE_APPLICATION_STARTING 这个类型值落在系统窗口类型的区间里,不在应用窗口区间。WMS 在 addWindow 时会按窗口类型校验调用方:应用窗口必须携带一个由 AMS 分配的合法 Activity token,系统窗口则需要调用方具备相应的系统身份或权限。App 进程用这个类型去 addView,过不了这道校验。
这不是一个可以绕过的限制,App 侧也没有必要绕。App 侧能做的是通过主题去影响这个窗口的内容,控制权在主题这一层交接:系统负责建窗口、管生命周期、管移除时机,App 负责声明"这块占位画成什么样"。
顺带说明一个容易混淆的地方:应用自己写的那种"闪屏页 SplashActivity"和这里的启动屏是两回事。前者是一个真正的 Activity,跑在 App 进程里,有完整的生命周期和布局,它自己也会先经历一遍启动屏;后者是系统窗口,没有 View 树,也没有任何 App 代码在其中执行。把两者叠在一起,用户会先看到启动屏、再看到闪屏页,启动路径反而更长。
八、优化方向:把黑白屏变成 logo 屏,并给启动计时

既然启动屏的内容边界就是主题,配置的入口就落在清单和 styles 里。
<!-- 应用节点:入口 Activity 的主题声明,决定启动窗口长什么样 --><application android:theme="@style/AppTheme"> <!-- 入口 Activity:启动屏读的正是它最终生效的主题 --> <activity android:name=".MainActivity"> <!-- 入口组件:桌面点击最终落到这里 --> <!-- 意图过滤器:声明它是可被 Launcher 启动的主入口 --> <intent-filter> <!-- 主动作:MAIN 表示这是应用主入口 --> <action android:name="android.intent.action.MAIN" /> <!-- 启动器类别:桌面据此为应用生成可点击图标 --> <category android:name="android.intent.category.LAUNCHER" /> <!-- 意图过滤器声明结束 --> </intent-filter> <!-- 入口 Activity 声明结束 --> </activity><!-- 应用节点声明结束 --></application><!-- AppTheme:继承自系统主题,启动窗口的背景与内容都来自这里 --><style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar"> <!-- windowBackground:黑白屏的"黑或白"就是它;改成一张图片就能变成 logo 页 --> <item name="android:windowBackground">@color/screen_bg</item> <!-- Android 12+:用一张图/图标替代纯色背景,作为启动屏内容 --> <item name="android:windowSplashScreenContent">@drawable/splash_logo</item><!-- 主题声明结束 --></style>application 上的 android:theme 是默认值,activity 上再声明会覆盖它——启动屏读的是入口 Activity 最终生效的那份主题。windowBackground 是全版本通用的入口,把它换成一个 layer-list(底色 + 居中 logo)就能把纯色屏变成品牌屏,成本极低且不增加任何启动耗时,因为这张图是系统在 system_server 里画的。windowSplashScreenContent 是 Android 12 引入的属性,配套还有 windowSplashScreenBackground 和 windowSplashScreenAnimatedIcon。

需要把两件事分开:改主题只改变了这段时间里屏幕上显示什么,没有缩短任何一毫秒。启动屏的时长仍然等于进程创建 + Application + Activity + 首屏数据这四段之和。想真正缩短它,得回到前几篇的三个阶段去压——进程创建那段动不了,Application 里的同步初始化和 ContentProvider 是主战场,Activity 的布局层级和首屏数据加载是另一半。
还有一种做法是给入口 Activity 设透明主题,让启动屏根本不出现(对应第六节里的 windowDisableStarting 分支)。这么做之后,用户点击图标之后屏幕会一直停在桌面上直到 App 首帧上屏,主观感受通常比看到一块纯色更差——点下去像没点到。除非启动足够快,否则这不是一个划算的取舍。
给启动计时的方式建议用两条线交叉验证:adb shell am start -W 拿到的 TotalTime 是系统侧的口径,覆盖到首帧;再用 Perfetto 抓一次冷启动,在 system_server 轨道上找启动窗口的添加与移除,在 App 主线程轨道上找 bindApplication、activityStart 和第一个 Choreographer#doFrame,两者的间隔就是黑白屏的实际时长,且能直接看出这段时间被哪一步吃掉了。
总结
那块黑白屏是 system_server 画的。触发它的是 ActivityRecord.addStartingWindow,建出来的是 PhoneWindowManager.addSplashScreen 里的一个 PhoneWindow,窗口类型是系统专用的 TYPE_APPLICATION_STARTING,带着 FLAG_NOT_TOUCHABLE 和 FLAG_NOT_FOCUSABLE,内容全部来自主题里的 windowBackground / windowSplashScreenContent。它能在 App 进程还不存在时就画出来,靠的是 PMS 在开机阶段就用 parsePackage 把所有清单解析进了内存。
"黑白屏优化"主要就两件事:改主题把占位内容从纯色换成品牌图,见效快但不影响时长;压缩启动四步的耗时,那才是缩短这块屏幕存在时间的根本解法。
本文由 AgentWorkFlow 生成