Bootstrap

iOS应用的启动流程和优化详解

一、应用启动流程

1、整体过程

(1)解析Info.plist

  • 加载相关信息,例如如闪屏
  • 沙箱建立、权限检查

(2)Mach-O(可执行文件)加载

  • dylib loading time(动态库耗时)
  • rebase/binding time(偏移修正/符号绑定耗时)

rebase(偏移修正):任何一个app生成的二进制文件,在二进制文件内部所有的方法、函数调用,都有一个地址,这个地址是在当前二进制文件中的偏移地址。一旦在运行时刻(即运行到内存中),每次系统都会随机分配一个ASLR(Address Space Layout Randomization,地址空间布局随机化)地址值(是一个安全机制,会分配一个随机的数值,插入在二进制文件的开头),例如,二进制文件中有一个 test方法,偏移值是0x0001,而随机分配的ASLR是0x1f00,如果想访问test方法,其内存地址(即真实地址)变为 ASLR+偏移值 = 运行时确定的内存地址(即0x1f00+0x0001 = 0x1f01)

binding(绑定):,例如NSLog方法,在编译时期生成的mach-o文件中,会创建一个符号!NSLog(目前指向一个随机的地址),然后在运行时(从磁盘加载到内存中,是一个镜像文件),会将真正的地址给符号(即在内存中将地址与符号进行绑定,是dyld做的,也称为动态库符号绑定),一句话概括:绑定就是给符号赋值的过程

  • 加载类扩展(Category)中的方法
  • C++静态对象加载、调用ObjC的 +load 函数
  • 执行声明为__attribute__((constructor))的C函数

Mach-O

  • Apple出品的操作系统的可执行文件格式几乎都是mach-o,iOS当然也不例外。
  • mach-o可以大致的分为三部分:

Header 头部,包含可以执行的CPU架构,比如x86,arm64

Load commands 加载命令,包含文件的组织架构和在虚拟内存中的布局方式

Data,数据,包含load commands中需要的各个段(segment)的数据,每一个Segment都得大小是Page的整数倍。

Virtual Memory

虚拟内存是在物理内存上建立的一个逻辑地址空间,它向上(应用)提供了一个连续的逻辑地址空间,向下隐藏了物理内存的细节。
虚拟内存使得逻辑地址可以没有实际的物理地址,也可以让多个逻辑地址对应到一个物理地址。
虚拟内存被划分为一个个大小相同的Page(64位系统上是16KB),提高管理和读写的效率。 Page又分为只读和读写的Page。
复制代码

虚拟内存是建立在物理内存和进程之间的中间层。在iOS上,当内存不足的时候,会尝试释放那些只读的Page,因为只读的Page在下次被访问的时候,可以再从磁盘读取。如果没有可用内存,会通知在后台的App(也就是在这个时候收到了memory warning),如果在这之后仍然没有可用内存,则会杀死在后台的App。

Page fault

在应用执行的时候,它被分配的逻辑地址空间都是可以访问的,当应用访问一个逻辑Page,而在对应的物理内存中并不存在的时候,这时候就发生了一次Page fault。当Page fault发生的时候,会中断当前的程序,在物理内存中寻找一个可用的Page,然后从磁盘中读取数据到物理内存,接着继续执行当前程序。 
复制代码

Dirty Page & Clean Page 如果一个Page可以从磁盘上重新生成,那么这个Page称为Clean Page 如果一个Page包含了进程相关信息,那么这个Page称为Dirty Page 像代码段这种只读的Page就是Clean Page。而像数据段(_DATA)这种读写的Page,当写数据发生的时候,会触发COW(Copy on write),也就是写时复制,Page会被标记成Dirty,同时会被复制。

(3)程序执行

  • 调用main()
  • 调用UIApplicationMain()
  • 调用applicationWillFinishLaunching

2、主要阶段:

分为两个阶段,pre-main阶段和main()阶段。程序启动到main函数执行前是pre-main阶段;在执行main函数后,调用AppDelegate中的-application:didFinishLaunchingWithOptions:方法完成初始化,并展示首页,这是main()阶段,或者叫做main()之后阶段。

