今天来聊聊启动优化。
启动分为两种类型,一个是冷启动,一个是热启动:
一般而言,我们讲启动优化指的都是冷启动的优化。
启动优化,优化的是启动的时间,所以说,首先要知道如何去获取启动的时间。我们一般以应用程序的main函数作为一个节点,分为main函数之前的启动(pre-main阶段)和main函数之后的启动。所以说启动时间的测量也是分为pre-main和main这两个阶段来分别采取不同的方式测量。
pre-main阶段的启动时间测量以及优化
首先来介绍一下如何打印preMain阶段的启动时间,其实很简单,摁住【Command + Shift + ,】,然后添加一个DYLD_PRINT_STATISTICS变量即可:
配置完成之后再运行,就会看到控制台上打印出了preMain阶段的启动时间:
Total pre-main time: 1.8 seconds (100.0%)
dylib loading time: 257.71 milliseconds (13.9%)
rebase/binding time: 1.4 seconds (79.3%)
ObjC setup time: 80.48 milliseconds (4.3%)
initializer time: 43.19 milliseconds (2.3%)
slowest intializers :
libSystem.B.dylib : 4.08 milliseconds (0.2%)
可以看到,我的测试工程的pre-main阶段的启动时间是1.8 seconds,注意哦,这个启动时间指的是冷启动的时间。
可以看到,阶段的启动时间组成大致可以分为4个部分:
接下来就来聊聊在上面的四个阶段,每个阶段都可以各自进行什么样的优化。
dylib loading time
这里是加载动态库所需的时间。
这个阶段该怎么做优化呢?
首先,Apple自身的系统库(比如UIKit、Foundation)是经过高度优化的,所以我们实现一个功能的首选还是苹果系统库。
然后,对于开发者自己导入的动态库(framework格式的),它需要的耗时是比较多的,苹果给出的建议是不要超过6个。
rebase/binding time && ObjC setup time
这两个时间,可优化空间是比较小的,唯一的办法就是减少OC的类。网上有人测试过,2万个OC的类会增加800毫秒。所以说,你在项目中刻意地去减少OC的类,实际上没有多大意义。
当然,对于那些历史遗留的、现在已经不使用了的老代码,及时清理掉是最好的选择。
initializer time
这一块是OC的load方法的耗时。所以说,尽可能地减少在load方法里面去做事情,能在initialize里面做的要尽可能在initialize里面去做。关于load和initialize的对比,可以参考initialize和load的调用时机
main阶段的启动时间测量以及优化
上面?我讨论了main函数之前的启动时间的测量以及优化方案,接下来就来讨论下main函数以及main函数之后的启动时间的测量以及优化方案。
main函数之后的启动时间测量的一个思路就是在特定的地方打点,一般是在main函数里面打一个点,然后在第一个界面的viewDidAppear再打一个点。接下来我就介绍一个打点工具——BLStopwatch
Github 地址:https://github.com/beiliao-mobile/BLStopwatch
我先把它下载下来:
git clone https://github.com/beiliao-mobile/BLStopwatch.git
下载到桌面后打开文件夹:
可以看到就一个BLStopwatch类。实际上我们可以参考这个打点工具类,来实现一个自己的打点工具类。
通过打点工具类,就可以获取到main函数之后各个过程的耗时,其具体使用这里就不赘述了,Github中有详细的使用说明。
接下来就来聊一聊在main以及main之后,该如何对启动时间进行优化。
懒加载
我们知道,在启动的时候,会对类进行加载,但是此时加载的都是非懒加载的内容,对于那些懒加载的内容,是在第一次使用到的时候才会进行加载的。所以说,能懒加载的就尽可能懒加载。
充分发挥CPU的价值
随着硬件技术的进步,好多设备的CPU现在都已经是多核了,多核的CPU在单线程中是发挥不出其威力的,所以我们需要尽可能在多线程中去做一些需要在启动阶段做的事情。当然,能用多线程的才要去用哈,不要强行去使用,搞出Bug来就尴尬了~
启动阶段展示的页面尽量避免使用sb/xib
storyBoard和xib的本质是XML文件,它是需要被解析成原生代码(OC或者Swift)的,解析的这个过程也是需要时间的,而在启动阶段,时间需要尽可能的短,所以在启动阶段展示的视图要尽量避免使用storyBoard和xib。
好,到这里为止,我就将启动优化常规的一些内容都给说完了。接下来咱就来聊聊一个非常规的黑科技——二进制重排,看一看如何在项目中去使用二进制重排来进行pre-main阶段的启动优化。
虚拟内存 & 物理内存 & ASLR
在讲二进制重排之前,我们先来了解一下虚拟内存和物理内存的概念。
首先,声明一个观点,每一项新技术的诞生都是为了解决实际的问题的。
在计算机发展的早期,是没有虚拟内存这个概念的,那个时候计算机中的内存地址都是实打实的物理内存地址。这个时候,当我们的应用程序需要执行的时候,计算机就会将存储在磁盘中的二进制可执行文件全部加载进内存当中。此时如果有多个应用程序执行,那么所有执行的应用程序就都会被计算机从磁盘加载进内存中,而这多个应用程序的可执行文件在内存中是一次自上而下排列的,如下图:
可以看到,各个应用程序的内存之间是紧挨着的,也就是说,应用程序1将其内存地址加上一定的大小,就可以访问到其他的应用程序的数据了,这是很不安全的。
仅使用物理内存的第二个弊端就是,内存浪费太严重。一个应用程序可能会有很多功能,因此其大小可能会比较大,而用户使用的时候可能只会使用其中的一个小功能,这个时候我将整个应用程序都加载进内存,势必会造成内存空间的浪费。当内存被占用殆尽的时候,其他的需要执行的应用程序由于不能获得足够的内存空间,所以它们就需要等待,直到有足够的内存空间被释放出来。
为了解决上面说的物理内存的这些弊端,虚拟内存技术应用而生。
如上图所示,进程在运行的那一时刻,系统就会为其开启一块虚拟内存。这个虚拟内存空间中的地址都是连续的,我们在开发中使用lldb断点调试的时候x出来的地址就是虚拟内存中的地址。
另外,还会有一个进程的映射表(又称页表),这个映射表存在于内存中,由操作系统进行管理,它的作用就是将虚拟内存中的各个地址映射到物理内存的不同区域上。
从上图中可以看到,一个应用程序的虚拟内存空间是连续的,但是对应的物理内存空间有可能是不连续的。
实际上这就解决了只使用物理内存的安全问题。比如说进程1的虚拟内存空间,你随便访问其中的任何一个地址,它都对应的是进程1 的物理内存,你永远访问不到进程2 的物理内存上面去。
现在我们知道了,虚拟内存可以解决内存安全问题,那么内存的浪费以及使用效率的问题可以得到解决吗?
比如说,进程1占用4个G,进程2占用4个G,那么物理内存就是占用8个G了,如果真的是这样的话,那内存也是经常爆表啊!
实际上,内存分页管理技术就解决了内存的使用效率问题。我们知道,虚拟内存通过映射表映射到物理内存上面,如果映射表是以字节为单位(即虚拟内存中的每个字节都会通过映射表映射到物理内存的对应字节上面),那么这个映射表就会非常大。所以为了节省映射表,它也不可能以字节为单位,实际上,映射表是以页为单位的,在Linux、MacOS上一页是4KB ,在iOS上一页是16KB。这也是为什么映射表也被称为页面。
应用程序在启动运行的时候,只会将其使用到的内存加载到物理内存中。
比如现在进程1在运行,目前页表中P1、P3、P5被标记为1,说明这三块对应的虚拟内存的内容在物理内存中并不存在。
接下来进程1执行某个任务,需要访问某个方法,通过该方法的虚拟内存地址找到虚拟页表中对应的是P2,此时P2被标记为0,这说明其对应的物理内存还没有被加载进来,此时系统就会立刻阻塞当前进程,并发出一个pageFault缺页异常,然后将P2对应的磁盘中的数据加载进物理内存,加载进来后在虚拟内存中将P2标记为1,并将P2指向该物理内存。
在加载P2对应的磁盘数据的过程中,如果物理内存中有空的位置,那就加载到这个空位置即可;如果没有空位置了,那么就将当前不活跃的那一页对应的物理内存空间给覆盖掉,至于覆盖哪一页,那由操作系统决定。
所以,你永远不会感觉到内存不够用。
这就解决了内存的效率问题。
介绍到这里,我们已经知道了,虚拟内存解决了仅物理内存的安全问题以及效率问题。但是虚拟内存也带来了一个问题。
早期只使用物理内存的时候,应用程序启动后被加载到内存中的哪个位置是不确定的;但是在使用虚拟内存之后,我这个应用程序在编译完成之后,函数在可执行文件中的位置就是固定不变的了,这样的话,黑客就可以很容易破解你的应用程序,因为他可以直接通过地址来访问到你的函数,进而Hook住该函数实行静态注入。
为了解决上面所说的这个问题,就又出现了一门技术——ASLR。
ASLR(Address Space Layout Randomization,地址空间布局随机化)指的是,在虚拟内存每次加载之前,都在其前面加一个随机的偏移,这个偏移就是ASLR。这样的话,我应用程序每一次运行,其函数在虚拟内存中的地址就都是不一样的了。
至此,我已经将二进制重排相关的需要了解的知识点完全介绍完了,下一篇文章我将系统地聊一聊二进制重排。
现在我可以简单聊一聊二进制重排的原理。
在虚拟内存里面,如果使用的函数等还没有加载进物理内存,系统就会发出缺页中断(pageFault),而pageFault是需要消耗时间的,每一页的耗时有很大的差距,少则0.1毫秒,多则1毫秒。在程序执行的时候,用户使用过程中,几毫秒实际上用户是感知不到的;但是在应用启动的时候,会有大量代码需要执行,此时会有数量众多的pageFault,这样一累计,用户就可以感知到了。
所以说,我们的优化思路就是尽量减少pageFault。
比如上图中进程1,启动的时候只有三行代码,而代码在MachO文件中的顺序是根据文件的编译顺序来的,它跟代码的调用顺序无关,因此启动的这三行代码很有可能是分别位于P1、P3、P5位置。此时,在启动的时候就需要3次pageFault,这实际上是很不划算的,我们需要做的是,将所有的启动代码都放到前面,这样就可以减少pageFault的数量,进而减少启动时间。
将启动代码重新排列到二进制可执行文件的前列,这种技术就是二进制重排。
以上。