一、基础概念
1、Mach-O类型
Mach-O是OSX和iOS系统可执行文件的格式,主要包括以下几种文件类型:
Executable:可执行文件,即应用的主要二进制。
Dylib:动态库
Bundle:不能被链接的Dylib,只能在运行时使用dlopen加载。
Image:包含Executable、Dylib和Bundle。
Framework:包含Dylib、资源文件和头文件的文件夹。
2、Mach-O 镜像文件结构
Header 头部,包含可以执行的CPU架构,比如x86,arm64。
Load commands 加载命令,包含文件的组织架构和在虚拟内存中的布局方式。
Data,数据,包含load commands中需要的各个段(segment)的数据,每一个Segment都得大小是Page的整数倍。
看一个真实的可执行文件的格式:
Mach-O 被划分成一些 segement,每个 segement 又被划分成一些 section。每一段segment的空间大小为页的倍数。页的大小由硬件决定,在 arm64 架构一页是 16KB,其余为 4KB。
几乎所有 Mach-O 都包含__TEXT,__DATA 和 __LINKEDIT这三个segment:
__TEXT 包含 Mach header,被执行的代码和只读常量(如C 字符串)。只读可执行(r-x)。
__DATA 包含全局变量,静态变量等。可读写(rw-)。
__LINKEDIT 包含了加载程序的『元数据』,比如函数的名称和地址。只读(r–)。
3、通用二进制(胖二进制/Mach-O Universal Files)
通用二进制格式由多种架构的Mach-O文件合并而成,通过Fat Header来记录不同架构在文件中的偏移量,它通过 header 来记录不同架构在文件中的偏移量,segement 占多个分页,header 占一页的空间。可能有人会觉得 header 单独占一页会浪费空间,但这有利于虚拟内存的实现。
可以通过file XC-XXX(App的可执行文件)指令查看,伪代码如下:
➜ Desktop file XC-XXX
XC-XXX: Mach-O universal binary with 2 architectures: [arm_v7:Mach-O executable arm_v7] [arm64]
XC-XXX (for architecture armv7): Mach-O executable arm_v7
XC-XXX (for architecture arm64): Mach-O 64-bit executable arm64
ps:关于如何得到app的可执行文件。通过Archive获得ipa安装包,解压ipa安装包获取payload文件夹,可执行的文件XC-XXX就在payload文件夹里面了。
4、虚拟内存Virtual Memory
虚拟内存是建立在物理内存和进程之间的中间层。通过添加间接层来让每个进程使用逻辑地址空间,它可以映射到 RAM 上的某个物理页上。这种映射不是一对一的,逻辑地址可能映射不到 RAM 上,也可能有多个逻辑地址映射到同一个物理 RAM 上。
若逻辑地址可能映射不到 RAM 上,当进程要存储逻辑地址内容时会触发 page fault。若有多个逻辑地址映射到同一个物理 RAM 上,则是是多进程共享内存。
如果一个Page可以从磁盘上重新生成,那么这个Page称为Clean Page。如果一个Page包含了进程相关信息,那么这个Page称为Dirty Page。像代码段这种只读的Page就是Clean Page。而像数据段(_DATA)这种读写的Page,当写数据发生的时候,会触发COW(Copy on write),也就是写时复制,Page会被标记成Dirty,同时会被复制。
5、Mach-O 镜像加载
所以在多个进程加载 Mach-O 镜像时 __TEXT 和 __LINKEDIT 因为只读,都是可以共享内存的。而 __DATA 因为可读写,就会产生 dirty page。当 dyld 执行结束后,__LINKEDIT 就没用了,对应的内存页会被回收。
6、安全
(1)ASLR(Address Space Layout Randomization)即地址空间布局随机化,采用ASLR,进程每次启动,地址空间都会被简单地随机化,但是只是偏移,不是搅乱。大体布局——程序文本、数据和库是一样的,但是具体的地址都不同了,可以阻挡黑客对地址的猜测 。
(2)Code Sign代码签名:可能我们认为 Xcode 会把整个文件都做加密 hash 并用做数字签名。其实为了在运行时验证 Mach-O 文件的签名,并不是每次重复读入整个文件,而是把每页内容都生成一个单独的加密散列值,并存储在 __LINKEDIT 中。这使得文件每页的内容都能及时被校验确并保不被篡改。
二、App启动流程
使用dyld2启动应用的过程如图:
大致的过程如下:
加载dyld到App进程
加载动态库(包括所依赖的所有动态库)
Rebase
Bind
初始化Objective C Runtime
其它的初始化代码
第一步:从exec到main()
exec() 是一个系统调用。系统内核把应用映射到新的地址空间,且每次起始位置都是随机的(因为使用 ASLR)。并将起始位置到 0x000000 这段范围的进程权限都标记为不可读写不可执行。如果是 32 位进程,这个范围至少是 4KB;对于 64 位进程则至少是 4GB。NULL 指针引用和指针截断误差都是会被它捕获。
第二步:dyld 加载 dylib 文件
当内核完成映射进程的工作后会将名字为 dyld 的Mach-O 文件映射到进程中的随机地址,它将 PC 寄存器设为 dyld 的地址并运行。dyld 在应用进程中运行的工作是加载应用依赖的所有动态链接库,准备好运行所需的一切,它拥有的权限跟应用一样。
下面的步骤构成了 dyld 的时间线:
Load dylibs -> Rebase -> Bind -> ObjC -> Initializers
从主执行文件的 header 获取到需要加载的所依赖动态库列表,而 header 早就被内核映射过。然后它需要找到每个 dylib,然后打开文件读取文件起始位置,确保它是 Mach-O 文件。接着会找到代码签名并将其注册到内核。然后在 dylib 文件的每个 segment 上调用 mmap()。应用所依赖的 dylib 文件可能会再依赖其他 dylib,所以 dyld 所需要加载的是动态库列表一个递归依赖的集合。一般应用会加载数百个 dylib 文件,但大部分都是系统dylib,它们会被预先计算和缓存起来,加载速度很快。
第三步:Fix-ups
在加载所有的动态链接库之后,它们只是处在相互独立的状态,需要将它们绑定起来,这就是Fix-ups。代码签名使得我们不能修改指令,那样就不能让一个 dylib 的调用另一个 dylib。这时需要加很多间接层。
现代 code-gen 被叫做动态 PIC(Position Independent Code),意味着代码可以被加载到间接的地址上。当调用发生时,code-gen 实际上会在 __DATA 段中创建一个指向被调用者的指针,然后加载指针并跳转过去。
所以 dyld 做的事情就是修正(fix-up)指针和数据。Fix-up 有两种类型,rebasing 和 binding。
Rebasing:在镜像内部调整指针的指向。
Binding:将指针指向镜像外部的内容。
之所以需要Rebase,是因为刚刚提到的ASLR使得地址随机化,导致起始地址不固定,另外由于Code Sign,导致不能直接修改Image。Rebase的时候只需要增加对应的偏移量即可。待Rebase的数据都存放在__LINKEDIT中。可以通过MachOView查看:Dynamic Loader Info -> Rebase Info
第四部:Objc Runtime
Objective C
ObjC 是个动态语言,可以用类的名字来实例化一个类的对象。这意味着 ObjC Runtime 需要维护一张映射类名与类的全局表。当加载一个 dylib 时,其定义的所有的类都需要被注册到这个全局表中。
同时,Objective C支持Category,在初始化的时候,也会把Category中的方法注册到对应的类中,同时会唯一Selector,这也是为什么当你的Cagegory实现了类中同名的方法后,类中的方法会被覆盖。
另外,由于iOS开发时基于Cocoa Touch的,所以绝大多数的类起始都是系统类,所以大多数的Runtime初始化其实在Rebase和Bind中已经完成。
第五步:Initializers
接下来就是必要的初始化部分了,主要包括几部分:
+load方法。
C/C++静态初始化对象和标记为__attribute__(constructor)的方法
这里要提一点的就是,+load方法已经被弃用了,如果你用Swift开发,你会发现根本无法去写这样一个方法,官方的建议是实用initialize。区别就是,load是在类装载的时候执行,而initialize是在类第一次收到message前调用。
整个事件由dyld主导,完成运行环境的初始化后,配合ImageLoader 将二进制文件按格式加载到内存,动态链接依赖库,并由runtime负责加载成objc 定义的结构,所有初始化工作结束后,dyld调用真正的main函数。
三、App启动时间优化
1、启动时间
启动时间在小于400ms是最佳的,因为从点击图标到显示Launch Screen,到Launch Screen消失这段时间是400ms。启动时间不可以大于20s,否则会被系统杀掉。
在Xcode中,可以通过设置环境变量来查看App的启动时间,DYLD_PRINT_STATISTICS和DYLD_PRINT_STATISTICS_DETAILS。
Total pre-main time: 726.14 milliseconds (100.0%)
dylib loading time: 182.25 milliseconds (25.0%)
rebase/binding time: 55.67 milliseconds (7.6%)
ObjC setup time: 37.04 milliseconds (5.1%)
initializer time: 451.16 milliseconds (62.1%)
slowest intializers :
libSystem.B.dylib : 360.07 milliseconds (49.5%)
MobileRTC : 30.23 milliseconds (4.1%)
XC-XXX : 92.76 milliseconds (12.7%)
2、App启动时间优化
以main()函数作为分水岭,启动时间其实包括了两部分:main()函数之前和main()函数到第一个界面的viewDidAppear:。
(1)pre-main阶段优化
Main函数之前是iOS系统的工作,所以这部分的优化往往更具有通用性。
#删除无用代码(未被调用的静态变量、类和方法)
可以使用AppCode对工程进行扫描,删项目中未使用的本地变量;未使用的参数;以及未使用的值等。
#dylibs
启动的第一步是加载动态库,加载系统的动态库使很快的,因为可以缓存,而加载内嵌的动态库速度较慢。所以,提高这一步的效率的关键是:减少动态库的数量。合并动态库,比如公司内部由私有Pod建立了如下动态库:XXTableView, XXHUD, XXLabel,强烈建议合并成一个XXUIKit来提高加载速度。
#Rebase & Bind & ObjC Runtime
Rebase和Bind都是为了解决指针引用的问题。对于Objective C开发来说,主要的时间消耗在Class/Method的符号加载上,所以常见的优化方案是:
减少__DATA段中的指针数量。
合并Category和功能类似的类。比如:UIView+Frame,UIView+AutoLayout…合并为一个
删除无用的方法和类。
#Initializers
通常,我们会在+load方法中进行method-swizzling,这也是Nshipster推荐的方式。
用initialize替代load。不少同学喜欢用method-swizzling来实现AOP去做日志统计等内容,强烈建议改为在initialize进行初始化。
减少__atribute__((constructor))的使用,而是在第一次访问的时候才用dispatch_once等方式初始化。
不要创建线程。
(2)Main阶段的优化
从main函数开始执行,到你的第一个界面显示,这期间一般会做哪些事情。
1、执行AppDelegate的代理方法,主要是didFinishLaunchingWithOptions。
2、初始化Window,初始化基础的ViewController结构(一般是UINavigationController+UITabViewController)。
3、获取数据(Local DB/Network),展示给用户。
主要优化点如下:
1、三方SDK初始化,比如Crash统计; 像分享之类的,可以等到第一次调用再出初始化。
2、启动相关日志,日志往往涉及到DB操作,一定要放到后台去做。
3、在写与启动相关的业务模块时尤其要注意,看哪些逻辑可以延迟加载或者懒加载。
4、类和方法名不要太长:iOS每个类和方法名都在__cstring段里都存了相应的字符串值,所以类和方法名的长短也是对可执行文件大小是有影响的,原因还是object-c的动态特性,因为需要通过类/方法名反射找到这个类/方法进行调用,object-c对象模型会把类/方法名字符串都保存下来。
5、通过instruments的Time Profiler分析耗时瓶颈,逐个解决。
参考(部分章节直接copy🐶):
深入理解iOS App的启动过程
iOS启动优化
优化 App 的启动时间