(1)pre-main阶段:

  • 加载应用的可执行文件
  • 加载动态链接库加载器dyld(dynamic loader)。
  • dyld递归加载应用所有依赖的dylib(dynamic library 动态链接库)。
  • 进行**rebase指针调整和bind**符号绑定。
  • ObjCruntime初始化(ObjC setup):ObjC相关Class的注册、category注册、selector唯一性检查等。
  • 初始化(Initializers):执行+load()方法、用attribute((constructor))修饰的函数的调用、创建C++静态全局变量等。

(2)main()阶段:

  • dyld调用main()
  • 调用UIApplicationMain()
  • 调用applicationWillFinishLaunching
  • 调用didFinishLaunchingWithOptions

二、获取启动流程的时间消耗

1、pre-main阶段

对于pre-main阶段,Apple提供了一种测量方法,在 Xcode 中 Edit scheme -> Run -> Auguments 将环境变量DYLD_PRINT_STATISTICS 设为1 。之后控制台会输出类似内容,我们可以清晰的看到每个耗时:

 从上面可以看出时间区域主要分为下面几个部分:

  • dylib loading time

    • 动态库载入过程,会去装载app使用的动态库,而每一个动态库有它自己的依赖关系,所以会消耗时间去查找和读取。
    • dyld (the dynamic link editor)动态链接器,是一个专门用来加载动态链接库的库,它是开源的。在 xnu 内核为程序启动做好准备后,执行由内核态切换到用户态,由dyld完成后面的加载工作,dyld的主要是初始化运行环境,开启缓存策略,加载程序依赖的动态库(其中也包含我们的可执行文件),并对这些库进行链接(主要是rebaseing和binding),最后调用每个依赖库的初始化方法,在这一步,runtime被初始化。
  • rebase/binding time

ASLR(Address Space Layout Randomization),地址空间布局随机化。在ASLR技术出现之前,程序都是在固定的地址加载的,这样hacker可以知道程序里面某个函数的具体地址,植入某些恶意代码,修改函数的地址等,带来了很多的危险性。ASLR就是为了解决这个的,程序每次启动后地址都会随机变化,这样程序里所有的代码地址都需要需要重新对进行计算修复才能正常访问。rebasing这一步主要就是调整镜像内部指针的指向。

Binding:将指针指向镜像外部的内容。

  • ObjC setup time

    • dyld调用的objc_init方法,这个是runtime的初始化方法,在这个方法里面主要的操作就是加载类(对需要的class和category进行注册);
    • objc_init方法通过内部的_dyld_objc_notify_register向dyld注册了一个通知事件,当有新的image(程序中对应实例可简称为image,如程序可执行文件macho,Framework,bundle等)加载到内存的时候,就会触发load_images方法,这个方法里面就是加载对应image里面的类,并调用load方法(在下一阶段initializer)。
    • 如果有继承的类,那么会先调用父类的load方法,然后调用子类的,但是在load里面不能调用[super load]。最后才是调用category的load方法。总之,所有的load都会被调用到(注意:子类的initialize方法会覆盖父类,不同于load方法)。
  • initializer time

承接上一过程进行初始化(load)。如果我们代码里面使用了clang的__attribute__((constructor))构造方法,这里会调用到。

2、main()阶段

测量main()函数开始执行到didFinishLaunchingWithOptions执行结束的时间,简单的方法:直接插入代码。(也可以使用其他工具)

  • main函数里

  • 到主UI框架的.m文件用extern声明全局变量StartTime

extern CFAbsoluteTime startTime;

  • 在viewDidAppear函数里,再获取一下当前时间,与StartTime的差值即是main()阶段运行耗时。

double launchTime = (CFAbsoluteTimeGetCurrent()-StartTime);

三、改善APP的启动

建议应用的启动时间控制在400ms之下,并且在20s内启动,否则系统会kill app。优化APP的启动时间,需要就是分别优化pre-main和main的时间。

1、改善启动时pre-main阶段

(1)加载 Dylib

载入动态库,这个过程中,会去装载app使用的动态库,而每一个动态库有它自己的依赖关系,所以会消耗时间去查找和读取。对于Apple提供的的系统动态库,做了高度的优化。而对于开发者定义导入的动态库,则需要在花费更多的时间。Apple官方建议尽量少的使用自定义的动态库,或者考虑合并多个动态库,其中一个建议是当大于6个的时候,则需要考虑合并它们。

