「iOS」————APP启动优化
iOS学习
- APP的启动流程
- 启动
- 流程
- 缺页错误
- 主要阶段
- pre-main阶段
- main阶段
- 启动优化
- pre-main
- main阶段
- 启动优化总结
- 流程总结
APP的启动流程
启动
首先我们来了解启动的概念:
- 广义上的启动是点击图标到首页数据加载完毕
- 狭义上的启动是点击图标到启动图完全消失的第一帧
启动的最佳时间是400ms以内,因为从点击图标到显示Launch Screen,到Launch Screen消失这段时间是400ms。启动时间不可以大于20s,否则会被系统杀掉
启动的计算方式如下:
- 起点:进程创建的时间
- 终点:第一个
CA::Transaction::commit()
启动分为冷启动和热启动:
冷启动:系统里没有任何进程的缓存信息,典型的是重启手机后直接启动 App
热启动:从后台再次进入App时,这次启动就是热启动,因为进程缓存还在
那么我们使用app时,哪个启动更多呢?
这实际上要看产品的形态,打开的频次越高,热启动比例越高,很好理解。
流程
我们先看图:
以下是APP启动的底层细节链
点击APP启动 -> 加载libSystem -> Runtime注册回调函数 -> 加载image(镜像文件) -> 执行map_images和load_images方法 -> 调用main函数。
而其中打开app直到调用main函数之前,就是上图的dyld工作阶段,即pre-main阶段的一部分。
-
进程创建与环境准备(内核 + SpringBoard)
-
创建进程、启用 ASLR、加载签名与沙箱、校验权限与 Entitlements
-
解析 Info.plist 的关键键(Bundle 标识、支持架构/平台、启动场景配置等)
-
系统根据 LaunchScreen.storyboard/旧版 LaunchImage 在“你的代码尚未执行”时显示启动画面(闪屏由系统渲染,非你主动绘制)
-
-
dyld 加载与 Mach-O 修复
-
加载可执行文件及其依赖的动态库(如 libSystem.B.dylib、libobjc.A.dylib、Swift runtime 等)
-
Rebase:把镜像内的指针按 ASLR 偏移修正到“真实地址”
-
Bind:为外部符号(如 NSLog)在运行时绑定到实际实现地址
-
(dyld3/共享缓存会显著缩短 dylib 加载与绑定时间)
-
运行时初始化与预执行阶段(均在 main 之前)
-
ObjC Runtime 初始化:注册类、元类、选择子、协议,处理 Category
-
调度并执行 Objective‑C +load(父类→子类,类→分类;各类/分类各执行一次)
-
执行 C/C++ 全局构造与带 __ attribute__((constructor)) 的函数(均为“进程初始化时机”,具体与 +load 的跨语言相对顺序不应依赖)
-
-
进入程序主入口与应用生命周期
-
调用 main() → UIApplicationMain(…)
-
创建 UIApplication 实例,建立主 RunLoop
-
读取 Info.plist 中的主界面/场景配置:iOS 13+ 走 UIScene,旧版读取 UIApplicationMainStoryboardFile
-
AppDelegate 回调:
-
application:willFinishLaunchingWithOptions:(可选)
-
application:didFinishLaunchingWithOptions:(常用,创建 UIWindow、设 rootVC、makeKeyAndVisible())
-
-
首个 UIViewController 生命周期:loadView → viewDidLoad → viewWillAppear → 动画 → viewDidAppear
-
系统移除启动画面,展示首屏
-
rebase(偏移修正):任何一个app生成的二进制文件,在二进制文件内部所有的方法、函数调用,都有一个地址,这个地址是在当前二进制文件中的偏移地址。一旦在运行时刻(即运行到内存中),每次系统都会随机分配一个ASLR(Address Space Layout Randomization,地址空间布局随机化)地址值(是一个安全机制,会分配一个随机的数值,插入在二进制文件的开头),例如,二进制文件中有一个 test方法,偏移值是0x0001,而随机分配的ASLR是0x1f00,如果想访问test方法,其内存地址(即真实地址)变为 ASLR+偏移值 = 运行时确定的内存地址(即0x1f00+0x0001 = 0x1f01)。程序每次启动后地址都会随机变化,这样程序里所有的代码地址都需要需要重新对进行计算修复才能正常访问。rebasing这一步主要就是调整镜像内部指针的指向。
binding(绑定):,例如NSLog方法,在编译时期生成的mach-o文件中,会创建一个符号!NSLog(目前指向一个随机的地址),然后在运行时(从磁盘加载到内存中,是一个镜像文件),会将真正的地址给符号(即在内存中将地址与符号进行绑定,是dyld做的,也称为动态库符号绑定),一句话概括:绑定就是给符号赋值的过程
缺页错误
程序运能运行时因为存在物理内存,也就是说程序加入到物理内存中才得以运行,这一步就是虚拟内存映射到物理内存。这个过程是个使用懒加载方式完成系统到CPU的交互(翻译)的过程。
而这个过程因为懒加载映射方式的缘故,它是“有多少拿多少”,所以我们会通过一页一页的方式也就是page的方式去加载的,iOS的页的大小是16kb,而macOS是4kb。
也是因为是懒加载的方式,所以如果需要用到的时候发现物理内存中没有,就会报出“page fault”的缺页错误,然后缺的页会再加载放入物理内存。这个过程很短,可能30ms,也可能是10ms。
主要阶段
主要分为两个阶段:pre-main阶段和main阶段
pre-main阶段:程序启动到main函数执行前
main阶段:在执行main函数后,调用AppDelegate中的-application:didFinishLaunchingWithOptions:
方法完成初始化,并展示首页
pre-main阶段
pre-main阶段做的事情与dyld的版本有关,此处以dyld2为例。
- 加载应用的可执行文件。
- 加载动态链接库加载器dyld(dynamic loader)。
- dyld递归加载应用所有依赖的dylib(dynamic library 动态链接库)。
- 进行**
rebase
指针调整和bind
**符号绑定。 ObjC
的runtime
初始化(ObjC setup):ObjC
相关Class
的注册、category
注册、selector
唯一性检查等。- 初始化(Initializers):执行
+load()
方法、用attribute((constructor))
修饰的函数的调用、创建C++
静态全局变量等。
dyld流程概述:
我们看这个流程是为了看APP启动到main函数前,也就是dyld是如何将images(镜像文件:如动静态库等)链接到内存中去的。而在objc_init的时候是做了什么操作去调起dyld,以及dyld又如何回调至objc中。
加载链接库
从主执行文件的 header
获取到需要加载的所依赖动态库列表,而 header
早就被内核映射过。然后它需要找到每个 dylib
,然后打开文件读取文件起始位置,确保它是 Mach-O
文件。接着会找到代码签名并将其注册到内核。然后在 dylib
文件的每个 segment
上调用 mmap()
。应用所依赖的 dylib
文件可能会再依赖其他 dylib
,所以 dyld
所需要加载的是动态库列表一个递归依赖的集合。一般应用会加载 100
到 400
个 dylib
文件,但大部分都是系统 dylib
,它们会被预先计算和缓存起来,加载速度很快。
修正
在加载所有的动态链接库之后,它们只是处在相互独立的状态,需要将它们绑定起来,这就是 Fix-ups
。代码签名使得我们不能修改指令,那样就不能让一个 dylib
的调用另一个 dylib
。这时需要加很多间接层。 现代 code-gen
被叫做动态 PIC(Position Independent Code),意味着代码可以被加载到间接的地址上。当调用发生时,code-gen
实际上会在 __DATA
段中创建一个指向被调用者的指针,然后加载指针并跳转过去。所以 dyld
做的事情就是修正(fix-up
)指针和数据。Fix-up
有两种类型,rebasing
和 binding
在执行main函数之前,需要把类的信息注册到一个全局的Table中。同时,Objective C支持Category,在初始化的时候,也会把Category中的方法注册到对应的类中,同时会唯一Selector,这也是为什么当你的Cagegory实现了类中同名的方法后,类中的方法会被覆盖。
另外,由于iOS开发时基于Cocoa Touch的,所以绝大多数的类起始都是系统类,所以大多数的Runtime初始化起始在Rebase和Bind中已经完成。
初始化
调用+load方法和C/C++静态初始化对象和标记为 __ attribute__(constructor)的方法
到此结束dyld2的流程
我们再来看一下dyld2与dyld3的区别
dyld2是纯粹的in-process,也就是在程序进程内执行的,也就意味着只有当应用程序被启动的时候,dyld2才能开始执行任务。
dyld3则是部分out-of-process,部分in-process。图中,虚线之上的部分是out-of-process的,在App下载安装和版本更新的时候会去执行,out-of-process会做如下事情:
- 分析Mach-o Headers
- 分析依赖的动态库
- 查找需要Rebase & Bind之类的符号
- 把上述结果写入缓存
此时,在进程内就只需要读取这个closure(闭包)直接从缓存中读取数据,大大减少了加载时间。
dyld是在是在dyld::_main
函数中调用的
main阶段
- 执行AppDelegate的代理方法,主要是didFinishLaunchingWithOptions 初始化Window
- 初始化基础的ViewController结构(一般是UINavigationController+UITabViewController) 获取数据(Local DB/Network),展示给用户。
启动优化
了解app启动流程和主要阶段,我们就可以来进行启动的优化了。
pre-main
在pre-main阶段,这个阶段最主要的就是dyld的加载。
而dylibs启动的第一步就是加载动态库,加载系统的动态库使很快的,因为可以缓存,而加载内嵌的动态库速度较慢。所以,提高这一步的效率的关键是:减少动态库的数量。
我们还可以考虑合并动态库,但是个人开发就不建议了
Rebase & Bind & Objective C Runtime
Rebase和Bind都是为了解决指针引用的问题。对于Objective C开发来说,主要的时间消耗在Class/Method的符号加载上,所以常见的优化方案是:
- 减少__DATA段中的指针数量。
- 合并Category和功能类似的类。比如:UIView+Frame,UIView+AutoLayout…合并为一个
- 删除无用的方法和类。
- 用initialize替代load
- 减少__atribute__((constructor))的使用,而是在第一次访问的时候才用dispatch_once等方式初始化。
- 不要创建线程
main阶段
延迟初始化那些不必要的UIViewController
。
能延迟执行的就延迟执行。比如SDK的初始化,界面的创建。 不能延迟执行的,尽量放到后台执行。比如数据读取,原始JSON数据转对象,日志发送。
启动优化总结
-
pre‑main(dyld/Runtime)
-
减少/合并第三方与自家动态库数量;能静态链就静态链(SPM/静态 XCFramework)。
-
避免在 +load / 构造器做耗时:移至懒加载(首次使用)或 didFinishLaunching 后的异步。
-
精简 ObjC 元数据:删除无用类/方法/Category,合并零碎 Category(权衡维护性)。
-
减少符号/指针膨胀:避免过度 @objc 暴露与反射;Swift 尽量 final/struct,降低动态性。
-
尽量避免启动期方法交换(swizzle);必须 swizzle 的延后到需要的子系统启用时。
-
-
main 之后(App/Scene)
-
轻量化首屏:小根 VC,延迟创建次级控制器与大型视图树。
-
避免主线程同步 I/O/网络/大 JSON 解析;放后台队列,首帧后再做。
- SDK 延迟初始化;按需加载功能模块。
-
使用 Auto Layout 时约束闭环、减少首帧布局抖动;首屏资源(字体/图片)尽量小并就近。
-
流程总结
从用户点击 App 图标开始,系统先创建进程,按 Info.plist 做基础配置校验,建立沙箱,并用 LaunchScreen.storyboard 显示启动画面。接着进入 dyld 阶段:把可执行文件和依赖的动态库从共享缓存加载进来,做 ASLR 的 rebase 和符号 binding。随后运行时初始化:Objective‑C/Swift Runtime 注册类与分类,执行 C/C++ 全局构造器和 ObjC 的 +load(这些都在 main 之前完成)。
然后进入 main,调用 UIApplicationMain 创建 UIApplication,启动主 RunLoop。iOS 13 及以后通常走 UIScene:在 scene:willConnectTo: 里配置 window 和 rootViewController;老版本是在 application:didFinishLaunching 里创建 window 并 makeKeyAndVisible。接着首个控制器会依次触发 loadView、viewDidLoad、viewWillAppear,动画结束后 viewDidAppear,系统移除启动图,首帧呈现给用户。