应用启动流程剖析
每次App点开后的那几秒钟,背后到底发生了什么?
我做性能优化这些年,发现很多团队只盯着业务代码里的viewDidLoad,却忽略了系统在调用main()之前已经走了好长一段路。你想想看,从用户手指点到屏幕响应,内核、动态链接器、Runtime,一层层接力——任何一个环节慢了,启动时间就上去了。今天咱们就把这段路拆开看看。
操作系统加载过程
用户点击图标那一刻,进程还没出生呢。
- 内核创建进程:
fork+execve,分配虚拟内存、文件描述符,载入Mach-O文件头部。 - 映射主可执行文件:内核按
LC_SEGMENT命令把代码段、数据段映射到内存,但此时还没加载任何库。 - 启动动态链接器:Mach-O里有个
LC_LOAD_DYLINKER,内核会去找/usr/lib/dyld,把它加载到进程空间,并跳转到dyld入口。
这个过程听起来快,但如果你App有大量segment或page fault,这里就可能产生几百微妙的延迟。我遇到过一款游戏,因为一个未使用的Segment占了200MB,导致冷启动时系统要花30ms额外映射——直接在syscall层就输了。
vmmap看进程内存布局,关注__TEXT,__DATA等段的虚拟地址和驻留页数。
main函数前的工作
dyld跑起来以后,活儿才开始变细。我个人习惯把这个阶段称为“无主之地”——代码还没执行,但开销已经客观存在了。
1. 递归加载动态库
dyld解析主二进制所有的LC_LOAD_DYLIB,一层层往下找依赖。每个库都要做:
- 文件映射(mmap)
- 符号查找(绑定外部符号)
- 执行初始化函数(
+load、C++静态构造、__attribute__((constructor)))
2. 运行时初始化
Objective-C Runtime会在+load调用前做内部准备:
- 读取各个类的
__objc_classlist段,注册到分类表 - 注册所有Category
- 解析Protocol
- 初始化自动释放池(
objc_autoreleasePoolPush)
这里有个常见陷阱——+load里的代码会显著推迟main。我曾经接手一个项目,几百个+load都在做map预填充,硬生生吃了200ms。后来改成+initialize懒加载,冷启动时间直接砍掉15%。
+load里做臃肿的初始化,特别是网络请求、磁盘读写、数据库建表。它跑在dyld线程,优先级极高,卡住就卡住全局。
Application/AppDelegate初始化阶段
等dyld忙完,main()终于被调用了。然后UIApplicationMain接管,干这几件事:
- 根据
Info.plist里的NSPrincipalClass创建UIApplication实例 - 根据
NSMainNibFile或UISceneStoryboard加载storyboard - 创建
AppDelegate实例,但不调用application:didFinishLaunchingWithOptions:——真正干活在RunLoop跑起来后
注意,这里有个容易被忽略的点:storyboard或xib里的初始视图会在UIApplicationMain期间被解档。如果你的RootViewController在xib里引用了大量图片、字体,那这部分时间也会被算进启动耗时。我在一个直播App里发现,仅一个启动页的xib就加载了12张全屏背景图(每张2MB),导致Main阶段多了80ms。
application:didFinishLaunchingWithOptions:执行时,整个UIWindow树已经搭好。大部分开发者的优化都堆在这里——SDK初始化、埋点启动、UI设置……但别忘了,前面这些系统流程已经跑完了,有些开销改不了,只能提前预知。
上面这张图串起了整个冷启动链路。你可以把它贴在团队墙上,走读代码时对着看——哪一步超时了,就锚定哪个阶段去排查。我个人习惯在main()前后打印时间戳,配合os_signpost,把整个流程拆成可观测的片段。
dyld::initializeMainExecutable、load_images等函数耗时。跑一遍instruments,你就能抓到真正的瓶颈。
好,今天我们把启动的骨架画清楚了。下次聊到具体的打点工具和OSSignpost集成时,你会更容易理解数据从哪来、往哪去。