(2)Rebase/Binding

减少App的Objective-C类,分类和Selector的个数。这样做主要是为了加快程序的整个动态链接, 在进行动态库的重定位和绑定(Rebase/binding)过程中减少指针修正的使用,加快程序机器码的生成;

(3)Objc setup

大部分ObjC初始化工作已经在Rebase/Bind阶段做完了,这一步dyld会注册所有声明过的ObjC类,将分类插入到类的方法列表里,再检查每个selector的唯一性。

在这一步倒没什么优化可做的,Rebase/Bind阶段优化好了,这一步的耗时也会减少。

(4)Initializers

到了这一阶段,dyld开始运行程序的初始化函数,调用每个Objc类和分类的+load方法,调用C/C++ 中的构造器函数(用attribute((constructor))修饰的函数),和创建非基本类型的C++静态全局变量。Initializers阶段执行完后,dyld开始调用main()函数。

在这一步,我们可以做的优化有:

    • 少在类的+load方法里做事情,尽量把这些事情推迟到+initiailize
    • 减少构造器函数个数,在构造器函数里少做些事情
    • 减少C++静态全局变量的个数

 2、main()阶段的优化

(1)核心点:didFinishLaunchingWithOptions方法

  这一阶段的优化主要是减少didFinishLaunchingWithOptions方法里的工作,在didFinishLaunchingWithOptions方法里我们经常会进行:

  • 创建应用的window,指定其rootViewController,调用window的makeKeyAndVisible方法让其可见;
  • 由于业务需要,我们会初始化各个三方库;
  • 设置系统UI风格;
  • 检查是否需要显示引导页、是否需要登录、是否有新版本等;

由于历史原因,这里的代码容易变得比较庞大,启动耗时难以控制。

(2)优化点:

  满足业务需要的前提下,didFinishLaunchingWithOptions在主线程里做的事情越少越好。在这一步,我们可以做的优化有:

  • 梳理各个二方/三方库,把可以延迟加载的库做延迟加载处理,比如放到首页控制器的viewDidAppear方法里。

  • 梳理业务逻辑,把可以延迟执行的逻辑做延迟执行处理。比如检查新版本、注册推送通知等逻辑。

  • 避免复杂/多余的计算

  • 避免在首页控制器的viewDidLoad和viewWillAppear做太多事情,这2个方法执行完,首页控制器才能显示,部分可以延迟创建的视图应做延迟创建/懒加载处理。

  • 首页控制器用纯代码方式来构建

四、+load与+initialize

1、+load

(1)+load方法是一定会在runtime中被调用的。只要类被添加到runtime中了,就会调用+load方法,即只要是在Compile Sources中出现的文件总是会被装载,与这个类是否被用到无关,因此+load方法总是在main函数之前调用

(2)+load方法不会覆盖。也就是说,如果子类实现了+load方法,那么会先调用父类的+load方法(无需手动调用super),然后又去执行子类的+load方法。

(3)+load方法只会调用一次。

(4)+load方法执行顺序是:类 -> 子类 ->分类。而不同分类之间的执行顺序不一定,依据在Compile Sources中出现的顺序**(先编译,则先调用,列表中在下方的为“先”)**。

(5)+load方法是函数指针调用,即遍历类中的方法列表,直接根据函数地址调用。如果子类没有实现+load方法,子类也不会自动调用父类的+load方法。

2、+initialize

(1)+initialize方法是在类或它的子类收到第一条消息之前被调用的,这里所指的消息包括实例方法和类方法的调用。因此+initialize方法总是在main函数之后调用

(2)+initialize方法只会调用一次。

(3)+initialize方法实际上是一种惰性调用,如果一个类一直没被用到,那它的+initialize方法也不会被调用,这一点有利于节约资源。

(4)+initialize方法会覆盖。如果子类实现了+initialize方法,就不会执行父类的了,直接执行子类本身的。如果分类实现了+initialize方法,也不会再执行主类的。

(5)+initialize方法的执行覆盖顺序是:分类 -> 子类 ->类。且只会有一个+initialize方法被执行

(6)+initialize方法是发送消息(objc_msgSend()),如果子类没有实现+initialize方法,也会自动调用其父类的+initialize方法。

