Q: APP启动的详细流程是什么?预热启动(prewarm)和冷启动有什么区别
iOS 应用启动分为冷启动、热启动和预热启动三种类型。冷启动是最完整的流程,分为 Pre-main 阶段 和 main 阶段。
Pre-main 阶段:
Pre-main 阶段由 dyld(动态链接器)负责,从用户点击 App 图标到 main() 执行之前。主线程在内核 fork() 创建进程时同时创建,后续所有 Pre-main 工作都在主线程上执行。
-
加载可执行文件:内核创建进程,使用
mmap()将 Mach-O 文件映射到虚拟内存(惰性加载,只有实际访问的页才加载到物理内存)。解析 Mach-O Header(验证 Magic Number、CPU 架构匹配、文件类型识别)和 Load Commands(LC_SEGMENT_64映射内存并设置权限、LC_LOAD_DYLIB记录动态库依赖、LC_MAIN计算入口地址等),最后验证代码签名。 -
加载动态库(dyld):dyld 从主程序 Mach-O 的
LC_LOAD_DYLIB中读取依赖的动态库路径,按搜索规则查找动态库实际位置(优先从共享缓存中查找),使用mmap()映射到进程虚拟地址空间,验证代码签名,然后使用深度优先搜索递归加载每个动态库的依赖(每个库只加载一次)。最终按依赖关系构建初始化顺序——被依赖的库先于依赖方(如 Foundation 在 UIKit 之前)。如果 App 使用 Swift,此阶段还会加载 Swift 标准库(iOS 12.2+ 位于系统共享缓存,无需嵌入 App)。 -
Rebase & Bind:由于 ASLR(地址空间布局随机化),App 每次启动的加载地址不同,需要修正指针。Rebase(重定位)修正指向 Mach-O 内部的指针,将编译时地址加上 ASLR 偏移量(slide);Bind(绑定)修正指向 Mach-O 外部的指针,查找符号表绑定到正确的外部符号地址(如
_objc_msgSend)。 -
ObjC Runtime 初始化:dyld 通过
_dyld_objc_notify_register回调通知 Runtime,触发_objc_init(map_images回调)。从 Mach-O 的__DATA系列段(__DATA、__DATA_CONST、__DATA_DIRTY)读取类列表(__objc_classlist)、Category 列表(__objc_catlist)、协议列表等元数据;遍历__objc_classlist将所有类注册到全局类表(gdb_objc_realized_classes);然后对非懒加载类(实现了+load方法的类)立即 realize——调用realizeClassWithoutSwift创建可读写的class_rw_t结构(编译期的class_ro_t是只读的),建立继承链(cls->superclass)和元类关系(cls->isa),初始化方法缓存cache_t;懒加载类则延迟到首次收到消息时才 realize(objc_msgSend→lookUpImpOrForward触发),执行相同的初始化流程。遍历__objc_catlist处理 Category:若对应类已 realize,立即将 Category 的方法、属性、协议附加到其class_rw_t上(方法插入到列表前面,实现"覆盖"效果);若对应类尚未 realize,则暂存到unattachedCategories表,待类 realize 时再附加。 -
Swift Runtime 元数据注册:ObjC Runtime 完成后,dyld 触发 Swift Runtime 之前通过
_dyld_register_func_for_add_image注册的回调,遍历所有已加载镜像中的 Swift section,将元数据记录的位置指针注册到 Runtime 的全局缓存中:__swift5_types(类型元数据)、__swift5_proto(协议遵循表)、__swift5_fieldmd(字段描述符)等。此阶段只做轻量的指针注册,并不解析和实例化元数据;真正的解析延迟到首次使用时(如首次as?触发协议遵循查找、首次Mirror(reflecting:)触发字段描述符解析、泛型类型首次实例化时创建完整的 type metadata)。 -
调用 +load 方法:dyld 调用 Runtime 的
load_images回调,通过函数指针直接调用(不经过objc_msgSend),因此 Category 的+load不会覆盖主类的+load,两者都会执行。调用顺序:父类优先于子类 → 类优先于 Category → 同一镜像内按编译顺序(Build Phases → Compile Sources)→ 不同镜像按依赖顺序(被依赖的库先执行)。所有+load在主线程串行调用,直接阻塞启动。 -
执行 Initializers:dyld 遍历所有已加载镜像的
__DATA,__mod_init_funcsection 中的函数指针并调用。来源包括 C++ 静态构造函数(如static std::string s = "hello")和__attribute__((constructor))标记的函数。执行顺序:按镜像依赖顺序(被依赖的库优先),同一镜像内按 带优先级的 constructor(数字小的先执行)→ C++ 构造函数 → 不带优先级的 constructor。
main 阶段:
-
main 函数:OC 中
main()调用UIApplicationMain(需手动创建@autoreleasepool,因为此时 RunLoop 尚未启动);Swift 中@main属性让编译器自动生成入口。 -
UIApplicationMain:创建
UIApplication单例 → 创建AppDelegate→ 加载 Info.plist → 设置并启动主 RunLoop(调用CFRunLoopRun()进入主事件循环,通常长期运行、不主动返回)。注意:主 RunLoop 采用懒加载机制,首次调用[NSRunLoop mainRunLoop]时创建,在UIApplicationMain内部启动。 -
AppDelegate 回调(RunLoop 事件循环中):加载 Main Storyboard →
willFinishLaunchingWithOptions:→ 状态恢复 →didFinishLaunchingWithOptions: -
首帧渲染(RunLoop 事件循环中):创建 Window → 设置 rootViewController → viewDidLoad → viewWillAppear → layoutSubviews → drawRect → Core Animation 提交图层树 → Render Server 渲染 → GPU 合成 → 显示首帧
预热启动(iOS 15+):
系统预测用户可能启动 App,提前在后台执行部分启动流程:
| 对比项 | 冷启动 | 预热启动 |
|---|---|---|
| 触发时机 | 用户点击 App 图标 | 系统预测后自动在后台执行 |
| Pre-main 工作 | 全部在用户点击后执行 | 大部分已在后台完成 |
| 用户感知启动时间 | 包含完整 Pre-main | 只包含 +load 之后的时间 |
预热阶段已完成:加载可执行文件、加载动态库、Rebase & Bind、ObjC 类注册和 Category 处理、Swift Runtime 元数据注册
预热阶段未执行:+load 方法、Initializers(C++ 构造函数、__attribute__((constructor)))、main 阶段全部内容
可通过 ProcessInfo.processInfo.environment["ActivePrewarm"] == "1" 检测是否为预热启动,避免将后台预热时间计入用户感知的启动耗时。
Q: 如何测量和监控 iOS 应用的启动时间?
测量方案对比:
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| dyld 环境变量 | 开发调试 | 无需代码,快速查看加载信息 | 只能定性分析,无耗时数据 |
| Instruments App Launch | 深度分析 | 函数级耗时分析,最详细 | 只能线下使用,无法覆盖线上用户 |
| 代码埋点 | 开发调试 + 线上监控 | 可自定义粒度,支持线上采集 | 需要开发和维护埋点代码 |
| MetricKit | 线上监控 | 系统级数据,提供分位数分布 | iOS 13+,数据延迟(每日回调),粒度较粗 |
实际工程中,通常结合使用:开发阶段用 Instruments 深度分析;线上用代码埋点 + MetricKit 双保险。
代码埋点方案:冷启动分为 Pre-main 和 main 两个阶段,需要在关键节点插入埋点:
Pre-main 阶段埋点:
| 埋点时机 | 实现方式 | 统计内容 |
|---|---|---|
| 进程创建时间 | sysctl 获取 p_starttime | 启动起点 |
| +load 方法 | ObjC +load 方法 | dylib 加载 + Rebase/Bind + Runtime 初始化完成的时间点 |
| 高优先级 constructor | __attribute__((constructor(101))) | +load 执行完成的时间点 |
| 低优先级 constructor | __attribute__((constructor(65535))) | 大部分 Initializers 执行完成的时间点 |
main 阶段埋点:
| 埋点时机 | 实现方式 | 统计内容 |
|---|---|---|
| main 函数 | main.swift 顶部 | Pre-main 结束,main 开始 |
| willFinishLaunching | AppDelegate 回调 | main 到 willFinish 的耗时 |
| didFinishLaunching 开始/结束 | AppDelegate 回调,return 前标记 | didFinish 执行耗时 |
| 首帧渲染 | viewDidAppear + DispatchQueue.main.async | didFinish 到首帧的耗时 |
为确保埋点的 +load 方法最先执行,推荐将埋点代码打包成动态 xcframework,在 Link Binary With Libraries 中排到最前面。动态库的 +load 天然先于静态库和主工程执行。
iOS 15+ 预热启动对统计的影响:预热启动时系统会提前创建进程并执行部分启动流程(dylib 加载、Rebase/Bind、Runtime 初始化),然后挂起等待用户点击。此时 sysctl 获取的 p_starttime 是预热时的时间,可能比用户实际点击早几小时,导致统计的启动时间异常偏大。处理方案:
- 检测预热启动:在
+load中检查环境变量[NSProcessInfo.processInfo.environment[@"ActivePrewarm"] isEqualToString:@"1"] - 调整起点时间:预热启动时,以
+load执行时间作为启动起点,而非进程创建时间 - 分开统计:区分预热启动和正常启动的数据,避免数据污染
Q: 启动优化有哪些方案?
一、Pre-main 阶段优化
Pre-main 阶段的优化核心思想是减少 dyld 需要处理的工作量。
1. 减少动态库数量
动态库每增加一个,dyld 就需要多执行一次 mmap 映射、代码签名验证、Rebase/Bind 和初始化。优化方案:
| 方案 | 说明 |
|---|---|
| 合并动态库 | 将多个功能相近的动态库合并为一个,减少总数 |
| 动态库改静态库 | 静态库在编译时合并到主程序,不增加 dyld 加载开销 |
| 使用 SPM 静态链接 | SPM 默认使用静态链接,不产生额外动态库 |
| 移除未使用的动态库 | 定期审查依赖,移除不再使用的库 |
2. 优化 Rebase/Bind
Rebase 修正指向 Mach-O 内部的指针,Bind 修正指向外部的符号引用。两者的工作量与 Mach-O 中需要修正的指针数量成正比。优化方案:
| 方案 | 原理 |
|---|---|
| 减少 ObjC 类数量 | 每个 ObjC 类都有大量元数据指针需要修正(isa、superclass、方法列表、属性列表等) |
| 减少 Category | Category 的方法、属性、协议指针都需要 Rebase/Bind |
| 减少 C++ 虚函数 | 虚函数表中的每个函数指针都需要修正 |
| 用 Swift 结构体替代类 | 结构体是值类型,不产生堆上的指针需要修正 |
| 清理无用代码 | 无用的类、方法、全局变量都会增加需要修正的指针 |
3. 优化 +load 方法
所有 +load 在主线程串行调用,直接阻塞启动。优化方案:
| 方案 | 说明 |
|---|---|
用 +initialize 替代 | +initialize 是懒加载的,只在类第一次收到消息时调用,不阻塞启动 |
| 延迟到启动后执行 | 将初始化逻辑移到 didFinishLaunching 或首帧后 |
| 用静态注册替代动态注册 | 避免在 +load 中通过 Runtime 动态注册类或方法 |
| 用 Swift 替代 ObjC | 纯 Swift 类没有 +load 机制 |
4. 优化 Initializers
C++ 静态构造函数和 __attribute__((constructor)) 也在主线程同步执行。优化方案:
| 方案 | 说明 |
|---|---|
| 延迟初始化 | 将非必要的全局初始化延迟到首次使用时 |
| 用 Swift 懒加载 | Swift 的 lazy 属性和全局变量天然支持懒加载 |
| 移除不必要的 constructor | 审查项目中的 __attribute__((constructor)),移除非必要的 |
| 用基本类型替代复杂类型 | 基本类型(int、const char*)的全局变量不需要构造函数 |
5. 二进制重排
App 的 Mach-O 会先通过 mmap() 映射到进程的虚拟地址空间,这一步不代表所有代码页都已经进入物理内存。启动过程中真正执行到某个函数时,如果该函数所在的代码页还没有驻留在物理内存中,就会触发 Page Fault(缺页中断),内核再从 App 二进制文件中读取该页内容并填充到物理页。
如果启动阶段调用的函数分散在不同的代码页中,冷启动时就可能产生较多 Page Fault。二进制重排将启动阶段调用的函数集中排列到相邻的代码页中,减少需要按需调入物理内存的离散代码页数量。
实现步骤:
- 插桩收集:使用 Clang SanitizerCoverage(
-fsanitize-coverage=func,trace-pc-guard)在每个函数入口插入回调 - 生成 Order File:运行 App 收集启动阶段的函数调用顺序,生成函数排列文件
- 链接器重排:通过 Xcode 的
Order File配置项指定文件路径,链接器按照指定顺序排列函数 - 验证效果:通过 Instruments 的 System Trace 观察 Page Fault 数量的变化
二、main 阶段优化
main 阶段从 main() 函数开始到首帧渲染完成,是开发者最能直接控制的阶段,通常也是优化的重点。
1. 启动任务分级管理
将 didFinishLaunching 中的初始化任务按优先级分级,而非全部同步执行:
| 级别 | 执行时机 | 包含任务 |
|---|---|---|
| P0 - 关键 | didFinishLaunching 同步执行 | 崩溃监控、日志系统、网络库配置、首屏数据请求 |
| P1 - 重要 | didFinishLaunching 异步执行 | 推送注册、数据库初始化、非首屏 SDK |
| P2 - 可延迟 | 首帧后或 RunLoop 空闲时 | 统计 SDK、分享 SDK、广告 SDK、预加载缓存 |
2. 并行初始化
利用 GCD 并发队列将无依赖关系的初始化任务并行执行,充分利用多核 CPU。但需注意:涉及 UI 操作的初始化必须在主线程执行。
3. 利用 RunLoop 空闲时机
在 kCFRunLoopBeforeWaiting 时注册 Observer,将低优先级任务拆分为多个小任务,在 RunLoop 每次空闲时执行一批。这样既不阻塞启动,也不影响用户交互。
4. 延迟加载
| 方案 | 说明 |
|---|---|
| 首屏无关的 SDK 延迟初始化 | 分享、支付、地图等 SDK 在用户首次使用相关功能时再初始化 |
| 非必要视图懒加载 | TabBar 中非首页的 ViewController 延迟到切换时再创建 |
| 首屏数据预加载 | 在后台提前加载首屏数据,避免白屏等待 |