iOS 应用启动流程与优化详解

一、什么算"启动"?

从用户点击 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):几个到几十个

每个动态库的加载过程:

  1. 从磁盘找到 .dylib 文件
  2. 验证代码签名(安全检查)
  3. 映射到内存
  4. 如果这个库还依赖其他库,递归加载(这就是为什么依赖关系复杂时会很慢)

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    │      │      │      │      │
└──────┴──────┴──────┴──────┴──────┘

怎么做?

  1. 用 Clang 的 -fsanitize-coverage 插桩,收集启动时调用的所有函数的顺序
  2. 生成一个 order 文件,列出这些函数的符号名
  3. 在 Xcode 的 Build Settings 中设置 Order File 路径
  4. 链接器会按照这个顺序重新排列函数在二进制中的位置

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 ← 首帧渲染完成
    │
    ▼
用户看到首页 ✅
相关推荐
忆江南2 小时前
HTTP 各版本演进与 HTTPS 原理详解
前端
忆江南2 小时前
对组件化与模块化的思考与总结
前端
小码哥_常2 小时前
从0到1:Android组件化架构搭建秘籍
前端
itslife2 小时前
前端架构模式思考
前端·架构
Wect2 小时前
JSX & ReactElement 核心解析
前端·react.js·面试
雨落Re2 小时前
从递归组件到 DSL 引擎:我造了一个让 AI 能"搭 UI"的运行时
前端·vue.js
Maxkim2 小时前
前端工程化落地指南:pnpm workspace + Monorepo 核心用法与实践
前端·javascript·架构
大漠_w3cpluscom2 小时前
使用 clip-path: shape() 创建 Squircle 形状
前端·css·weui
大怪v14 小时前
AI抢饭?前端佬:我要验牌!
前端·人工智能·程序员