3、两者的异同

(1)相同点

  1. load和initialize会被自动调用,不能手动调用它们。
  2. 子类实现了load和initialize的话,会隐式调用父类的load和initialize方法。
  3. load和initialize方法内部使用了锁,因此它们是线程安全的。

(2)不同点

  1. 调用顺序不同,以main函数为分界,+load方法在main函数之前执行,+initialize在main函数之后执行。
  2. 子类中没有实现+load方法的话,子类不会调用父类的+load方法;而子类如果没有实现+initialize方法的话,也会自动调用父类的+initialize方法。
  3. +load方法是在类被装在进来的时候就会调用,+initialize在第一次给某个类发送消息时调用(比如实例化一个对象),并且只会调用一次,是懒加载模式,如果这个类一直没有使用,就不回调用到+initialize方法。

4、使用场景

(1)+load一般是用来交换方法Method Swizzle,由于它是线程安全的,而且一定会调用且只会调用一次,通常在使用UrlRouter的时候注册类的时候也在+load方法中注册。
(2)+initialize方法主要用来对一些不方便在编译期初始化的对象进行赋值,或者说对一些静态常量进行初始化操作。

Mach-O启动过程

使用dyld2启动应用的过程如图:

大致的过程如下:

加载dyld到App进程
加载动态库(包括所依赖的所有动态库)
Rebase
Bind
初始化Objective C Runtime
其它的初始化代码
复制代码

加载动态库

dyld会首先读取mach-o文件的Header和load commands。 接着就知道了这个可执行文件依赖的动态库。例如加载动态库A到内存,接着检查A所依赖的动态库,就这样的递归加载,直到所有的动态库加载完毕。通常一个App所依赖的动态库在100-400个左右,其中大多数都是系统的动态库,它们会被缓存到dyld shared cache,这样读取的效率会很高。 

Rebase && Bind

里先来讲讲为什么要Rebase? 

 有两种主要的技术来保证应用的安全:ASLR和Code Sign。

 ASLR的全称是Address space layout randomization,翻译过来就是“地址空间布局随机化”。App被启动的时候,程序会被影射到逻辑的地址空间,这个逻辑的地址空间有一个起始地址,而ASLR技术使得这个起始地址是随机的。如果是固定的,那么黑客很容易就可以由起始地址+偏移量找到函数的地址。 

 Code Sign相信大多数开发者都知晓,这里要提一点的是,在进行Code sign的时候,加密哈希不是针对于整个文件,而是针对于每一个Page的。这就保证了在dyld进行加载的时候,可以对每一个page进行独立的验证。

 mach-o中有很多符号,有指向当前mach-o的,也有指向其他dylib的,比如printf。那么,在运行时,代码如何准确的找到printf的地址呢?

 mach-o中采用了PIC技术,全称是Position Independ code。当你的程序要调用printf的时候,会先在__DATA段中建立一个指针指向printf,在通过这个指针实现间接调用。dyld这时候需要做一些fix-up工作,即帮助应用程序找到这些符号的实际地址。主要包括两部分 

 Rebase 修正内部(指向当前mach-o文件)的指针指向

 Bind 修正外部指针指向 

之所以需要Rebase,是因为刚刚提到的ASLR使得地址随机化,导致起始地址不固定,另外由于Code Sign,导致不能直接修改Image。Rebase的时候只需要增加对应的偏移量即可。待Rebase的数据都存放在__LINKEDIT中。 可以通过MachOView查看:Dynamic Loader Info -> Rebase Info 

Rebase解决了内部的符号引用问题,而外部的符号引用则是由Bind解决。在解决Bind的时候,是根据字符串匹配的方式查找符号表,所以这个过程相对于Rebase来说是略慢的。 同样,也可以通过xcrun dyldinfo来查看Bind的信息,比如我们查看bind信息中,包含UITableView的部分:

Objective C 

 Objective C是动态语言,所以在执行main函数之前,需要把类的信息注册到一个全局的Table中。同时,Objective C支持Category,在初始化的时候,也会把Category中的方法注册到对应的类中,同时会唯一Selector,这也是为什么当你的Cagegory实现了类中同名的方法后,类中的方法会被覆盖。 

 另外,由于iOS开发时基于Cocoa Touch的,所以绝大多数的类起始都是系统类,所以大多数的Runtime初始化起始在Rebase和Bind中已经完成。

 Initializers 接下来就是必要的初始化部分了,主要包括几部分: 

 +load方法。 

