启动流程拆解:应用启动的生命周期、main函数之前发生了什么、加载阶段与初始化阶段详解

我做性能优化这些年,最常被问到的一个问题是:“启动到底慢在哪?”

很多人拿着一份启动耗时数据,却不知道从哪下手。说白了,你要先搞清楚启动这件事本身——它分几个阶段,每个阶段干了什么,瓶颈可能藏在哪。

这一章,我就带你把启动流程整个拆开来看。

1. 启动的三种形态

先聊一个基础概念:冷启动、热启动、温启动。你想想看,每次点开App,系统所处的状态是不一样的,这直接决定了启动的路径长短。

类型 定义 典型耗时 优化重点
冷启动 进程被系统kill,内存中无缓存数据 2~5s(视App复杂度) 全链路优化,收益最大
热启动 进程存活,部分数据仍在内存 200~500ms 通常无需大动
温启动 进程刚被回收,缓存失效但页面状态残留 1~2s 减少重建成本

我们做性能优化,盯死的目标是 冷启动。为什么?因为它最慢,也最暴露问题。热启动你基本不用管,温启动的优化逻辑和冷启动是相通的。

关键认知: 冷启动的优化空间最大,也是业界通用的性能基线。把冷启动啃下来,其他两种自然跟着受益。

2. main函数之前:系统默默干了多少活

很多人觉得App是从 main() 开始跑的。这是个误区。

实际上,在 main 函数被调用之前,系统已经干了一大堆事。我刚开始接触启动优化时,就踩过这个认知的坑——总在 main 之后找问题,结果发现根因根本不在那。

main之前,主要干了这几件事:

  1. 内核创建进程——fork、分配虚拟内存、加载主二进制。
  2. dyld加载——动态链接器开始工作,递归加载所有依赖的动态库。
  3. 链接阶段——对动态库进行 rebase(基址修正)和 bind(符号绑定)。
  4. 加载阶段——把二进制的代码段、数据段通过 mmap 映射到内存。
  5. 初始化阶段——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. 一张图看懂启动流程

下面这张图,我把整个启动流程的关键节点画了出来。建议你保存下来,以后做优化分析时对着它看,思路会清晰很多。

应用启动流程拆解 pre-main 阶段 应用启动(冷/热/温) 内核阶段:fork / 内存分配 dyld加载 + rebase/bind 加载阶段 二进制加载 · 动态库加载 · mmap 初始化阶段 Runtime · +load · C++ 静态构造 main 函数(用户代码开始) 💡 核心原则: 1. 动态库数量越少,加载阶段越快 2. +load 方法越轻量,初始化阶段越快

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 做定量分析。 我个人习惯在开发阶段一直开启这个标志。每次启动都能看到每个子阶段的耗时,哪个阶段异常一眼就能发现。
一个小技巧: 在 Xcode 中为 Debug 配置单独开启 DYLD_PRINT_STATISTICS,不会影响 Release 包的性能。你可以在 Scheme → Run → Arguments → Environment Variables 里设置。

启动流程拆解,是性能优化的基本功。把 pre-main 的每个阶段都吃透了,你分析问题时就有了“地图”,不会像无头苍蝇一样乱撞。

下一块我们要聊的,是启动过程中的另一个关键维度——时间都在哪耗掉了。我会结合 Instruments 的实测数据,带你看懂启动火焰图。(嗯,先不剧透了)