APP启动缓慢可能会导致用户流失、负面评价甚至卸载. iOS系统经过多年的演进,已经形成了一套复杂而精密的启动机制。本文将从内核加载到首帧渲染,,深入剖析iOS应用启动的全过程。
本文将以启动过程、监控、优化三部分来讲解过往实践总结。
一、iOS启动过程
如果你了解或做过启动优化,你会发现iOS中其实有很多的启动场景,那么具体有哪些场景?区别是什么?
1. 启动场景-冷启动作为优化目标
让我们先来了解iOS的启动场景:
- 冷启动(Cold Launch):系统不存在 App进程,也没有进程缓存信息,系统创建进程启动。
- 热启动(Warm Launch):App 最近被关闭,进程已销毁,但部分数据仍被系统缓存。
- 回前台(Resume/Foreground):用户回到桌面,App 未被关闭,只是切到了后台(挂起状态),进程和数据都还在内存中。
- 后台启动(Background Launch) :系统因后台任务(如 Background Fetch、推送静默通知、VoIP 唤醒)唤醒或创建进程 → 执行有限时间代码(30 秒) → 系统可能再次挂起或终止进程。用户完全无感知。
- 预热启动(Prewarming Launch):系统为了加快启动速度,会智能预测提前帮你启动APP到后台(执行didFinishLaunching但不初始化UI),这样你点开时系统从暂停点继续执行,跳过了耗时的初始化过程,实现了"秒开"。
| 类型 | 进程状态 | 是否需要创建进程 | 是否需要加载资源 | 速度 | 典型场景 |
|---|---|---|---|---|---|
| 冷启动 | 不存在 | ✅ 是 | ✅ 全部 | 最慢 | 重启手机后首次打开App |
| 热启动 | 刚被杀死,缓存还在 | ✅ 是 | 大部分 | 较快 | 手动杀掉后台,再立即打开 |
| 回前台 | 存活(挂起状态) | ❌ 否 | ❌ 否 | 最快 | 切到后台几秒后又切回来 |
| 后台启动 | 不存在、挂起 | 不一定 | 不一定 | 瞬间(后台) | 后台刷新、推送唤醒 |
| 预热启动 | 系统预创建 | ⚠️ 提前创建 | ⚠️ 部分 | 极快 | iOS 15+ 预测你要打开该 App |
综上,冷启动涉及启动全过程,我们应该以冷启动速度作为目标。
2. 冷启动全过程图解

