App启动过程与优化

一、基础概念

1、Mach-O类型

Mach-O是OSX和iOS系统可执行文件的格式,主要包括以下几种文件类型:

Executable:可执行文件,即应用的主要二进制。
Dylib:动态库
Bundle:不能被链接的Dylib,只能在运行时使用dlopen加载。
Image:包含Executable、Dylib和Bundle。
Framework:包含Dylib、资源文件和头文件的文件夹。

2、Mach-O 镜像文件结构

Mach-O镜像文件格式.png
Header 头部,包含可以执行的CPU架构,比如x86,arm64。
Load commands 加载命令,包含文件的组织架构和在虚拟内存中的布局方式。
Data,数据,包含load commands中需要的各个段(segment)的数据,每一个Segment都得大小是Page的整数倍。

看一个真实的可执行文件的格式:


可执行文件格式.png

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启动应用的过程如图:


dyld2启动应用的过程.png

大致的过程如下:

加载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_bind.png

之所以需要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 的启动时间

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,793评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,567评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,342评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,825评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,814评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,680评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,033评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,687评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,175评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,668评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,775评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,419评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,020评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,978评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,206评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,092评论 2 351
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,510评论 2 343

推荐阅读更多精彩内容