App 冷启动全流程
从用户点击桌面图标,到 App 第一帧画面渲染完成,中间发生的事情远比你想象的多。理解这个流程,是优化启动性能、排查启动问题的基础。
冷启动 vs 热启动
在讲启动流程之前,先明确两个概念:
- 冷启动(Cold Start):App 进程不存在,需要从零创建进程、初始化 Application、加载第一个 Activity。这是最耗时的场景。
- 热启动(Warm Start):App 进程还在,但 Activity 被销毁了(如系统内存紧张)。系统只需重建 Activity,不用重建进程。
- 温启动(Lukewarm Start):Activity 在返回栈中,完全复用,只需调用
onRestart()→onStart()→onResume()。
本文重点分析冷启动,因为它涉及最多的原理。
全流程时序图
用户点击图标(Launcher 调用 startActivity)
│
↓ Binder IPC
┌──────────────────┐
│ ActivityManagerService │ ← 在 system_server 进程
│ - 检查目标 App 进程是否存在
│ - 不存在 → 向 Zygote 发请求创建进程
└────────┬─────────┘
│ Socket 通信
↓
┌──────────────────┐
│ Zygote 进程 │ ← 执行 fork(),复制自身
│ fork() 新进程 │
└────────┬─────────┘
│
↓ 新进程启动
┌──────────────────┐
│ ActivityThread │ ← App 进程的主线程
│ main() 入口 │
│ - 创建 Looper/MessageQueue
│ - 调用 attach() 向 AMS 注册
│ - AMS 发送 BIND_APPLICATION 消息
│ - 创建 Application,调用 onCreate()
│ - AMS 发送 LAUNCH_ACTIVITY 消息
│ - 创建 Activity,走生命周期
└────────┬─────────┘
│
↓
┌──────────────────┐
│ 第一帧渲染完成 │ ← onWindowFocusChanged() 后
└──────────────────┘
第一阶段:Launcher 发起启动
当你点击桌面图标时,Launcher(本身也是一个 App)调用:
// Launcher 的点击处理
Intent intent = appInfo.intent; // 包含 ComponentName 指定的目标 Activity
startActivity(intent); // 等同于调用 AMS.startActivity()
这个 startActivity() 通过 Binder 调用 ActivityManagerService.startActivity(),将控制权交给 system_server。
隐藏阶段:WMS 渲染 Starting Window (白屏/黑屏的真相)
在 AMS 决定要启动一个新的 App 进程之前,由于从创建进程(fork)、类加载到渲染首帧是一个非常耗时的过程,如果系统在这期间什么都不显示,用户会觉得“点下去没反应,手机卡死了”。
为了缓解这种视觉焦虑,AMS 在下达创建新进程指令的同时,会通知 WindowManagerService (WMS) 立即为这个即将诞生的 Activity 渲染一个临时窗口,即 Starting Window(启动窗口)。
- 长什么样? 根据你 App
AndroidManifest.xml中配置的主题(Theme)里的windowBackground属性颜色或图片进行绘制。如果你没配置,它默认就是纯白或纯黑(这也是经典的“冷启动白屏/黑屏”现象的根源)。 - 历史演变:
- 以前: 开发者常把
windowBackground设为一张 Splash 图片,让主活动加载完成前展示,这也是之前优化中让用户感觉 App "秒开"的障眼法。 - 现在: Android 12 引入了统一的
SplashScreen核心 API,App 不再鼓励自定义过度花哨的启动页 Activity,而是交给系统级别的 SplashScreen 进行标准化的动画过渡。
- 以前: 开发者常把
第二阶段:AMS 决策与 Zygote 创建进程
AMS 是进程的「调度总指挥」:
- 解析 Intent,找到目标 Activity 的
ActivityInfo - 检查目标 App 的进程是否已存在(查
mProcessNames表) - 若不存在,调用
Process.start(),通过 Socket 向 Zygote 发送请求
为什么 AMS 和 Zygote 之间用 Socket 而不是 Binder? 这涉及操作系统层面一个经典的陷阱:多线程 + fork() = 灾难。
① fork() 的"克隆陷阱"
Linux 的 fork() 有一个极其反直觉的规定:它只会把调用 fork 的那一个线程复制到子进程中,原进程中其他所有线程在子进程里全部凭空消失。
💡 你可能会问:Linux 为什么要这么“反直觉”地设计?不能把所有线程全克隆过去吗?
- 避免极大浪费 (
fork + exec惯例):在 Linux 世界中,fork()出一个子进程后,99% 的接下来操作是立即干掉自己,并且执行exec()去加载一个全新的程序(比如ls进程)。如果原进程有 100 个线程,OS 辛辛苦苦把 100 个线程的执行栈全克隆一遍,结果子进程下一秒调用exec()直接原爆全剧终,这绝对是暴殄天物的性能浪费。- 避免状态连环爆炸:假设克隆了所有线程。原进程有个后台线程正在往某日志文件死命写数据,克隆后,立刻变成了两个后台线程毫无章法地往同一个文件狂写。这种共享资源和外部状态(Socket、文件)的双重处理会导致无数的灾难。
所以,操作系统的 POSIX 标准一拍大腿:谁按的克隆按钮,我就只克隆谁!其他线程就当不存在!
💡 通俗比喻:Zygote 是一家公司,里面有很多员工(线程)。员工 A 跑去按下了"克隆机"(
fork),出来的克隆新公司里,只有员工 A 的复制人,其他员工 B、C、D 全都不见了。
② 死锁的诞生
如果员工 B 消失的瞬间,正好反锁了公司唯一的厕所门(持有一把 Mutex 锁),那么在克隆出的新公司里:厕所门是锁着的,但没人能从里面打开——因为员工 B 根本不存在。此时员工 A 想上厕所(获取锁),就会永远等在门外,死锁。
在真实系统中,C/C++ 的 malloc 内存分配、JVM 内部操作到处都在用锁。多线程环境下 fork,子进程几乎 100% 会在某个地方发生死锁。
③ Binder 的天性:多线程
Binder 机制底层自带一个线程池(默认最多 15 个 Binder 线程)。一旦 Zygote 启用 Binder 通信,系统就会自动创建多个 Binder 线程。此时 Zygote 执行 fork(),这些 Binder 线程在子进程中全部消失,但它们持有的锁和中间状态却被原样克隆了过来——必定死锁。
④ Socket 的安全保障
Socket(UNIX 域套接字)可以通过 select/epoll 在单一主线程中以事件循环的方式监听消息。Zygote 始终保持单线程状态,不存在"别人拿着锁却消失"的问题,fork() 前后干干净净。新的 App 进程 fork 出来后(危险期已过),再自行启动 Binder 线程池与系统通信,此时就没有问题了。
Zygote 收到请求后执行 fork():
// Zygote.java(简化逻辑)
pid = Zygote.forkAndSpecialize(...);
if (pid == 0) {
// 子进程:新的 App 进程
handleChildProc(...); // 进入 ActivityThread.main()
} else {
// 父进程(Zygote):记录子进程 PID,继续等待下一个请求
}
fork() 执行后,新进程继承了 Zygote 预加载的所有 Framework 类和资源。通过写时复制(COW),只有被修改的内存页才会真正分配新内存,开销极小。
开启 Binder 线程池:为了防范死锁,fork() 执行前进程保持着单线程状态。但当 fork() 完毕、进入新诞生的子进程(App 进程)后,危险期即刻解除。新进程随即调用 ZygoteInit.nativeZygoteInit(),通过底层逻辑 ProcessState::self()->startThreadPool() 为这个新进程开启 Binder 线程池。有了这个多线程池,App 进程才真正融入了 Android 的底层通信体系,获得了接收 AMS 跨进程指令(如 BIND_APPLICATION 或 LAUNCH_ACTIVITY)的能力。
第三阶段:ActivityThread 初始化
新进程的起点是 ActivityThread.main():
// ActivityThread.java
public static void main(String[] args) {
// 1. 准备主线程的消息循环
Looper.prepareMainLooper();
// 2. 创建 ActivityThread 实例,负责管理整个进程的 Activity 生命周期
ActivityThread thread = new ActivityThread();
// 3. 通过 Binder 向 AMS 注册,告诉 AMS:我在这里,可以和我通信了
thread.attach(false);
// 4. 开始消息循环,从此 App 的一切都在这个循环里处理
Looper.loop(); // 永远不会返回
}
attach() 内部将 ApplicationThread(一个 Binder 对象)注册到 AMS,作为 AMS 回调这个进程的通道。
第四阶段:Application 创建
AMS 通过 ApplicationThread(Binder)发送 BIND_APPLICATION 消息,主线程的 H(Handler)处理这个消息:
// ActivityThread.handleBindApplication()
// 1. 反射创建 Application
Application app = mInstrumentation.newApplication(
cl, data.appInfo.className, appContext);
// 2. 安装 ContentProvider(注意:它发生在 Application.onCreate 之前!)
installContentProviders(app, data.providers);
// 3. 调用 Application.onCreate()
mInstrumentation.callApplicationOnCreate(app);
底层耗时暗坑:类的加载与验证 (Verify)
在 Application 初始化阶段,系统会把你的 Dex 文件加载进内存。开发者经常会发现 Application.onCreate 里仅有寥寥几行代码,但在低端机上依旧耗时几十甚至大几百毫秒。这往往是 ART 虚拟机的验证机制造成的:ART 在首次加载新的类时,需要对字节码进行严格且耗时的 Verify(完整性校验)。
解决这个隐性耗时的现代优化方案是使用官方的 Baseline Profiles(基准配置文件)。它可以在 App 安装时,直接将冷启动热点路径代码(如启动页、主页用到的类)进行 AOT (Ahead-Of-Time) 编译为机器码,从而完美跳过 JIT 和 Verify 过程,显著提升启动速度。
关键时机:
- ContentProvider 在
Application.onCreate()之前初始化。很多三方库(如 Firebase、WorkManager)利用这一点,创建一个 ContentProvider 来偷偷初始化,不需要你在Application.onCreate()里手动调用。 Application.onCreate()是启动优化的重点关注区域:阻塞的初始化任务都会延迟 App 启动速度。
第五阶段:Activity 创建与首帧渲染
AMS 收到 Application 初始化完成的信号后,发送 LAUNCH_ACTIVITY 消息:
// ActivityThread.performLaunchActivity()
Activity activity = mInstrumentation.newActivity(
cl, component.getClassName(), r.intent); // 反射创建 Activity
activity.attach(appContext, this, ...); // 绑定 Window
mInstrumentation.callActivityOnCreate(activity, ...); // Activity.onCreate()
// 后续:onStart() → onResume() → 开始 View 绘制
首帧渲染时机:
setContentView() 构建 View 树后,不会立即渲染。真正的绘制发生在第一次 Choreographer 回调时(同步到 VSync 信号),这时才开始 measure/layout/draw 流程,最终通过 SurfaceFlinger 合成到屏幕上。
开发者可以用 onWindowFocusChanged(hasFocus=true) 作为「首帧完整渲染」的近似标志。
首屏加载完了,但用户还不能点?(IdleHandler 的妙用)
很多时候第一帧刚刚画完,App 主线程继续被塞满各种非核心 SDK 的初始化任务,导致用户滑动屏幕却毫无响应,严重影响交互体验。
此时极佳的做法是利用 Looper.myQueue().addIdleHandler(),将那些非核心的长耗时任务(如打点 SDK、部分数据库预热、埋点初始化)打包进去。当主线程把关键渲染任务处理完、进入空闲(Idle)状态等待新消息时,系统便会自动触发执行 IdleHandler 里的任务,从而保障界面在首帧显示后即可流畅响应用户的交互。
启动优化的关键着力点
理解了启动流程,优化目标就很清晰:
| 阶段 | 优化手段 |
|---|---|
| 类加载与预编译 | 引入 Baseline Profiles 提前 AOT 编译核心代码,跳过运行时 Verify |
| Application.onCreate() | 异步初始化非必要库;使用官方 App Startup 库统一管理任务依赖 |
| ContentProvider 初始化 | 检查并消灭冗余的三方库 Provider,收敛到统一初始化入口 |
| Activity.onCreate() | 减少 setContentView() 嵌套;异步 Inflate;使用 ViewStub 延迟加载 |
| 首帧渲染 | 利用 Android 12 标准 SplashScreen 过渡;避免背景过度绘制 |
| 首屏之后的主线程防卡顿 | 将非必要初始任务推入 IdleHandler,保障界面首显示即可秒级响应点击事件 |