C/C++静态初始化对象和标记为__attribute__(constructor)的方法 

这里要提一点的就是,+load方法已经被弃用了,如果你用Swift开发,你会发现根本无法去写这样一个方法,官方的建议是实用initialize。区别就是,load是在类装载的时候执行,而initialize是在类第一次收到message前调用。

 dyld3 

上文的讲解是dyld2的加载方式。而最新的是dyld3加载方式略有不同:

dyld2是纯粹的in-process,也就是在程序进程内执行的,也就意味着只有当应用程序被启动的时候,dyld2才能开始执行任务。

 dyld3则是部分out-of-process,部分in-process。图中,虚线之上的部分是out-of-process的,在App下载安装和版本更新的时候会去执行,out-of-process会做如下事情:

  •  分析Mach-o Headers 

  • 分析依赖的动态库 

  • 查找需要Rebase & Bind之类的符号

  •  把上述结果写入缓存

这样,在应用启动的时候,就可以直接从缓存中读取数据,加快加载速度

启动时间

冷启动 VS 热启动

如果你刚刚启动过App,这时候App的启动所需要的数据仍然在缓存中,再次启动的时候称为热启动。如果设备刚刚重启,然后启动App,这时候称为冷启动
复制代码

启动时间在小于400ms是最佳的,因为从点击图标到显示Launch Screen,到Launch Screen消失这段时间是400ms。启动时间不可以大于20s,否则会被系统杀掉。

 在Xcode中,可以通过设置环境变量来查看App的启动时间,DYLD_PRINT_STATISTICS和DYLD_PRINT_STATISTICS_DETAILS。

Total pre-main time:  43.00 milliseconds (100.0%)
         dylib loading time:  19.01 milliseconds (44.2%)
        rebase/binding time:   1.77 milliseconds (4.1%)
            ObjC setup time:   3.98 milliseconds (9.2%)
           initializer time:  18.17 milliseconds (42.2%)
           slowest intializers :
             libSystem.B.dylib :   2.56 milliseconds (5.9%)
   libBacktraceRecording.dylib :   3.00 milliseconds (6.9%)
    libMainThreadChecker.dylib :   8.26 milliseconds (19.2%)
                       ModelIO :   1.37 milliseconds (3.1%)
复制代码

对于这个libMainThreadChecker.dylib估计很多同学会有点陌生,这是XCode 9新增的动态库,用来做主线成检查的。

优化启动时间

启动时间这个名词,不同的人有不同的定义。在我看来,

启动时间是用户点击App图标,到第一个界面展示的时间。
复制代码

以main函数作为分水岭,启动时间其实包括了两部分:main函数之前和main函数到第一个界面的viewDidAppear:。所以,优化也是从两个方面进行的,个人建议优先优化后者,因为绝大多数App的瓶颈在自己的代码里。

Main函数之后 我们首先来分析下,从main函数开始执行,到你的第一个界面显示,这期间一般会做哪些事情。 执行AppDelegate的代理方法,主要是didFinishLaunchingWithOptions 初始化Window,初始化基础的ViewController结构(一般是UINavigationController+UITabViewController) 获取数据(Local DB/Network),展示给用户。 

UIViewController

延迟初始化那些不必要的UIViewController

在启动的时候只需要初始化首页头条页面即可。像“要闻”,“我的”等页面,则延迟加载,即启动的时候只是一个UIViewController作为占位符给TabController,等到用户点击了再去进行真正的数据和视图的初始化工作。

AppDelegate

通常我们会在AppDelegate的代理方法里进行初始化工作,主要包括了两个方法:

  • didFinishLaunchingWithOptions
  • applicationDidBecomeActive

优化这些初始化的核心思想就是:

能延迟初始化的尽量延迟初始化,不能延迟初始化的尽量放到后台初始化。
复制代码

这些工作主要可以分为几类:

  • 三方SDK初始化,比如Crash统计; 像分享之类的,可以等到第一次调用再出初始化。

  • 初始化某些基础服务,比如WatchDog,远程参数。

  • 启动相关日志,日志往往涉及到DB操作,一定要放到后台去做

  • 业务方初始化,这个交由每个业务自己去控制初始化时间。

