启动流程拆解:应用启动的生命周期、main函数之前发生了什么、加载阶段与初始化阶段详解
我做性能优化这些年,最常被问到的一个问题是:“启动到底慢在哪?”
很多人拿着一份启动耗时数据,却不知道从哪下手。说白了,你要先搞清楚启动这件事本身——它分几个阶段,每个阶段干了什么,瓶颈可能藏在哪。
这一章,我就带你把启动流程整个拆开来看。
1. 启动的三种形态
先聊一个基础概念:冷启动、热启动、温启动。你想想看,每次点开App,系统所处的状态是不一样的,这直接决定了启动的路径长短。
| 类型 | 定义 | 典型耗时 | 优化重点 |
|---|---|---|---|
| 冷启动 | 进程被系统kill,内存中无缓存数据 | 2~5s(视App复杂度) | 全链路优化,收益最大 |
| 热启动 | 进程存活,部分数据仍在内存 | 200~500ms | 通常无需大动 |
| 温启动 | 进程刚被回收,缓存失效但页面状态残留 | 1~2s | 减少重建成本 |
我们做性能优化,盯死的目标是 冷启动。为什么?因为它最慢,也最暴露问题。热启动你基本不用管,温启动的优化逻辑和冷启动是相通的。
2. main函数之前:系统默默干了多少活
很多人觉得App是从 main() 开始跑的。这是个误区。
实际上,在 main 函数被调用之前,系统已经干了一大堆事。我刚开始接触启动优化时,就踩过这个认知的坑——总在 main 之后找问题,结果发现根因根本不在那。
main之前,主要干了这几件事:
- 内核创建进程——fork、分配虚拟内存、加载主二进制。
- dyld加载——动态链接器开始工作,递归加载所有依赖的动态库。
- 链接阶段——对动态库进行 rebase(基址修正)和 bind(符号绑定)。
- 加载阶段——把二进制的代码段、数据段通过 mmap 映射到内存。
- 初始化阶段——runtime 初始化、调用 +load 方法、执行 C++ 静态构造器。
你看,这还没到 main 呢,已经跑了一大圈了。我把这个区间统称为 “pre-main 阶段”,它往往是启动耗时的重灾区。
DYLD_PRINT_STATISTICS=1,可以在控制台看到 pre-main 阶段的详细耗时分布。我个人习惯在项目里长期保留这个开关,方便随时排查。
// 在 Xcode Scheme 的 Arguments 中设置
// Environment Variables:
// DYLD_PRINT_STATISTICS = 1
// 输出示例(简化):
// Total pre-main time: 2.34 seconds
// dylib loading time: 0.85 seconds
// rebase/binding time: 0.62 seconds
// ObjC +load time: 0.57 seconds
// initializer time: 0.30 seconds
3. 加载阶段——把二进制“搬”进内存
加载阶段的核心动作,我总结为三个字:读、解、映。
- 读——从磁盘读取 Mach-O 文件头,解析 Load Commands。
- 解——理解每个 Segment 和 Section 的大小、偏移、权限。
- 映——通过 mmap 将代码段、数据段映射到虚拟内存,注意是懒加载的,用到了才真正物理加载。
加载阶段最影响速度的因素,是 动态库的数量。每一个动态库都意味着一次完整的文件读取、签名验证、mmap 映射。我在一个旧项目里遇到过 120+ 个动态库,光是加载它们就花了 1.2 秒。
4. 初始化阶段——系统在“暖场”
初始化阶段是 pre-main 里最容易被忽视的环节。它包含三个主要部分:
Runtime 初始化
ObjC runtime 在这个阶段完成类、方法、协议的注册。App 启动时,所有类的元数据都要被加载到内存中。如果你的二进制里类数量特别多(比如 10 万+),这一步就会显著变慢。
+load 方法
每个类的 +load 方法,在 main 之前就会被执行。而且是按父类→子类→分类的顺序,自动递归调用。
// 一个容易被忽视的坑:
// +load 方法中做了任何耗时操作,都会直接卡住启动
@implementation MyViewController
+ (void)load {
// 我曾经在一个项目里看到有人在这里做网络请求...
// 结果启动慢了 0.8 秒,就因为这行代码
[self setupSomeGlobalCache]; // 不要在这里干重活
}
@end
我曾经接手过一个项目,启动耗时 3.6 秒,查了半天发现是某个第三方 SDK 在 +load 里用同步方式初始化数据库。嗯,从那以后我定了一条铁律:+load 里面只做注册,不做初始化。
C++ 静态构造器
C++ 中全局变量的构造函数,也是在 main 之前执行的。它们的执行顺序是由链接顺序决定的,而且不同编译单元之间的初始化顺序是未定义的。这一点很容易踩坑。
- 减少 +load 方法的数量,能用 +initialize 替代的就别用 +load。
- C++ 全局对象尽量使用懒初始化(比如包装在函数内部的 static 变量中)。
- 集中管理初始化逻辑,避免散落在各个文件里。
5. 一张图看懂启动流程
下面这张图,我把整个启动流程的关键节点画了出来。建议你保存下来,以后做优化分析时对着它看,思路会清晰很多。
6. 几个实用的避坑指南
讲完了理论,我分享几个自己在项目中踩过的坑,以及对应的解决思路。
- 动态库能少就少。 我记得有个项目把十几个业务模块都拆成了动态 Framework,结果启动时 dyld 加载花了 1.6 秒。后来合并成 4 个核心动态库,耗时降了 60%。
- 慎重对待 +load 方法。 我建议团队做一次全局扫描,把所有的 +load 方法列出来。凡是做了初始化以外工作的,一律改成 +initialize 或显式调用。避免踩坑。
-
C++ 全局对象,能用函数内 static 就别用全局变量。
// ❌ 不推荐:启动时就构造 // std::string g_largeData = loadLargeData(); // ✅ 推荐:首次使用时才构造 const std::string& getLargeData() { static std::string data = loadLargeData(); return data; } - 善用 DYLD_PRINT_STATISTICS 做定量分析。 我个人习惯在开发阶段一直开启这个标志。每次启动都能看到每个子阶段的耗时,哪个阶段异常一眼就能发现。
DYLD_PRINT_STATISTICS,不会影响 Release 包的性能。你可以在 Scheme → Run → Arguments → Environment Variables 里设置。
启动流程拆解,是性能优化的基本功。把 pre-main 的每个阶段都吃透了,你分析问题时就有了“地图”,不会像无头苍蝇一样乱撞。
下一块我们要聊的,是启动过程中的另一个关键维度——时间都在哪耗掉了。我会结合 Instruments 的实测数据,带你看懂启动火焰图。(嗯,先不剧透了)