一、什么算"启动"?
从用户点击 App 图标,到第一个页面完整渲染出来,这段时间就是启动时间。
苹果把启动分为两个阶段:
css
用户点击图标
│
▼
┌──────────────────────────────┐
│ Pre-main 阶段 │ ← 系统在干活,你的代码还没执行
│ (dyld 加载 → Runtime 初始化) │
└──────────────┬───────────────┘
│
▼ main() 函数被调用
┌──────────────────────────────┐
│ Post-main 阶段 │ ← 你的代码开始执行
│ (AppDelegate → 首页渲染完成) │
└──────────────────────────────┘
│
▼
用户看到首页
苹果的标准:冷启动应在 400ms 以内完成,超过 20 秒系统会杀掉 App(Watchdog 机制)。
冷启动 vs 温启动 vs 热启动
| 类型 | 条件 | 耗时 |
|---|---|---|
| 冷启动 | App 不在内存中,从零开始加载 | 最长 |
| 温启动 | App 刚被杀掉,部分数据还在系统缓存中 | 中等 |
| 热启动 | App 在后台,从挂起状态恢复 | 最短(几乎瞬间) |
启动优化主要针对冷启动,因为它是最慢的。
二、Pre-main 阶段:系统在干什么?
从点击图标到 main() 函数执行,中间经历了以下步骤:
css
① 内核 fork 进程,加载可执行文件(Mach-O)
│
▼
② dyld 接管,开始加载动态库
│
▼
③ Rebase & Bind:修复指针、绑定外部符号
│
▼
④ Objc Runtime 初始化:注册类、处理 Category
│
▼
⑤ 执行 +load 方法和 C++ 静态构造函数
│
▼
⑥ 调用 main()
2.1 加载可执行文件(Mach-O)
iOS 的可执行文件格式叫 Mach-O(Mach Object)。内核先 fork 出一个新进程,把 Mach-O 文件映射到内存中。
Mach-O 的结构:
css
┌─────────────────┐
│ Header │ ← 架构信息(arm64)、文件类型
├─────────────────┤
│ Load Commands │ ← 告诉 dyld 需要加载哪些动态库、各段放在哪
├─────────────────┤
│ __TEXT 段 │ ← 代码(只读)
├─────────────────┤
│ __DATA 段 │ ← 全局变量、指针(可读写)
├─────────────────┤
│ __LINKEDIT 段 │ ← 符号表、签名信息
└─────────────────┘
2.2 dyld 加载动态库
dyld(dynamic link editor) 是苹果的动态链接器,负责把 App 依赖的所有动态库(.dylib / .framework)加载到内存中。
一个普通 App 依赖的动态库数量:
- 系统库(UIKit、Foundation、CoreGraphics 等):100~400 个
- 第三方库(如果用了动态 framework):几个到几十个
每个动态库的加载过程:
- 从磁盘找到
.dylib文件 - 验证代码签名(安全检查)
- 映射到内存
- 如果这个库还依赖其他库,递归加载(这就是为什么依赖关系复杂时会很慢)
2.3 Rebase & Bind
由于 ASLR(地址空间布局随机化)的存在,每次启动时 Mach-O 被加载到内存的地址都不同。但代码里的指针是编译时确定的固定地址,所以需要修正。
Rebase(内部指针修正):
- 把 Mach-O 内部指向自己的指针,加上一个随机偏移量(slide)
- 比如:代码里写的是
0x1000,ASLR slide 是0x5000,修正后变成0x6000
Bind(外部符号绑定):
- 把 Mach-O 引用的外部符号(比如
UIKit里的UIViewController)绑定到实际的内存地址 - 需要在符号表中查找,比 Rebase 更慢
类比理解:
Rebase 就像搬家后更新通讯录里自己家人的地址(内部),Bind 就像更新朋友的地址(外部,需要打电话问)。
2.4 Objc Runtime 初始化
- 注册所有 Objective-C 类到全局类表
- 处理 Category(把 Category 中的方法附加到对应的类上)
- 确保 selector 唯一性
类越多,这一步越慢。 如果你的项目有上万个类,这里的耗时就很可观。
2.5 Initializers(+load 和静态构造函数)
这是 Pre-main 阶段最后一步,也是开发者唯一能直接控制的部分:
- 执行所有类的
+load方法(按编译顺序,先父类后子类,先主类后 Category) - 执行 C++ 的全局/静态对象的构造函数
- 执行标记了
__attribute__((constructor))的 C 函数
这些代码在 main() 之前就执行了 ,而且是在主线程上同步执行。如果你在 +load 里做了耗时操作(比如 Swizzle 大量方法、读文件、网络请求),启动就会被拖慢。
三、Post-main 阶段:你的代码在干什么?
scss
main()
│
▼
UIApplicationMain()
│
▼
application:didFinishLaunchingWithOptions:
│ ← 大量初始化代码通常堆在这里
│ SDK 初始化、数据库初始化、推送注册、路由注册...
▼
创建 UIWindow、设置 rootViewController
│
▼
首页 viewDidLoad → viewWillAppear → viewDidAppear
│
▼
首帧渲染完成 → 用户看到界面
这个阶段的耗时主要来自 didFinishLaunchingWithOptions 中的各种初始化。
四、dyld 版本演进与优化
这是重点中的重点。苹果在 dyld 上做了三次大的版本迭代,每次都大幅优化了启动速度。
4.1 dyld 1.0(远古时代,macOS 早期)
最初的版本,设计简单粗暴:
- 全量加载:启动时把所有动态库一次性全部加载到内存
- 无缓存:每次启动都重新解析、绑定
- 无优化:Rebase/Bind 逐个处理,没有批量优化
问题:随着系统库越来越多,启动速度越来越慢。
4.2 dyld 2.0(iOS 3.1 ~ iOS 12)
这是大家最熟悉的版本,做了很多重要优化:
核心改进
| 优化项 | 做了什么 | 效果 |
|---|---|---|
| 共享缓存(dyld shared cache) | 把几百个系统库预先合并成一个大的缓存文件 | 系统库加载速度大幅提升 |
| 懒绑定(Lazy Binding) | 外部符号不在启动时全部绑定,而是在第一次调用时才绑定 | 减少启动时的 Bind 耗时 |
| 符号缓存 | 缓存已解析的符号地址 | 避免重复查找 |
共享缓存(dyld shared cache)详解
这是 dyld 2 最重要的优化。
问题: 一个 App 可能依赖 300+ 个系统动态库。如果每次启动都逐个加载、解析,太慢了。
解决: 苹果在系统更新(或首次启动)时,预先把所有系统库打包合并成一个大文件,叫 dyld shared cache 。存放在 /System/Library/dyld/。
objectivec
打包前: 打包后:
UIKit.framework ─┐
Foundation.framework ├──→ dyld_shared_cache_arm64
CoreGraphics.framework│ (一个约 1-2GB 的文件)
libsystem.dylib ─┘
所有系统库的 Rebase/Bind 已经预先完成
所有系统库共用一个地址空间
好处:
- 系统库启动时只需映射这一个文件,不需要逐个解析
- Rebase/Bind 已经预先做完,启动时不需要再做
- 所有 App 共享同一份缓存,节省内存
懒绑定(Lazy Binding)
dyld 2 引入了 PLT(Procedure Linkage Table) 机制:
启动时:
外部函数调用 → PLT 桩函数 → dyld_stub_binder(绑定真实地址并修改 PLT 条目)
首次调用后:
外部函数调用 → PLT 桩函数 → 直接跳到真实地址(已经绑定好了)
意思是:你的 App 引用了 UIKit 的 100 个函数,启动时不会全部绑定。而是在你第一次调用某个函数时,才去解析它的真实地址。这样启动时的 Bind 工作就分散到了运行时。
dyld 2 的残留问题
尽管有了很多优化,dyld 2 仍然是串行、逐步执行的:
markdown
解析 Mach-O → 查找依赖库 → 逐个加载 → Rebase → Bind → 初始化
└── 每一步都在主线程上同步执行 ──┘
而且:
- 第三方动态库没法享受 shared cache
- 每次启动还是要做一遍 Rebase/Bind(对 App 自身的 Mach-O)
- 安全校验(代码签名)也是启动时做的
4.3 dyld 3.0(iOS 13+,重大重构)
dyld 3 是一次架构级别的重写,核心思想是:把能预先做的工作提前到"启动之外"去做。
三层架构
css
┌─────────────────────────────────────────────┐
│ ① 进程外的 Mach-O 解析器 │
│ (App 安装/更新时运行,不在启动路径上) │
│ │
│ - 解析 Mach-O header 和依赖关系 │
│ - 查找所有依赖库的位置 │
│ - 执行安全校验(代码签名) │
│ - 把结果写入 启动闭包(Launch Closure) │
└────────────────────┬────────────────────────┘
│ 预先计算好的结果
▼
┌─────────────────────────────────────────────┐
│ ② 启动闭包缓存 │
│ │
│ 一个预先序列化好的数据结构,包含: │
│ - 所有 dylib 的加载地址 │
│ - 所有需要的 Rebase/Bind 信息 │
│ - 初始化顺序 │
│ - 已验证的代码签名结果 │
└────────────────────┬────────────────────────┘
│ 直接读取缓存
▼
┌─────────────────────────────────────────────┐
│ ③ 进程内的引擎 │
│ (真正在 App 启动时运行的部分) │
│ │
│ - 读取启动闭包(一次 mmap) │
│ - 按预先计算好的结果直接加载 │
│ - 极少的运行时计算 │
└─────────────────────────────────────────────┘
启动闭包(Launch Closure)
这是 dyld 3 最核心的概念。
类比: dyld 2 就像每次做菜都要翻菜谱、找食材、洗切配。dyld 3 相当于提前把所有食材洗好切好配好放在盒子里(启动闭包),做菜时直接下锅就行。
闭包在什么时候创建?
- App 安装时
- App 更新时
- 系统更新时(shared cache 变了)
闭包里存了什么?
- 完整的依赖关系图
- 每个 dylib 的磁盘路径和内存加载地址
- Rebase/Bind 所需的全部信息
- 代码签名验证结果(通过/失败)
- 初始化器的执行顺序
dyld 3 vs dyld 2 对比
| 维度 | dyld 2 | dyld 3 |
|---|---|---|
| Mach-O 解析 | 每次启动都做 | 安装时做好,缓存到闭包 |
| 依赖库查找 | 每次启动都在文件系统搜索 | 闭包里已记录完整路径 |
| 代码签名校验 | 每次启动都验证 | 安装时验证,结果缓存 |
| Rebase/Bind 计算 | 每次启动都计算 | 闭包里已预计算 |
| 安全性 | 解析器在进程内,有被攻击风险 | 解析器在进程外,更安全 |
| 启动速度 | 慢 | 快 40%+ |
4.4 dyld 4.0(iOS 16+ / WWDC 2022)
dyld 4 没有大的架构变化,主要是在 dyld 3 基础上做了进一步优化:
主要改进
| 优化项 | 说明 |
|---|---|
| 统一两种模式 | dyld 3 有"有闭包"和"无闭包"两种路径(模拟器上不用闭包),dyld 4 统一成一种 |
| Just-In-Time 加载 | 更激进的懒加载,某些 dylib 推迟到真正使用时才加载 |
| 页面级别的按需加载 | 不再把整个 dylib 映射进来,而是按页(Page)按需加载 |
| 更好的 Swift 支持 | 优化了 Swift metadata 的初始化 |
| Compact Info | 用更紧凑的格式存储链接信息,减少 __LINKEDIT 段的大小 |
| Pre-warming | 系统会在后台预热高频 App 的启动闭包 |
4.5 dyld 各版本一张图总结
scss
dyld 1 → dyld 2 → dyld 3 → dyld 4
(原始) (iOS 3.1) (iOS 13) (iOS 16)
│ │ │ │
│ 共享缓存 启动闭包 统一架构
│ 懒绑定 进程外解析 按页懒加载
│ 符号缓存 签名缓存 Swift 优化
│ │ │ │
▼ ▼ ▼ ▼
全量加载 减少重复工作 大量工作移到 极致的懒加载
每次解析 分散绑定时机 安装/更新时 页级按需加载
五、启动优化实战指南
5.1 Pre-main 阶段优化
减少动态库数量
每多一个动态库,就多一次查找、加载、签名校验的过程。
| 做法 | 效果 |
|---|---|
| 合并自己的动态 framework | 直接减少加载次数 |
| 能用静态库就不用动态库 | 静态库在编译时已合并进主二进制,启动时无额外加载 |
控制 Pods 的 use_frameworks! |
改用 use_frameworks! :linkage => :static |
| 苹果建议:第三方动态库不超过 6 个 | 超过就考虑合并 |
减少 Rebase/Bind
- 减少 Objective-C 类的数量(合并功能相近的类)
- 减少 Category 的数量
- 减少 C++ 虚函数
- 用 Swift Struct 代替 OC 对象(Struct 不需要 Rebase)
干掉 +load
+load 是启动优化的头号敌人。
| 原方案 | 优化方案 |
|---|---|
在 +load 中做 Method Swizzling |
移到 +initialize(首次使用时才触发) |
在 +load 中注册路由 |
改用编译期方案(__attribute__((section)) 写入 Mach-O 段) |
在 +load 中初始化 SDK |
移到 didFinishLaunching 或更晚 |
+initialize vs +load 的关键区别:
+load:App 启动时全部执行,即使这个类从未被使用+initialize:某个类第一次收到消息时才执行,懒加载
二进制重排(Page Fault 优化)
这是近年最热门的 Pre-main 优化手段。
问题: App 启动时需要执行很多函数,但这些函数分散在不同的内存页上。每访问一个新页面就会触发一次 Page Fault(缺页中断),内核需要从磁盘加载这一页到物理内存。每次 Page Fault 大约耗时 0.1~1ms。
启动时可能触发几百到上千次 Page Fault,累计就是几百毫秒。
解决: 把启动时需要执行的函数重新排列,让它们尽量排在相邻的内存页上,减少 Page Fault 次数。
css
优化前:
┌──────┬──────┬──────┬──────┬──────┐
│ Page1│ Page2│ Page3│ Page4│ Page5│
│ A │ X │ B │ Y │ C │ 启动需要 A→B→C,触发 3 次 Page Fault
│ │ │ │ │ │
└──────┴──────┴──────┴──────┴──────┘
优化后:
┌──────┬──────┬──────┬──────┬──────┐
│ Page1│ Page2│ Page3│ Page4│ Page5│
│ A │ X │ Y │ │ │ 启动需要 A→B→C,只触发 1 次 Page Fault
│ B │ │ │ │ │
│ C │ │ │ │ │
└──────┴──────┴──────┴──────┴──────┘
怎么做?
- 用 Clang 的
-fsanitize-coverage插桩,收集启动时调用的所有函数的顺序 - 生成一个 order 文件,列出这些函数的符号名
- 在 Xcode 的 Build Settings 中设置
Order File路径 - 链接器会按照这个顺序重新排列函数在二进制中的位置
5.2 Post-main 阶段优化
分级初始化
不要把所有 SDK 初始化都堆在 didFinishLaunchingWithOptions 里。
┌─────────────────────────────────────────────────────┐
│ 分级初始化策略 │
├─────────────┬──────────────────┬────────────────────┤
│ 必须立即做 │ 首页出现后做 │ 用到时才做 │
│ │ │ │
│ 崩溃统计 │ 推送注册 │ 分享 SDK │
│ 日志系统 │ 数据统计 SDK │ 地图 SDK │
│ 网络库初始化 │ ABTest │ 支付 SDK │
│ 数据库核心表 │ 开屏广告 │ 蓝牙/定位 │
│ │ │ AI 相关 SDK │
└─────────────┴──────────────────┴────────────────────┘
首页渲染优化
| 优化手段 | 说明 |
|---|---|
| 首页用纯代码布局 | 避免 xib/storyboard 解析的开销 |
| 首页数据缓存 | 先展示上次的缓存数据,再异步请求新数据 |
| 预加载 | 在 viewDidLoad 发起网络请求,不要等 viewDidAppear |
| 骨架屏 | 先展示骨架屏,给用户"已经在加载"的感觉 |
| 减少首页层级 | AutoLayout 约束越少越好,层级越浅越好 |
子线程分担
把不依赖 UI 的初始化工作放到子线程:
主线程:UI 配置 → rootVC 创建 → 首页渲染
子线程:SDK 初始化 / 数据库 Migration / 缓存预热
注意:UIKit 相关的操作必须在主线程,但大部分 SDK 的 init 是线程安全的。
六、启动耗时测量
6.1 Pre-main 耗时
在 Xcode 的 Scheme → Arguments → Environment Variables 中添加:
ini
DYLD_PRINT_STATISTICS = 1 // 基础信息
DYLD_PRINT_STATISTICS_DETAILS = 1 // 详细信息
会输出类似:
less
Total pre-main time: 420.17 milliseconds (100.0%)
dylib loading time: 154.88 milliseconds (36.8%)
rebase/binding time: 37.43 milliseconds (8.9%)
ObjC setup time: 52.29 milliseconds (12.4%)
initializer time: 175.54 milliseconds (41.7%)
每一项对应的优化方向一目了然。
6.2 Post-main 耗时
在 main() 开头和首页 viewDidAppear 各打一个时间戳,相减就是 Post-main 耗时。
更精细的测量可以用 Instruments 的 App Launch 模板(Xcode 11+),它会自动标注各阶段的耗时。
6.3 MetricKit(线上监控)
iOS 13+ 提供了 MetricKit 框架,可以在线上采集启动耗时数据:
MXAppLaunchMetric:冷启动 / 恢复启动的耗时分布- 以直方图形式提供 P50 / P90 / P99 数据
七、优化优先级总结
按性价比从高到低排列:
| 优先级 | 优化项 | 预期收益 | 难度 |
|---|---|---|---|
| ★★★★★ | 删除 +load,改用 +initialize | 立竿见影 | 低 |
| ★★★★★ | didFinishLaunching 分级初始化 | 几十到几百 ms | 低 |
| ★★★★☆ | 减少动态库数量 / 改用静态库 | 每个库 5-10ms | 中 |
| ★★★★☆ | 首页数据缓存 | 体感提升明显 | 低 |
| ★★★☆☆ | 减少 OC 类数量 / 用 Swift Struct | Rebase 阶段提升 | 中 |
| ★★★☆☆ | 子线程并行初始化 | 分担主线程压力 | 中 |
| ★★☆☆☆ | 二进制重排 | 约 10-30% Page Fault 减少 | 高 |
| ★★☆☆☆ | 骨架屏 / 闪屏优化 | 体感优化(非真正提速) | 低 |
八、一张图总结全流程
css
用户点击图标
│
├── 内核 fork 进程,加载 Mach-O
│
├── dyld 启动
│ ├── [dyld 3/4] 读取启动闭包(大量工作已预先完成)
│ ├── 加载动态库(系统库走 shared cache,极快)
│ ├── Rebase(修复内部指针)
│ ├── Bind(绑定外部符号,非懒绑定部分)
│ └── 加载完成
│
├── Objc Runtime 初始化
│ ├── 注册所有类
│ └── 处理 Category
│
├── Initializers
│ ├── +load 方法(尽量消灭它们!)
│ └── C++ 静态构造函数
│
╞══════════════════════ main() ═══════════════
│
├── UIApplicationMain
│
├── didFinishLaunchingWithOptions
│ ├── 🔴 必须立即做的初始化
│ ├── 🟡 延迟到首页出现后
│ └── 🟢 延迟到用到时
│
├── 首页 ViewController 初始化
│ ├── viewDidLoad(发起网络请求)
│ ├── viewWillAppear
│ └── viewDidAppear ← 首帧渲染完成
│
▼
用户看到首页 ✅