阶段一: 2.1 虚拟内存分配-128TB巨大内存
系统通过"虚拟内存分配"给进程创造一个独立、连续、安全的内存世界 。试想如果没有虚拟内存,所有进程(你的 App、微信、系统设置)都直接操作物理内存,那会是怎样的混乱和复杂度?
系统创建进程后会为进程分配地址空间,具体的大小根据CPU架构决定,当前iOS设备实际为128TB,这对移动设备已足够。
2.2 dyld通过mmap加载MachO
dyld 不是"创建", 而是系统预置的二进制文件(/usr/lib/dyld),内核将其映射到新进程的地址空间。 dyld 加载库时使用了mmap(内存映射技术),而不是"整个文件加载"。
- 加载主二进制machO并解析依赖库
- Debug下额外加载调试动态库
- 加载依赖系统动态库(共享缓存)
- 递归加载依赖APP内动态库
动态库的加载顺序由依赖关系决定,而非简单的字母排序:
- 依赖关系优先:被依赖的库先于依赖者加载
- 同一层级顺序:无依赖关系的库按Mach-O中的出现顺序
调试环境差异:在Xcode调试时,会注入调试库(如libBacktraceRecording.dylib、libMainThreadChecker.dylib),这些库会在主程序之前加载。
2.3 Rebase与ASLR处理
由于iOS库加载使用ASLR(地址空间布局随机化),所有库中指向自己的指针地址需要重新计算:
c
// Rebase过程
new_address = original_address + aslr_slide
// ASLR偏移是启动时确定的随机值
// 需要遍历所有指针并加上这个偏移
2.4 符号绑定(Bind)
符号绑定是将符号引用解析为实际地址的过程,库中引用外部动态库的符号需要绑定到真实的内存地址。分为三个阶段:
减少启动时间] C --> F[启动时立即绑定
确保符号可用] D --> G[运行时决定是否绑定
符号可能不存在] E --> H[通过PLT/GOT完成] F --> I[直接修改__DATA段] G --> J[不影响启动]
绑定过程技术细节:
- 解析Mach-O中的LC_DYLD_INFO命令
- 遍历需要绑定的符号列表
- 在符号表中查找对应符号
- 将实际地址写入__DATA段的相应位置
2.5 ObjC运行时初始化
- 注册所有ObjC类和方法
- 建立方法列表和协议映射表
- 初始化Category
- 准备消息发送机制
2.6 +load方法执行
所有OC类的+load方法在这个阶段执行,执行顺序:
- 所有类的+load方法(先父类后子类,没有继承关系按编译顺序)
- Category的+load方法(编译顺序)
(注意:Swift中没有load和initiallize方法)
2.7 C++静态初始化阶段
这是启动过程中常被忽视但影响重大的阶段。C++静态初始化的两个阶段:
- 静态存储期初始化:零初始化和常量初始化(编译期确定)
- 动态初始化:调用构造函数、执行复杂表达式(运行时)
C++构造函数
无论是 C++ 全局对象的构造函数,还是用 __attribute__((constructor)) 修饰的 C 函数,编译器都会将它们放到 Mach-O 文件的同一个段中:
- 段名 :
__DATA,__mod_init_func - 内容 :一个函数指针数组,指向所有需要在
main()之前执行的初始化函数。
当 dyld 完成动态链接后,会执行:
js
// dyld 伪代码
void doModInitFunctions(const Image* image) {
void** funcs = image->getSegmentData("__DATA", "__mod_init_func");
int count = image->getSegmentSize("__DATA", "__mod_init_func") / sizeof(void*);
for (int i = 0; i < count; i++) {
funcs[i](); // 逐个调用所有初始化函数
}
}
// dyld 根本不区分这个函数指针是来自 C++ 构造函数还是 `__attribute__((constructor))`,
// 它只是无差别地调用。
调用顺序:+load 先于 constructor
在任何一个单一的镜像文件(Image,指可执行文件或动态库)的加载过程中,dyld 严格按照以下顺序执行初始化①②:
由于 runtime 的初始化在 dyld 处理
__mod_init_func段之前,所以+load自然先执行。
性能影响:全局C++对象的构造函数可能执行昂贵操作(文件I/O、网络请求、复杂计算),显著拖慢启动。
阶段二:2.8 main()函数
objective-c
int main(int argc, char * argv[]) {
@autoreleasepool {
return UIApplicationMain(argc, argv, nil,
NSStringFromClass([AppDelegate class]));
}
}
2.9 UIApplicationMain启动流程
- 创建UIApplication单例实例
- 创建AppDelegate实例
- 建立应用运行环境
- 调用
application:didFinishLaunchingWithOptions:
2.10 首帧渲染关键路径
markdown
1. 加载Main.storyboard或根视图控制器
2. 执行viewDidLoad方法
3. 执行viewWillAppear方法
4. 布局视图(viewWillLayoutSubviews)
5. 渲染视图(drawRect:)
6. 执行viewDidAppear方法/applicationDidBecomeActive
二、系统做的优化
1. iOS13-启动闭包(Launch Closure)
iOS 13 引入了新一代的动态链接器 dyld3,并引入了"启动闭包"的概念。在系统更新、App 更新或重启后首次启动时,系统会创建一个包含预加载和预绑定信息的闭包。这大大减少了后续启动时动态库加载、符号绑定和初始化的工作量,是启动速度提升的关键技术基础。
启动闭包内容构成:依赖动态库列表(depends)、绑定和重基地址(fixup: bind & rebase)、初始化调用顺序(initializer-order)以及 Objective-C 优化信息(optimizeObjc)等。
核心机制:Out-of-Process 预计算
dyld3 最大的架构变化,是把加载过程分为了"Out-of-Process(进程外) "和"In-Process(进程内) "两部分①。
-
进程外 (Out-of-Process) 预计算 :这是一个独立的系统进程,它会在App安装、更新或系统重启后自动运行一次①。
- 它会解析 App 的可执行文件(Mach-O)及其所有依赖动态库 参考。
- 将这一过程产生的所有关键信息 打包成一个"启动闭包"文件,保存在
tmp/com.apple.dyld目录下。 - 这些信息包括:依赖库列表、需要修复(rebase/bind)的地址、初始化顺序(initializer-order),以及优化过的 Objective-C 类/方法元数据等。
-
进程内 (In-Process) 快速执行:当用户下次点击 App 图标时,dyld3 不再重新解析和计算。
- 它直接读取之前生成好的"启动闭包"文件参考①参考②。
- 由于所有解析工作都已经完成,dyld3 只需要根据闭包内的指令,快速地将镜像加载和初始化即可。 对启动闭包进一步优化时间可参考# 得物 iOS 启动优化
2. iOS15-链式修复(Chained Fixup)
iOS 15 引入的链式修复机制,是 dyld 在动态链接领域的又一次重大优化。它彻底改变了 App 启动时修正地址的传统方式,将独立的操作表重组为按物理页组织的链表 ,以空间换时间,将随机 I/O 转化为顺序遍历,大幅提升了启动速度。
A.传统方案的瓶颈?
App 启动时的 fixups 主要包含两步:Rebase(基址修复) 、Bind(符号绑定)
传统方案下,这两个操作是独立的表,dyld 必须先全量处理完所有 Rebase,再全量处理所有 Bind 。这意味着 dyld 要在内存里来来回回跑两遍,导致大量的内存页被重复访问和标记为"脏",性能开销很大。
B.链式修复是如何工作的?
链式修复的核心思想是:把分散在多个表中的修正信息,按内存物理页(16KB)为单位,串成一条条链表 。 这种设计让 dyld 在遍历内存时,访问是顺序的,完美契合了 CPU 的缓存预取机制,将随机的磁盘 I/O 开销降到了最低。
新结构取代旧命令
在 iOS 15 的二进制文件中,旧的
LC_DYLD_INFO_ONLY加载命令被替换成了LC_DYLD_CHAINED_FIXUPS(链式修正信息)和LC_DYLD_EXPORTS_TRIE(导出符号信息)。每个指针即是"节点"
现在,需要进行修正的地址本身被设计成了一个精巧的"链表节点"。例如,一个普通的 64 位指针,不再只存一个地址,其内部被拆分为不同的位域,用来存储:
target(36位) :指向最终需要修正到的目标地址偏移。next(12位) :本页内下一个需要修正的指针地址,偏移范围正好是 0~16KB(一个页面大小)。bind(1位) :标识这是一个 Rebase 操作还是 Bind 操作。处理流程:一"镜"到底
dyld 不再需要遍历全局大表。现在,它会:
- 跳转到每个内存页的起始修正点。
- 读取该指针,根据
bind位判断是 Rebase 还是 Bind,并立即执行。- 根据
next字段,直接定位到本页内的下一个需要修正的地址。- 重复步骤 2,直到
next字段为 0,表示本页修正结束。
3. iOS15-预热启动(Prewarming Launch)
系统为了加快启动速度,会智能预测提前帮你启动APP到后台(执行didFinishLaunching但不初始化UI),这样你点开时系统从暂停点继续执行,跳过了耗时的初始化过程,实现了"秒开"。一般siri建议、Spotlight、小组件、系统预测时会启动(可能几分钟后被杀)。
js
// 判断是否预热启动
ProcessInfo.processInfo.environment["ActivePrewarm"] == "1"
三、内存分页与启动优化
4.1 内存分页机制
在iOS启动过程中,方法在内存中的布局和分页由编译器和链接器协同决定:
- Mach-O段和节结构:__TEXT段存放代码,__DATA段存放数据
- 对齐约束:每个Section有特定的对齐要求(如__text为16字节)
- 链接器布局算法:决定方法在内存中的最终位置
- 页面大小边界:iOS使用16KB页面大小
4.1 为什么方法同页重要?
物理内存映射机制 :一个虚拟内存页对应一个物理页,按需加载;在方法调用时其代码未载入内存会产生一次Page Fault。
c
**Page Fault的昂贵成本**:
// 一次Page Fault包含:
1. 陷入内核(上下文切换)≈ 1000 cycles
2. 查找页表项 ≈ 200 cycles
3. 分配物理页 ≈ 500 cycles
4. 磁盘I/O(最贵!)≈ 10,000-100,000 cycles
5. 建立映射 ≈ 300 cycles
6. 返回用户态 ≈ 1000 cycles
// 总计:≈ 13,000-103,000 cycles
// 对比:L1缓存命中 ≈ 4 cycles
如果将启动关键方法同页化(二进制重排),那么就可以减少Page Fault次数 、降低 I/O 开销,从而提升启动速度。二进制重排的具体操作可以查看参考文章。
4.3 静态库与动态库的分页差异
静态库:
- 代码在链接时被拆散并合并到主Mach-O中
- 与主程序代码共享内存区域和分页行为
- 链接器可以进行全局优化布局
- 启动相关代码可集中到同一页
动态库依赖的静态库:
- 静态库代码被合入到依赖它的动态库中
- 与动态库自身代码共享相同的内存映射区域
- 所有代码(动态库+静态库)统一分页加载
多个自定义动态库的问题:
- 每个动态库有独立的内存区域
- 方法天然分散在不同页
- 导致更多Page Fault和缓存未命中
- 40个动态库可能导致40+次额外Page Fault
四、启动耗时统计与监控
方式一:自定义监控
主要关注指标TTID、TTFD。
TTID: 起点=进程创建时间;结束点=ViewDidAPPear.
TTFD: 起点=进程创建时间;结束点=首页初始化请求完成并刷新完整页面.
为什么可以这样定?先来看下启动过程的生命周期函数调用过程:
Swift
1. 系统加载动态库和静态初始化器。
2. 执行 main() 函数,调用 UIApplicationMain。
3. AppDelegate 调用:
- application:willFinishLaunchingWithOptions:
- application:didFinishLaunchingWithOptions:
4. (iOS 13+)SceneDelegate 调用:
5.scene:willConnectToSession:options:
- 创建 UIWindow 和根视图控制器。
- 初始 UIViewController 调用:
6. - viewDidLoad() → viewWillAppear: → viewDidAppear:
7. applicationDidBecomeActive:(或 SceneDelegate 的 sceneDidBecomeActive:)
// 注意:viewDidAppeare方法和didBecomeActive方法的顺序实际打印出来是不一定的。
// AAAA:自己工程里面纯代码未使用scene,是先didAppeare再didBecomeActive
["DidFinishLaunching", "viewDidLoad", "viewWillAppear", "viewDidAppear", "DidBecomeActive"]
// BBBB:新建工程里确是先didBecomeActive
📍 AppDelegate.init
📍 AppDelegate.didFinishLaunching
📍 ViewController.init(coder:)
SceneDelegate.willConnectTo
📍 ViewController.loadView
📍 ViewController.viewDidLoad
📍 ViewController.viewWillAppear
SceneDelegate.sceneWillEnterForeground(未调用AppDelegate对应方法)
SceneDelegate.sceneDidBecomeActive(未调用AppDelegate对应方法)
📍 ViewController.viewDidAppear
由此我们可以把启动监控起点定在:进程创建的时间;终点定在:applicationDidBecomeActive或者viewDidAppear。
swift
// 启动的起点可以获取进程的创建时间
func logProcessStartTime() {
let pid = getpid()
var info = kinfo_proc()
var size = MemoryLayout<kinfo_proc>.size
var mib = [CTL_KERN, KERN_PROC, KERN_PROC_PID, pid]
if sysctl(&mib, UInt32(mib.count), &info, &size, nil, 0) == 0 {
let startTime = TimeInterval(info.kp_proc.p_starttime.tv_sec) +
TimeInterval(info.kp_proc.p_starttime.tv_usec) / 1_000_000.0
print("Process started at: \(Date(timeIntervalSince1970: startTime))")
}
}
方式二:Apple的MetricKit:
swift
import MetricKit
// iOS 13以上
class MetricsManager: MXMetricManagerSubscriber {
func didReceive(_ payloads: [MXMetricPayload]) {
for payload in payloads {
if let launchMetrics = payload.applicationLaunchMetrics {
let duration = launchMetrics.timeToFirstDraw
// 处理启动耗时数据
}
}
}
}
五、启动优化实战策略
1. Pre-main阶段优化
去除无用代码
去除无用代码:无用类、方法、协议等;无用图片、资源文件等.
减少动态库数量:
- 合并小功能库
- 使用静态库替代动态库
- 相同功能库改为使用同一个
- 去除未动态库
- Debug下的功能库改为Debug才引入
- 如有必要可以采取懒加载(dlopen的方式在用到时手动加载)
控制相关方法调用
-
+load方法:避免在+load中执行耗时操作,将初始化延迟到首次使用时
-
优化C++静态初始化:消除或延迟全局C++对象构造,避免执行耗时的初始化操作,将部分功能延迟到首次调用时。
-
fishhook尽量不放到启动过程。
二进制重排
先收集启动期间的方法调用顺序放到文件中,然后设置到工程的Order file编译设置中。
收集调用方法可以使用LLVM插桩的方式:简单来说 SanitizerCoverage 是 Clang 内置的一个代码覆盖工具。它把一系列以 __sanitizer_cov_trace_pc_ 为前缀的函数调用插入到用户定义的函数里,借此实现了全局 AOP 的大杀器。其覆盖之广,包含 Swift/Objective-C/C/C++ 等语言,Method/Function/Block 全支持。
相关方法已经有人做成了开源库,现在很简单就能收集到,具体文章在文末。
监控Page Fault:
objective-c
// 监控Page Fault数量
task_vm_info_data_t vm_info;
mach_msg_type_number_t count = TASK_VM_INFO_COUNT;
task_info(mach_task_self(), TASK_VM_INFO, (task_info_t)&vm_info, &count);
NSLog(@"Page Faults: %llu", vm_info.faults);
2. main函数后阶段优化
- main函数后用启动任务管理器来管理任务优先级和多线程调度,将不影响效果的功能后移到viewDidAppear之后执行。
- 优化webview useragent、keychain获取、Userdefault、定位权限、WIFI信息等xpc跨进程通信的动作
- 优化广告加载、bundle中image获取、一键登录预取号逻辑
六、调试方法
6.1 动态库加载顺序分析
objective-c
// 打印当前加载的所有库
+ (void)load {
uint32_t count = _dyld_image_count();
NSLog(@"=== 库加载顺序验证 ===");
for (uint32_t i = 0; i < count; i++) {
const char *name = _dyld_get_image_name(i);
NSLog(@"%2d: %s", i, name);
}
}
6.2 启动闭包调试
bash
# 调试启动闭包
DYLD_PRINT_CLOSURES=1 # 打印闭包使用情况
DYLD_DISABLE_CLOSURES=1 # 禁用闭包(强制传统启动)
DYLD_FORCE_CLOSURE_REBUILD=1 # 强制重建闭包
DYLD_PRINT_STATISTICS=1 # 包含闭包节省的时间
6.3 编译顺序查看
可以通过查看LinkMap的查看:## 通过LinkMap来了解Mach-O
6.4 性能分析工具
- Instruments - System Trace:分析系统调用和Page Fault
- Instruments - Time Profiler:分析CPU时间分布
- Instruments - App Launch Template:专门分析启动性能
- Xcode Organizer:查看真实用户启动数据
九、总结与最佳实践
在货拉拉出行乘客端中,我们做完优化之后,3秒内完成启动设备数从85% -》99%

结语
iOS应用启动过程是一个复杂而精密的系统工程,涉及操作系统内核、动态链接器、编译器和运行时等多个层面的协同工作。通过深入理解启动过程的每个阶段,我们可以有针对性地进行优化,显著提升应用启动速度,改善用户体验。
作为开发者,我们需要保持学习的态度,持续探索和实践,为用户打造更快、更好的应用体验。
参考资料: