内容概要:
1. 启动速度
2. 如何测量启动时间
3. 影响启动时间的原因
4. 启动优化的方案
我们的应用在运行前应该减少操作,推迟一些启动行为,从而在启动前一点点时间进行初始化。下面让我们来看本章内容概要。
一、启动速度
在不同平台上,应用的启动时间有所不同。苹果在开发者大会中提出400毫秒是一个不错的启动时间。原因在于,当你看着应用在运行时,手机上的启动动画
能够给用户带来一种在主屏幕和应用之间切换时的持续感。这些动画占用时间,并且会给你一个机会隐藏启动时间。
显然根据情况会有所不同,app扩展程序也是应用启动的一部分,它们启动的时间不同。手机,电视和手表是不同的设备,但400毫秒是一个很好的启动目标。此外,启动时间不要超过20秒,如果超过20秒,OS会终止应用,以为它进入了死循环。
最后在所支持的最慢的设备上进行测试也很重要,在Apple平台支持的所有设备上,这些时间都是常量值。如果你在iPhone 6s上测试的结果达到400毫秒,很可能在iPhone 5上达不到。在前面的理论部分我们知道启动时需要做什么,要解析图像、映射图像、重设基址图像、绑定图像、启动图像初始化器、调用主函数,还有一些操作,包括运行框架初始化器以及加载NIB,最终在应用委托里收到回调。最后两个操作也计算在我们前面说的400毫秒的时间里。
启动应用时,分冷启动和热启动。
-
热启动
是指启动时应用已经在内存里,或者因为启动过,之前退出了,但还在内核的磁盘缓存里,或者因为你刚把它复制过去。 -
冷启动
是指启动时应用不在磁盘缓存里。
测量冷启动时间通常更为重要。冷启动时间更为中重要的原因是,当用户重启手机后启动应用,或很长时间后启动应用,这时非常需要一个快速启动。为了测量冷启动时间,必须在每次测量之间重启设备。如果你正尝试优化热启动时间,那冷启动时间应该也会随之加快。你可以通过加速开发周期加快热启动,但是请时不时地测试一下冷启动。
二、如何测量启动时间
在主函数启动之前该如何测量时间?dyld
里有内置的测量系统,可以通过设置环境变量DYLD_PRINT_STATISTICS
和DYLD_PRINT_STATISTICS_DETAILS
访问。安装操作系统时候就可用了,但它打印了很多内部调试信息,并没有什么用。它缺少了某些你可能想知道的信息。现在我们就来改进,在新的OS里进步显著。
它可以为你提供更为相关的信息,这些信息应该会提供可操作的方法,来加快启动时间。当加载每一个dylib
,调试程序必须暂停启动才能解析应用的符号和加载断点,这通过USB线将非常耗时。但是dyld
清楚这一点,它把调试时间从注册时间里减出去,所以不必为此担心。但是你会注意到它,因为dyld
会显示比你在钟表中所观察到的数字精细得多。这是预期的和能够接受的,如果你看到了那个数字,一切都是正确的。这里只是提示下。
在Xcode里设置环境变量,如下图所示:
运行后,控制台的输出信息如下:
Total pre-main time: 10.6 seconds (100.0%)
dylib loading time: 240.09 milliseconds (2.2%)
rebase/binding time: 351.29 milliseconds (3.3%)
ObjC setup time: 11.83 milliseconds (0.1%)
initializer time: 10 seconds (94.3%)
slowest intializers :
MyAwesomeApp : 10.0 seconds (94.2%)
下面的时间条代表上面不同部分所占时间,而白色的虚线代表400毫秒:
上面的基本步骤就是前面理论部分讲的启动顺序。
三、优化方案
(一)dylib
加载优化
关于dylib
加载,还有从中看到的速度缓慢,需特别了解的是嵌入式dyld
会非常昂贵。我们知道一个应用大概包含100到400个Dylib,但是操作系统的dylib很快,这是因为构建操作系统时,我们预计算了大量dylib的数据。但是我们在开发操作系统时,无法做到每个应用里的每个dylib。我们无法预计算你要嵌入应用的dylib,所以加载时必须要经过一个耗时的过程。其解决方案是少用dylib,而这将非常困难。这并不是说完全不能使用,有很多方法可以合并已有的dylib。
可以使用静态存档,把dylib用这种方法链接到应用。还可以使用延迟加载,也就是使用dlopen()
函数。但是dlopen()
函数会带来细微的性能和正确性的问题,实际上会导致之后做更多的工作量,而这些工作量被延迟执行了。所以这是一个可行的选项,但是必须要仔细思考清楚,尽量减少这种延迟加载的操作。
优化方案:
- 使用更少的dylib;
- 合并现有的dylib;
- 使用静态存档;
- 懒加载;
(二)重设基址和绑定优化
重设基址和绑定需要350毫秒时间,根据前面的理论部分我们知道,重设基址由于I/O会更慢一些,而绑定在计算上会昂贵,但它已经完成I/O。所以I/O是为了它们,它们混合在一起,时间也混合在一起。我们深入研究一下,就会发现时间消耗在修复DATA
段里的指针。所以我们必须减少指针的修复。用其他工具可以看到在DATA
,分区,dyld信息中修复的指针。还能显示正在哪些段和分区操作,你会很清楚地了解到在修复什么。比如,若看到一个在ObjC
分区的ObjC
类符号,很可能你有很多ObjC
类。所以你能做的一件事就是减少ObjC
类对象和ivars
的数量。有很多编码样式都鼓励只有一个或两个函数的小类,这些特殊的模式可能会导致速度逐渐变慢。当你越加越多时,更要格外小心。现在有100或者1000个类不成问题,但有些大型应用有上万个类。在这种情况下,将会消耗更多的启动时间,因为内核要把它们读入页面。
还可以做一件事情,可以尝试减少使用C++虚拟函数。虚拟函数将会创建我们称之为V表格的东西,这和ObjC
元数据相同,因为它们在DATA
段创建了必须修复的结构。它们比ObjC
元数据小,但它们对于某些应用程序来说仍然很重要。
还可以使用Swift的结构体。因为Swift通常使用更少这种带有指针修复的数据。并且Swift更为内联,可以更好的使用code-gen
减少消耗。所以转为Swift语言也是一个好方法。
还有一点,需要小心机器生成的代码。曾经有过这样的例子,你可能用DSL或一些自定义语言描述某个结构,然后有一个程序从中生成其他代码。如果这些程序中有很多指针,它们将变得非常昂贵,因为生成代码时会生成非常非常大的结构。也有生成兆量级数据的情况。但好处是比较容易进行控制,因为你只需要改变代码生成器,使其使用非指针的内容,比如偏移基址,结构。
优化方案:
- 减少
__DATA
指针; - 减少
ObjC
元数据 - 类,选择器和类别; - 减少C++虚拟函数;
- 使用Swift结构;
- 检查机器生成的代码 - 使用偏移量而非指针,标记为只读;
(三)ObjC Setup
优化
关于设置ObjC
,前面理论部分讲过它做的工作,它要处理类的注册,要处理非脆弱ivar
,要处理分类的注册,还要让选择器唯一。这里我们不用处理太多,因为这些问题通过之前对重设基址,数据,和绑定的修复时都已经解决,之前所做的减少和在这里做的完全相同。
(四)初始化器的优化
初始化器有两种类型,显示初始化器,比如+load
,前面理论部分建议用+initiailize
取代它,这将导致ObjC
运行时在类被实例化而不是文件被加载时初始化代码。
或者在C/C++里,有一个可以放在函数上的属性,可以让函数像初始化器一样生成代码,因此这是显示初始化器,但不建议这么做。建议选择调用site initializers
取代上面的方式。调用site initializers
是指调用像dispatch_once()
函数,或者在跨平台代码里的pthread_once()
,或者在C++代码中的std::once()
。所有这些函数基本上都有相同的功能,这些函数的代码只会在第一次点击时运行一次。dispatch_once()
在系统里很优秀,第一次执行以后,几乎等同于无操作,直接跳过。所以强烈建议不要使用显示初始化器。
另一种初始化器是隐式初始化器。隐式初始化器大部分来自带有非默认初始化器,非默认构造函数的C++全局变量。可以选择调用site initializers
取代它,在很多地方可以把全局变量替换成想要初始化的无全局结构或指针的对象。还有一种选择是没有非默认初始化器。所以在C++中,初始化器称为POD,一个普通的旧数据。
如果对象只是普通的旧数据,静态链接器,或者静态链接器将会为DATA
分区预计算所有的数据,只把数据放在那里,不一定要运行,不一定要修复。最后一点,很难找到它,因为它们是隐性的,但是编译器会收到警告 --- -Wglobal-constructors
,如果这么做,只要产生其中一个,就会有警告。所以把它添加到编译器使用的标志里是个好方法。还有一个选择就是使用Swift重新编写。理由就是Swift有全局变量,并且会被初始化,它们确保在使用前被初始化,但是其方法不是用初始化器,在后台使用一次dispatch_once()
。所以转为Swift将会做到这一点。
最后在初始化器里请不要调用dlopen()
函数,它将会带来巨大的性能问题。原因有很多,dyld
在运行时,是在应用启动之前,我们可以做一些诸如关闭锁的操作,因为是单线程。当dlopen()
出现在那种情况下,初始化器的运行发生了改变,可能会有多线程,必须要打开锁,将会带来巨大的性能下降,还会带来细微的死锁和未定义的行为。还有,不要在初始化器上开启线程,也是出于同样的理由。