对于didFinishLaunchingWithOptions的代码,建议按照以下的方式进行划分:

@interface AppDelegate ()
//业务方需要的生命周期回调
@property (strong, nonatomic) NSArray<id<UIApplicationDelegate>> * eventQueues;
//主框架负责的生命周期回调
@property (strong, nonatomic) id<UIApplicationDelegate> basicDelegate;
@end
复制代码

然后,你会得到一个非常干净的AppDelegate文件:

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    for (id<UIApplicationDelegate> delegate in self.eventQueues) {
        [delegate application:application didFinishLaunchingWithOptions:launchOptions];
    }
    return [self.basicDelegate application:application didFinishLaunchingWithOptions:launchOptions];
}
复制代码

由于对这些初始化进行了分组,在开发期就可以很容易的控制每一个业务的初始化时间:

CFTimeInterval startTime = CACurrentMediaTime();
//执行方法
CFTimeInterval endTime = CACurrentMediaTime();
复制代码

用Time Profiler找到元凶 Time Profiler在分析时间占用上非常强大。实用的时候注意三点 

 在打包模式下分析(一般是Release),这样和线上环境一样。

 记得开启dsym,不然无法查看到具体的函数调用堆栈 

分析性能差的设备,对于支持iOS 8的,一般分析iphone 4s或者iphone 5。 一个典型的分析界面如下: 

几点要注意:

  1. 分析启动时间,一般只关心主线程
  2. 选择Hide System Libraries和Invert Call Tree,这样我们能专注于自己的代码
  3. 右侧可以看到详细的调用堆栈信息

在某一行上双击,我们可以进入到代码预览界面,去看看实际每一行占用了多少时间:

小结 

 不同的App在启动的时候做的事情往往不同,但是优化起来的核心思想无非就两个: 能延迟执行的就延迟执行。比如SDK的初始化,界面的创建。 不能延迟执行的,尽量放到后台执行。比如数据读取,原始JSON数据转对象,日志发送。 

Main函数之前

 Main函数之前是iOS系统的工作,所以这部分的优化往往更具有通用性。

 dylibs 启动的第一步是加载动态库,加载系统的动态库使很快的,因为可以缓存,而加载内嵌的动态库速度较慢。所以,提高这一步的效率的关键是:减少动态库的数量。

 合并动态库,比如公司内部由私有Pod建立了如下动态库:XXTableView, XXHUD, XXLabel,强烈建议合并成一个XXUIKit来提高加载速度。 

Rebase & Bind & Objective C Runtime 

 Rebase和Bind都是为了解决指针引用的问题。对于Objective C开发来说,主要的时间消耗在Class/Method的符号加载上,所以常见的优化方案是:

 减少__DATA段中的指针数量。 

合并Category和功能类似的类。比如:UIView+Frame,UIView+AutoLayout…合并为一个

 删除无用的方法和类。

 多用Swift Structs,因为Swfit Structs是静态分发的。感兴趣的同学可以看看我之前这篇文章:《Swift进阶之内存模型和方法调度》 

Initializers 

 通常,我们会在+load方法中进行method-swizzling,这也是Nshipster推荐的方式。 

 用initialize替代load。不少同学喜欢用method-swizzling来实现AOP去做日志统计等内容,强烈建议改为在initialize进行初始化。 

减少__atribute__((constructor))的使用,而是在第一次访问的时候才用dispatch_once等方式初始化。 

不要创建线程 

使用Swfit重写代码。 

Objective C支持Category,在初始化的时候,也会把Category中的方法注册到对应的类中,同时会唯一Selector,这也是为什么当你的Cagegory实现了类中同名的方法后,类中的方法会被覆盖。” category的的确会实现类似覆盖原同名方法的功能,但是实现上不是覆盖,而是将category的方法放到原方法的前面,methodlist中就有了两个同名的方法,第一个方法是category方法,而第二个方法是原明发。这样在命中方法的时候,就会命中这个category方法。 系统的实现方法是这样的


作者:洋仔
链接:https://juejin.cn/post/6951591401528229895
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

;