iOS应用启动过程深度分析与优化实践

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内动态库

动态库的加载顺序由依赖关系决定,而非简单的字母排序:

  1. 依赖关系优先:被依赖的库先于依赖者加载
  2. 同一层级顺序:无依赖关系的库按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)

符号绑定是将符号引用解析为实际地址的过程,库中引用外部动态库的符号需要绑定到真实的内存地址。分为三个阶段:

graph TD A[符号绑定] --> B[延迟绑定 Lazy Binding] A --> C[非延迟绑定 Non-Lazy Binding] A --> D[弱符号绑定 Weak Binding] B --> E[第一次使用时绑定
减少启动时间] C --> F[启动时立即绑定
确保符号可用] D --> G[运行时决定是否绑定
符号可能不存在] E --> H[通过PLT/GOT完成] F --> I[直接修改__DATA段] G --> J[不影响启动]

绑定过程技术细节

  1. 解析Mach-O中的LC_DYLD_INFO命令
  2. 遍历需要绑定的符号列表
  3. 在符号表中查找对应符号
  4. 将实际地址写入__DATA段的相应位置

2.5 ObjC运行时初始化

  • 注册所有ObjC类和方法
  • 建立方法列表和协议映射表
  • 初始化Category
  • 准备消息发送机制

2.6 +load方法执行

所有OC类的+load方法在这个阶段执行,执行顺序:

  1. 所有类的+load方法(先父类后子类,没有继承关系按编译顺序)
  2. Category的+load方法(编译顺序)

(注意:Swift中没有load和initiallize方法)

2.7 C++静态初始化阶段

这是启动过程中常被忽视但影响重大的阶段。C++静态初始化的两个阶段

  1. 静态存储期初始化:零初始化和常量初始化(编译期确定)
  2. 动态初始化:调用构造函数、执行复杂表达式(运行时)

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启动流程

  1. 创建UIApplication单例实例
  2. 创建AppDelegate实例
  3. 建立应用运行环境
  4. 调用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(进程内) "两部分

  1. 进程外 (Out-of-Process) 预计算 :这是一个独立的系统进程,它会在App安装、更新或系统重启后自动运行一次

    • 它会解析 App 的可执行文件(Mach-O)及其所有依赖动态库 参考
    • 将这一过程产生的所有关键信息 打包成一个"启动闭包"文件,保存在 tmp/com.apple.dyld 目录下。
    • 这些信息包括:依赖库列表、需要修复(rebase/bind)的地址、初始化顺序(initializer-order),以及优化过的 Objective-C 类/方法元数据等。
  2. 进程内 (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 开销降到了最低。

  1. 新结构取代旧命令

    在 iOS 15 的二进制文件中,旧的 LC_DYLD_INFO_ONLY 加载命令被替换成了 LC_DYLD_CHAINED_FIXUPS(链式修正信息)和 LC_DYLD_EXPORTS_TRIE(导出符号信息)。

  2. 每个指针即是"节点"

    现在,需要进行修正的地址本身被设计成了一个精巧的"链表节点"。例如,一个普通的 64 位指针,不再只存一个地址,其内部被拆分为不同的位域,用来存储:

    • target (36位) :指向最终需要修正到的目标地址偏移。
    • next (12位)本页内下一个需要修正的指针地址,偏移范围正好是 0~16KB(一个页面大小)。
    • bind (1位) :标识这是一个 Rebase 操作还是 Bind 操作。
  3. 处理流程:一"镜"到底

    dyld 不再需要遍历全局大表。现在,它会:

    1. 跳转到每个内存页的起始修正点。
    2. 读取该指针,根据 bind 位判断是 Rebase 还是 Bind,并立即执行。
    3. 根据 next 字段,直接定位到本页内的下一个需要修正的地址。
    4. 重复步骤 2,直到 next 字段为 0,表示本页修正结束。

3. iOS15-预热启动(Prewarming Launch)

系统为了加快启动速度,会智能预测提前帮你启动APP到后台(执行didFinishLaunching但不初始化UI),这样你点开时系统从暂停点继续执行,跳过了耗时的初始化过程,实现了"秒开"。一般siri建议、Spotlight、小组件、系统预测时会启动(可能几分钟后被杀)。

js 复制代码
// 判断是否预热启动
ProcessInfo.processInfo.environment["ActivePrewarm"] == "1"

三、内存分页与启动优化

4.1 内存分页机制

在iOS启动过程中,方法在内存中的布局和分页由编译器和链接器协同决定

  1. Mach-O段和节结构:__TEXT段存放代码,__DATA段存放数据
  2. 对齐约束:每个Section有特定的对齐要求(如__text为16字节)
  3. 链接器布局算法:决定方法在内存中的最终位置
  4. 页面大小边界: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 性能分析工具

  1. Instruments - System Trace:分析系统调用和Page Fault
  2. Instruments - Time Profiler:分析CPU时间分布
  3. Instruments - App Launch Template:专门分析启动性能
  4. Xcode Organizer:查看真实用户启动数据

九、总结与最佳实践

在货拉拉出行乘客端中,我们做完优化之后,3秒内完成启动设备数从85% -》99%

结语

iOS应用启动过程是一个复杂而精密的系统工程,涉及操作系统内核、动态链接器、编译器和运行时等多个层面的协同工作。通过深入理解启动过程的每个阶段,我们可以有针对性地进行优化,显著提升应用启动速度,改善用户体验。

作为开发者,我们需要保持学习的态度,持续探索和实践,为用户打造更快、更好的应用体验。


参考资料

  1. Apple Developer Documentation - Reducing Your App's Launch Time
  2. WWDC 2019 - Optimizing App Launch
  3. WWDC 2020 - Explore app launch performance
  4. dyld源代码分析

抖音 iOS 启动优化实战

网易云启动速度-开荒篇

iOS基于二进制重排启动优化

二进制重排工具AppOrderFiles库地址 iOS15 动态链接 fixup chain 原理详解

相关推荐
largecode4 小时前
企业名称能在来电显示吗?号码显示公司名服务打通多终端展示
android·xml·ios·iphone·xcode·webview·phonegap
MonkeyKing4 小时前
iOS Core Animation 渲染架构详解:Render Server 与 Commit Transaction
ios
MonkeyKing4 小时前
iOS Auto Layout 原理详解:Cassowary 算法、性能问题与优化
ios
运维之美@5 小时前
Nginx性能优化(二):HTTP/2升级指南,让你的网站开启极速模式
ios·iphone
恋猫de小郭6 小时前
Flutter GenUI 0.9 和 A2UI 0.9 发布,全动动态 UI 支持,AI 在 App 里直出界面
android·flutter·ios
sakiko_6 小时前
Swift/UIkit学习笔记27-模块管理,发送位置信息
前端·笔记·学习·ios·swift·uikit
shadowcz0071 天前
苹果不卷AI了:iOS 27要让第三方模型“竞标“进系统
人工智能·ios
90后的晨仔1 天前
Combine 错误处理与恢复:构建健壮的应用防线
ios
90后的晨仔1 天前
Combine 多线程与调度器:掌控数据流的执行线程
ios