前言
APP的启动优化,对开发者来说是一个永无止境的过程。开发者们在追求更快的路上,实现了一次又一次的突破(这里也包括Apple团队对操作系统的优化);而且启动优化也是面试经常会问到的问题。本篇文章我们就一起探索下在iOS APP中启动优化的那些事儿。
通常我们在做优化的时候会将优化以main()
函数为界分为2个部分,即main
之前的pre-main
阶段 和 main()
之后。这里我们就具体的看下:
启动性能检测main()之前
我们在前面的文章介绍过,在APP启动到main
函数执行的这个阶段,是由dyld
来操作的,即_dyld_start
->dyldbootstrap::start()
->dyld::_main()
->dyld::initializeMainExecutable()
->ImageLoader
->ImageLoaderMachO
->
libSystem_initializer()
->libdispatch_init()
->_objc_init()
->
_dyld_objc_notify_register()
->map_images
->load_images
->main()
的大致流程。而且这个阶段我们也无法介入,所在这里只能借助dyld
的一些配置参数来获取一些信息。这里我们通过一个DYLD_PRINT_STATISTICS
配置来获取pre-main
阶段的耗时统计。
在Edit Schemes -> Run -> Arguments -> Environment Variables中添加配置:
配置完成之后,运行我们的程序(冷启动),然后看控制台输出:
Total pre-main time: 5.8 seconds (100.0%)
dylib loading time: 169.23 milliseconds (2.9%)
rebase/binding time: 312.46 milliseconds (5.3%)
ObjC setup time: 229.00 milliseconds (3.9%)
initializer time: 5.1 seconds (87.7%)
slowest intializers :
libSystem.B.dylib : 6.01 milliseconds (0.1%)
TestApp : 5.0 seconds (85.9%) 主程序耗时
这里可以看到,输出了在pre-main
阶段的总的耗时数,同时列举出了每个环节的具体耗时情况,以及最耗时操作项。接下来,对每一项做一个简单的介绍和一些优化建议:
dylib loading time
: 动态库加载耗时(169.23ms)。关于动态库的加载,这个是不可避免的,我们能做的就是减少动态库的引用,官方的建议的是动态库的使用应该在6个以内,所以这里就引入了一个动态库合并的概念(后面的文章会详细介绍),通过合并动态库,从而减少在pre-main
时的加载时间。
rebase/binding
: 偏移修正/符号绑定。这个过程由操作系统完成。(ASLR
安全机制,在二进制文件头部添加随机值)/
ObjC setup
: OC类注册。这也就意味着项目中OC类越多,这里消耗的时间也就会增加。
initializer
: 这个阶段指的是+ (void)load,C++构造函数等初始化操作。 这里可以看到用时5.1 seconds
,是所有项做高的。这里是因为我在项目里面随便的一个类里实现了+(void)load
函数,并模拟了一个耗时操作。所以这里的优化比较明确:1. 能不使用+load
就尽量不要使用,可以将load
内部逻辑推迟到initialize
时;2. 使用到了load
,就尽量不要在内部执行耗时操作;3. 如果混编了C++
代码,要尽量减少构造函数中的耗时操作
slowest intializers
: 启动时用时最慢的文件,这个可以看到耗时最多的是TestApp
项目本身,这里主要是由于那个模拟的耗时操作导致。
启动性能检测main()之后
在main()
函数之后的优化就因项目不同而异了,大致有这么几个核心:
- 业务逻辑:这里主要指APP从启动到首页呈现的阶段。尽量减少与该阶段无关且没有必要的初始化代码操作,把这部分代码以懒加载的方式处理。
-
删除无用代码:这里是随着业务的发展,APP不断的迭代更新,会产生很多的的下架业务,从而堆积了很多的无用代码,这些代码会增加
ObjC setup
的耗时,所以要清理掉。 - 多线程操作:在启动时,将一些必要的非UI业务且需要初始化操作的任务放在子线程中,这样可以在APP启动的时候,发挥CPU的最大性能。
-
启动页面:首要呈现的画面,尽量减少使用
.xib
或storyBoard
来实现,因为它们需要解析成代码,会造成耗时。 - 业务具体优化:提升代码质量,采用最合理的实现方式等。
如果说上面的优化,都是具体项目内部代码的优化,而且都是些常规的操作,那接下来就开始装逼了,近年来比较有名的项目层面的优化——二进制重排。在第一次听到这个概念的时候,觉的非常的牛逼(无知嘛),但实际了解后发现,这本不是一个新的概念,而是苹果(Xcode)本身就提供给我们的能力,操作也非常的简单,由于不了解,所以被忽视。在实际操作前,我们需要弄清楚的是,
- 什么是二进制排列?
- 二进制重排是如何做到优化的,或者说它优化的是什么?
为了弄清楚这两个问题,我们就需要先了解一下计算机内存的发展演变过程,带着问题,继续阅读下文:
虚拟内存与物理内存
物理内存指通过物理内存条而获得的内存空间。
在早期的操作系统没有虚拟内存的概念,而是直接操作物理内存,当应用程序启动后,会全部载入物理内存中。
这样就导致了2个非常严重的问题:
1.内存不够用:由于应用程序会直接全部载入到物理内存,当多个应该启动时,出现了内存使用紧张问题;同时随着软件行业的快速发展,软件越来越大,当前的逻辑无法满足需求。
2.安全问题:由于是直接载入到物理内存,所以黑客可以通过内存地址直接访问到应用程序,并做任意更改;而且可以通过地址偏移访问到其他应用程序;造成了严重的安全问题。
为了解决上面的问题,就出面了虚拟内存。虚拟内存是计算机系统内存管理的一种技术。它使得应用程序认为它拥有连续的可用的内存(一个连续完整的地址空间),而实际上,它通常是被分隔成多个物理内存碎片,还有部分暂时存储在外部磁盘存储器上,在需要时进行数据交换。在当下的操作系统中,被普遍应用。
操作系统会给每一个进程分配独立的虚拟内存空间(大小为4G,并不会满载),将二进制文件进行碎片化切割,而切割的每一个碎片称为一个页表,页表大小在MacOS的大小为4kb,在iOS下是16kb。页表实际上是虚拟地址->物理地址的映射表,通过MMU技术实现查询,最终找到对应的物理地址。
此时,由于物理内存只存在多个进程的非连续碎片,使得物理内存空间得到了释放;每个APP进程都有了独立的虚拟内存,进程与进程之间隔离且独立,互不干扰,安全方面提升;同时开发者只能访问虚拟内存地址,而无法操作物理地址,黑客攻击难度加大。
还要注意一个点是:为了防止攻击者能可靠地跳转到内存的特定位置来利用函数,在二进制文件加载到虚拟内存时,采用ASLR技术,在二进制的头部添加了一个随机地址偏移。这也就对应上了在pre-main
阶段的rebase
(偏移修正)操作。
这跟二进制重排有什么关系呐?
当CPU访问页表数据时,首先访问虚拟内存页表地址,通过MMU 读取到物理地址,如果存在页表,读取物理内存上的数据;物理内存页表不存在时,会将虚拟内存对应的页表载入到物理内存,然后读取物理内存上的数据;当物理内存满载时,此时如果需要载入新的页表,操作系统会通过一些算法来找到物理内存中不活跃的页表数据,让新的页表直接覆盖掉(比如我们打开多个APP,并将APP退入到后台,长时间不打开后,再次从后台唤起时,需要重新加载)。
这里重点说一下物理内存页表不存在时的情况,此时会触发一个缺页异常/缺页中断(PageFault
),然后会将对应的虚拟内存中的页表载入到物理内存。一个PageFault
是会耗时的,只不过这个耗时非常短,用户感知不到。
在我们的应用中,什么时候会大量的出现PageFault
异常呐?
毫无疑问是APP刚启动的时候,初始化时,物理内存上还没有相关的页表,需要大量的载入,同时抛出大量的PageFault
,大量的PageFault
堆积导致了用户可感知的耗时。
而这里的页表
载入到物理内存是存在资源浪费的。比如APP启动到首页呈现的过程中调用了func0
,test1
,load2
等方法,但这3个方法分别存在Page0
,Page3
,Page5
,而为了达到目的,需要将这3个页表都载入到内存中,也就是3x16kb 的数据量,而我访问这3个方法的数据量一共还没有一个页表的大小,如果能将这3个方法放在同一个页表内,不就不需要其他页表的载入了吗?这里就引出了我们二进制重排,将一些相关性的数据放在同一个页表中(默认放在最前面的页表中),这样就能减少多个页表的载入,从而减少PageFault
的出现次数,启动耗时自然也就减少了。
接下来,我们通过一个简单的Demo 来感受一下:
@implementation ViewController
- (void)loadOtherObject
{
TestObject *objc = [TestObject alloc];
[objc toDoSomething];
}
- (void)func1
{
NSLog(@"func1");
}
- (void)viewDidLoad
{
[super viewDidLoad];
NSLog(@"viewDidLoad");
[self func1];
[self loadOtherObject];
}
void func0(){
NSLog(@"func0");
}
+ (void)load
{
NSLog(@"我是load");
func0();
}
@end
上面代码的执行顺序:
load
->func0
->viewDidLoad
->func1
->loadOtherObject
->toDoSomething
但是代码的排列顺序是什么样的呐?查看代码排列顺序:
Target->Build Settings->Link Map->Write Link Map File = YES
command+build
后,会在指定的Path to Link Map File
的位置生成一个.txt
文件,打开文件:从这里可以看出代码的排列顺序是跟代码书写顺序是一致的,页表的拆分也会按照这个顺序来分割,这也就导致了在
ViewController
中的用到的-[TestObject toDoSomething]
跟其它函数的调用会分布在不同的页表中,而二进制重排的目的就上将有关联性的零散分布在多个页表中的数据整合到一个页表内,来减少多个页表的载入耗时,提升APP启动效率。
这里的代码排列顺序为什么是先LoginView
,TestObject
,LoginViewModel
,SecneDelegate
.....的顺序呐?这是由Build Phases
下的Compile Source
决定的:
这里要注意的是二进制重排并不受这里顺序的影响。
PageFault调试
通过Xcode的Instruments工具来直观的看下当APP启动时会抛出多少PageFault
异常数量。
通过command+control+I
唤起Instruments,然后选择System Trace
点击运行,当程序起来后,结束运行,等待生成报告,
这里需要切换到APP的
MainThread
,然后选择Summary:Virtual Memory
,之后可以看到列表第一行File Backed Page In
,Count
值为836
,这里表示APP启动时发生了836
次缺页异常/缺页中断(PageFault
)。我们的优化目标就是降低这个Count的值。
二进制重排
上面大篇幅的文字,都是为二进制重排做铺垫,同时也回答了上面提到的2个问题。接下来,我们就实际的操作一下二进制重排,上文中我也提到过,Xcode本身就给我们提供了这个能力,我们只需要按照步骤操作就行了。
二进制文件 本质上是由 链接器(LLVM-ld) 生成的,而链接器本身自带一个参数Order file
,这个参数就是为了满足开发者自定义符号排列顺序而存在的。
在apple的库源码项目中也会看到这么一个.order
文件的存在,它也是通过二进制重排的方式做了一些优化:
到此,我们首先要在我们项目的根目录创建一个.order
文件,然后在Xcode中配置Order File
,最后编辑.order
文件:
接下来编辑TestApp.order
文件,在上的Link-Map中已经看到过代码排列顺序,
而且可以看到启动时使用到的代码都在后面:
# Address Size File Name
0x100004ED4 0x00000028 [ 1] +[LoginView load]
0x100004EFC 0x00000014 [ 1] +[LoginView initialize]
0x100004F10 0x00000114 [ 1] -[LoginView initWithFrame:]
0x100005024 0x00000014 [ 1] -[LoginView loadUI]
0x100005038 0x00000074 [ 1] -[LoginView loginClick]
0x1000050AC 0x0000002C [ 1] -[LoginView viewModel]
0x1000050D8 0x00000048 [ 1] -[LoginView setViewModel:]
0x100005120 0x00000044 [ 1] -[LoginView .cxx_destruct]
0x100005164 0x0000002C [ 2] -[TestObject toDoSomething]
0x100005190 0x00000080 [ 3] -[LoginViewModel toLoginWithPhone:pass:]
0x100005210 0x000000B4 [ 4] -[SceneDelegate scene:willConnectToSession:options:]
0x1000052C4 0x0000004C [ 4] -[SceneDelegate sceneDidDisconnect:]
0x100005310 0x0000004C [ 4] -[SceneDelegate sceneDidBecomeActive:]
0x10000535C 0x0000004C [ 4] -[SceneDelegate sceneWillResignActive:]
0x1000053A8 0x0000004C [ 4] -[SceneDelegate sceneWillEnterForeground:]
0x1000053F4 0x0000004C [ 4] -[SceneDelegate sceneDidEnterBackground:]
0x100005440 0x0000002C [ 4] -[SceneDelegate window]
0x10000546C 0x00000048 [ 4] -[SceneDelegate setWindow:]
0x1000054B4 0x00000044 [ 4] -[SceneDelegate .cxx_destruct]
0x1000054F8 0x000000AC [ 5] _main
0x1000055A4 0x00000088 [ 6] -[AppDelegate application:didFinishLaunchingWithOptions:]
0x10000562C 0x00000108 [ 6] -[AppDelegate application:configurationForConnectingSceneSession:options:]
0x100005734 0x00000080 [ 6] -[AppDelegate application:didDiscardSceneSessions:]
0x1000057B4 0x00000064 [ 7] -[ViewController loadOtherObject]
0x100005818 0x0000002C [ 7] -[ViewController func1]
0x100005844 0x00000088 [ 7] -[ViewController viewDidLoad]
0x1000058CC 0x0000001C [ 7] _func0
0x1000058E8 0x00000030 [ 7] +[ViewController load]
接下来我想要修改这个排列顺序为:
# Address Size File Name
0x1000058E8 0x00000030 [ 7] +[ViewController load]
0x1000058CC 0x0000001C [ 7] _func0
0x1000054F8 0x000000AC [ 5] _main
0x100005844 0x00000088 [ 7] -[ViewController viewDidLoad]
0x100005818 0x0000002C [ 7] -[ViewController func1]
0x1000057B4 0x00000064 [ 7] -[ViewController loadOtherObject]
0x100005164 0x0000002C [ 2] -[TestObject toDoSomething]
·····其他符号````````
编辑TestApp.order
,在文件中添加我们想要的符号顺序:
然后
command + B
编译一下,然后查看LinkMap.txt
的变化情况:可以看到的是,代码的排列顺序按照了
order
文件的顺序排列,并且将他们都放在了开头。到此,我们就完成了一次牛逼的二进制重排(是不是非常的简单)。
细节:
a. 在order
文件中指定的符号,Link Map
会按照order配置的顺序放在最前面;没有指定的符号,会按照原来的方式排列;
b. 在order
指定一个不存在的方法符号,编译时不会发生错误的,会自动忽略掉;问题
该如何写出一个完整的Order File
文件,或者说如何获取启动时加载的所有方法符号,启动结束的界限在哪里?