iOS必会面试题 - App 启动与优化

7 阅读11分钟

Q: APP启动的详细流程是什么?预热启动(prewarm)和冷启动有什么区别

iOS 应用启动分为冷启动、热启动和预热启动三种类型。冷启动是最完整的流程,分为 Pre-main 阶段main 阶段

Pre-main 阶段

Pre-main 阶段由 dyld(动态链接器)负责,从用户点击 App 图标到 main() 执行之前。主线程在内核 fork() 创建进程时同时创建,后续所有 Pre-main 工作都在主线程上执行。

  1. 加载可执行文件:内核创建进程,使用 mmap() 将 Mach-O 文件映射到虚拟内存(惰性加载,只有实际访问的页才加载到物理内存)。解析 Mach-O Header(验证 Magic Number、CPU 架构匹配、文件类型识别)和 Load Commands(LC_SEGMENT_64 映射内存并设置权限、LC_LOAD_DYLIB 记录动态库依赖、LC_MAIN 计算入口地址等),最后验证代码签名。

  2. 加载动态库(dyld):dyld 从主程序 Mach-O 的 LC_LOAD_DYLIB 中读取依赖的动态库路径,按搜索规则查找动态库实际位置(优先从共享缓存中查找),使用 mmap() 映射到进程虚拟地址空间,验证代码签名,然后使用深度优先搜索递归加载每个动态库的依赖(每个库只加载一次)。最终按依赖关系构建初始化顺序——被依赖的库先于依赖方(如 Foundation 在 UIKit 之前)。如果 App 使用 Swift,此阶段还会加载 Swift 标准库(iOS 12.2+ 位于系统共享缓存,无需嵌入 App)。

  3. Rebase & Bind:由于 ASLR(地址空间布局随机化),App 每次启动的加载地址不同,需要修正指针。Rebase(重定位)修正指向 Mach-O 内部的指针,将编译时地址加上 ASLR 偏移量(slide);Bind(绑定)修正指向 Mach-O 外部的指针,查找符号表绑定到正确的外部符号地址(如 _objc_msgSend)。

  4. ObjC Runtime 初始化:dyld 通过 _dyld_objc_notify_register 回调通知 Runtime,触发 _objc_initmap_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_msgSendlookUpImpOrForward 触发),执行相同的初始化流程。遍历 __objc_catlist 处理 Category:若对应类已 realize,立即将 Category 的方法、属性、协议附加到其 class_rw_t 上(方法插入到列表前面,实现"覆盖"效果);若对应类尚未 realize,则暂存到 unattachedCategories 表,待类 realize 时再附加。

  5. 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)。

  6. 调用 +load 方法:dyld 调用 Runtime 的 load_images 回调,通过函数指针直接调用(不经过 objc_msgSend),因此 Category 的 +load 不会覆盖主类的 +load,两者都会执行。调用顺序:父类优先于子类 → 类优先于 Category → 同一镜像内按编译顺序(Build Phases → Compile Sources)→ 不同镜像按依赖顺序(被依赖的库先执行)。所有 +load 在主线程串行调用,直接阻塞启动。

  7. 执行 Initializers:dyld 遍历所有已加载镜像的 __DATA,__mod_init_func section 中的函数指针并调用。来源包括 C++ 静态构造函数(如 static std::string s = "hello")和 __attribute__((constructor)) 标记的函数。执行顺序:按镜像依赖顺序(被依赖的库优先),同一镜像内按 带优先级的 constructor(数字小的先执行)→ C++ 构造函数 → 不带优先级的 constructor。

main 阶段

  1. main 函数:OC 中 main() 调用 UIApplicationMain(需手动创建 @autoreleasepool,因为此时 RunLoop 尚未启动);Swift 中 @main 属性让编译器自动生成入口。

  2. UIApplicationMain:创建 UIApplication 单例 → 创建 AppDelegate → 加载 Info.plist → 设置并启动主 RunLoop(调用 CFRunLoopRun() 进入主事件循环,通常长期运行、不主动返回)。注意:主 RunLoop 采用懒加载机制,首次调用 [NSRunLoop mainRunLoop] 时创建,在 UIApplicationMain 内部启动。

  3. AppDelegate 回调(RunLoop 事件循环中):加载 Main Storyboard → willFinishLaunchingWithOptions: → 状态恢复 → didFinishLaunchingWithOptions:

  4. 首帧渲染(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 开始
willFinishLaunchingAppDelegate 回调main 到 willFinish 的耗时
didFinishLaunching 开始/结束AppDelegate 回调,return 前标记didFinish 执行耗时
首帧渲染viewDidAppear + DispatchQueue.main.asyncdidFinish 到首帧的耗时

为确保埋点的 +load 方法最先执行,推荐将埋点代码打包成动态 xcframework,在 Link Binary With Libraries 中排到最前面。动态库的 +load 天然先于静态库和主工程执行。

iOS 15+ 预热启动对统计的影响:预热启动时系统会提前创建进程并执行部分启动流程(dylib 加载、Rebase/Bind、Runtime 初始化),然后挂起等待用户点击。此时 sysctl 获取的 p_starttime 是预热时的时间,可能比用户实际点击早几小时,导致统计的启动时间异常偏大。处理方案:

  1. 检测预热启动:在 +load 中检查环境变量 [NSProcessInfo.processInfo.environment[@"ActivePrewarm"] isEqualToString:@"1"]
  2. 调整起点时间:预热启动时,以 +load 执行时间作为启动起点,而非进程创建时间
  3. 分开统计:区分预热启动和正常启动的数据,避免数据污染

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、方法列表、属性列表等)
减少 CategoryCategory 的方法、属性、协议指针都需要 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。二进制重排将启动阶段调用的函数集中排列到相邻的代码页中,减少需要按需调入物理内存的离散代码页数量。

实现步骤:

  1. 插桩收集:使用 Clang SanitizerCoverage(-fsanitize-coverage=func,trace-pc-guard)在每个函数入口插入回调
  2. 生成 Order File:运行 App 收集启动阶段的函数调用顺序,生成函数排列文件
  3. 链接器重排:通过 Xcode 的 Order File 配置项指定文件路径,链接器按照指定顺序排列函数
  4. 验证效果:通过 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 延迟到切换时再创建
首屏数据预加载在后台提前加载首屏数据,避免白屏等待

更多经典面试问题

github.com/ChaselAn/aw…