文章来自于WWDC2016-406_optimizing_app_startup_time
目录
一、理论
1.1、Mach-O术语
1.2、Mach-O Image File
1.3、Mach-O universal file
1.4、Virtual Memory虚拟内存
二、Mach-O 镜像和虚拟内存的映射
2.1、Load dylibs
2.1.1、ASLR和code signing
2.1.2、exec
2.2、Rebase 和bind
2.3、ObjC
2.4、initializers
三、减少启动时间
3.1、设置环境变量
3.1、binding and rebasing
3.1、initializers
一、理论
1.1、Mach-O术语
`Mach-O`是一种用于不同运行时可执行文件的文件类型。
所以第一个可执行文件,即应用程序中的主要二进制文件,它也是应用程序扩展中的主要二进制文件。
文件类型:
- Executable -- 应用程序的主要二进制文件
-
dylib
-- 动态链接库(对应Linux
平台的DSO
和Windows
平台的DLL
) - Bundle -- 特殊类型的
dylib
, 只能在运行时通过dlopen
打开,主要用于MacOS上的插件
Image -- 一种Executable
、dylib
或者Bundle
类型
Framework -- 一种dylib
,它有一个特殊的目录结构,用于持有该dylib
所需的文件(资源和头文件)。
1.2、Mach-O 镜像文件
Mach-O
镜像由多个segment
组成,按照惯例,segment
名称全部由大写字母组成。每个segment
由多个page size
组成,上面演示图中的TEXT这个SEGMENT包含三个page size
,DATA 和LINKEDIT各占一个。page size
主要由硬件平台决定,对于arm64
,page size
是16k,其它的平台是4k。
另外可以以section
的角度审视Mach-O
的组成。section
是segment
的一部分,它没有整数倍page size
的限制,但是section
之间不能重叠。section
是以小写字母命名的。
大多数二进制文件都存在__TEXT, __DATA, __LINKEDIT 这三个segment
。
- __TEXT -- 二进制文件的起始段,内部包含
Mach header
,所有的机器指令以及只读变量 如:c 中的字符串。 - __DATA -- 包含所有的可读写内容:全局变量,静态变量等
- __LINKEDIT -- 包含如果加载程序的“元数据”,比如函数的名称和地址等数据
1.3、Mach-O universal file
假如建立了一个64位的iOS 应用,那么就有了一个Mach-O
文件。如果想让应用运行在32的机器上,只能重新在Xcode上编译,这是会生成另外一个Mach-O
文件,如果两个Mach-O文件合并到第三个文件中,那么这个文件就是Mach-O universal file。
Mach-O universal文件起始位置是一个header文件,即Fat header
,它占用一页空间的大小。Fat header
包含了所有的架构体系以及它们在文件中的偏移量。
按分页来存储这些segement
和 header
会浪费空间,但这有利于虚拟内存的实现。
1.4、Virtual Memory虚拟内存
软件工程中有个谚语,“计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决” 。
当有多个进程,如果同时映射到物理的RAM,就是利用了这一点。
每个进程都有一个逻辑地址空间,它被映射到RAM的某个物理页面。现在这个映射不是一对一的关系,逻辑地址空间也许没有对应的RAM的物理页面,或者多个逻辑地址空间被映射到同一个物理页面。这就给我们提供了很多机会。
针对第一种情况会发生page fault(页缺失)
,内核会停止该进程,并尝试查看需要做什么。
针对第二种情况RAM内存中的某些页会发生共享。
第三个有意思的特点是file backed mapping
(文件备份映射)。实际情况不是将整个镜像文件读入到RAM中,而是利用mmap 调用来通知VM系统,我想要把镜像文件中的一部分映射到进程中的某个范围。没有将整个镜像文件读入到RAM中,而通过建立映射,当你第一次操作不同的地址时好像他们已经被读入到了内存中,每次当操作了一块地址而这块地址之前未被读入到内存中时,就会发生page fault
,内核就会将读入那块地址并与RAM中的某一页建立映射。所以这就给了懒加载文件的机会。
现在把这些特点串联一下,dylib
或者image
中的TEXT段可能被映射到多个进程,它的读取是懒加载形式的,加载到内存中的page
可以被多个进程共享。
DATA段是可读可写的,因此我们有一个称为copy on write
的技巧,它类似于Apple文件系统中的克隆。
当所有的进程只是读取全局变量,这时候共享起作用,当有一个进程试着对某一个DATA页写操作时,写时复制就产生了。写时复制会将当前RAM中的那一页复制到RAM中的另一页,并重新将进行写操作的进程映射到新的页。所以这个进程就拥有了这个页。这时产生了两个概念,clean page 和dirty page
。clean page 是被复制的那个页,dirty page
是复制的那个页。clean page是可以重新磁盘上读取时内核可以重新产生的一些东西,dirty page
包含了进程中一些特殊信息。因此dirty page
的操作是相对昂贵的。
权限问题,对于每一个page,都可对其设定readable, writable和executable,或者这三个权限的组合。
二、Mach-O 镜像 和虚拟内存的映射
下面的这段话,最好结合官方的PPT,要不然可以略过。这部分主要利用一个例子讲解Mach-O如何映射到内存中去,精华所在。英文好的同学可以直接看视频,或相应的文档。翻译可能有些出入,有错误希望各位指出。
假设我们有一个dylib
,没有将其读入内存,而是跟内存有一个映射,那么在内存中这个dylib
将占用8个页大小的空间。对于节省下来的空间(这个dylib
映射到内存中占用8页,在磁盘中占用5页),他们的不同点是用零填充,事实证明全局变量就是用零进行初始化的,所以静态链接器会做一次优化,把所有零填充的全局变量放到底部(__DATA段底部),而不占用任何磁盘空间。相反,我们使用vm
特性告诉vm
第一次访问这个页面时,用零填充它,所以不需要读取。
接下来dyld
(dynamic loader)第一件要做的事就是在当前的线程对应的内存中查看Mach header
,所以它将查看内存的顶部,当查看的时候,那里什么都没有,即没有一个对应的物理映射,所以page fault
发生了。就在那时,内核意识到应该映射一个文件,因为它读取文件中的第一个页并把它放入物理RAM中,并建立映射。
现在dyld
可以通过Mach header
读取数据了。它读取Mach header
,Mach header
说LINKEDIT段有一些你需要查看的信息,所以再一次,dyld
指向线程一的底部,它也导致了page fault
。内核将LINKEDIT读入到物理内存中的另一个页。现在dyld
可以依赖LINKEDIT。
现在LINKEDIT将会告诉dyld
需要对DATA做一些修复让dylib
可以运行。因为同样的事情发生了,dyld
将从DATA页读取一些数据,但是这里稍微有些不同。实际上dyld
正在执行回写操作,那就意味着它正在改变DATA页,因此copy on write
(写时复制)就发生了。那一页就变成了dirty
页。如果我们分配了8页的空间之后全部将他们读入,在内存中我们将会得到8页的dirty page
,但是现在我们只一个dirty page
,和两个clean page
(解释: 目前只读入了三个页,一个Mach header
, 一个LINKEDIT, 一个DATA),内存中的对应的DATA页变成了dirty page
)。
第二个线程载入相同的dylib
的时候同样会以相同的步骤。首先查看Mach header
,但是这次内核说,我已经在内存中存在了这个页,所以它就只是简单进行映射,页没有进行io操作。LINKEDIT也是同样的,它进行的非常的快。
现在操作DATA页,这时内核查看是否DATA页对应的clean copy
是否存在于内存中的某个地方,如果存在复用它,如果不存在就重新读取 。在这个进程中,它会将RAM变成dirty。
现在进入最后一步,dyld
只有在进行它自己的操作时才需要LINKEDIT,所以那就意味着告诉内核,一旦操作完成,LINKEDIT页将不再需要。可以回收他们供其它使用内存的使用。
因此现在的结果是两个进程共享这个dylib
,本来每一个线程占用8页,一共16页,但现在我们只有两个dirty page
,一个clean页以及其它的共享的页。
2.1、ASLR和code signing
接下来我们会讨论下两个不太重要的事情是如何影响dyld
的。
一个是ASLR(地址空间随机分布),这是一个一二十年前的老技术,依靠它可以使加载的地址随机化。
第二个是code signing。在Xcode中,很多人都必须处理code signing,并且你认为的代码签名是,对整个文件运行一个加密散列,然后用签名对其进行签名。那意味着如何想验证它必须读入整个文件。相反在编译期间真正发生的是Mach-O
文件中的每一个page都会有自己独立的加密散列。这些散列存储在LINKEDIT中,这允许对每个页面进行验证,确保它没有被篡改,并且在页面上每次只有一个拥有者。
2.2、exec
什么是exec?exec是一个系统调用。
当陷入内核中,你可能想用一个新程序替换个这个进程。内核将清空整个地址空间来运行你指定的可执行文件。ASLR随机为它映射了一个空间,接下来要做的是自底至顶将整个区域标记不为可访问(意味着它是不可读,不可写,不可执行的)。对于32位系统,这块区域的大小至少4KB,对于64位系统,至少4GB。它捕获所有的空指针引用异常,并预测更多的位,它捕获任何指针截断。
三、 dyld的加载顺序
3.1、Load dylibs
在最初的几十年里,Unix的生活很轻松,因为所做的只是映射一个程序,将PC(程序计数器)设置到其中,然后开始运行它。之后共享库产生了。那谁来加载这些dylib
呢?他们很快意识到事情变得非常复杂,内核开发人员不想让内核来处理这件事,所以这时帮助程序(helper program)就产生了。在Mach平台它叫dyld
,在其它的Unix平台这叫LD.SO
。因此,当内核映射完一个进程后,它现在将另一个名为dyld
的Mach-O映射到另一个随机地址的进程中。设置PC到dyld
中,让其完成启动这个进程。现在dyld
运行在进程中,它的任务就是加载这个进程所依赖的dylib
,让所有东西就绪并准备运行。
现在我们捋一下这些步骤。这是一系列的步骤,它在底部有一个时间线,当我们经历过这些步骤时,我们会走过时间线。
第一件事是dyld
映射所有的独立的dylib
那什么是独立的dylib
?为了找到这些独立的dylib
,dyld
首先读取main executable
的头部,并且这个头部已经被内核映射到了内存中。这个头部是一个所有独立库的列表。现在开始解析头部,它将找到每一个dylib
,发现一个dylib
,它将会打开并解析每个文件的起始,需要确认这个文件是一个Mach-O
类型,验证它找到它的code signing(代码签名),并向内核注册发现的code signing。实际上dyld
会在当前的dylib
中的每个segment调用mmap。
你的App知道dyld
,dyld
会说你的app依赖A和B两个dylib
,把他们加载到内存中,任务就完成了。但是还可以更复杂些,因为A dylib
和 B dylib
他们自己可能会依赖其它的dylib
,因此,dyld
以解析main executable
相同的方式解析每一个dylib
,每一个dylib
依赖的dylib
可能已经被加载到内存中或者需要决定是否一些新的东西已经被加载,如果没有加载,那么需要加载它。这个操作持续进行下去,直到所有的文件都被加载。
现在看下进程,在mach系统中平均每个进程需要加载100到400个dylib
,也就是说需要加载好多个dylib
,幸运的是它们中的大多数是OS dylib
,开发者在编译操作系统的时候会pre-calculate
(预计算)和pre-cache
(预缓存)许多工作,这些工作是dyld
加载dylib
做的事件。因此OS dylib
加载非常非常快。
3.2、Rebase 和bind
现在我们已经加载了所有的dylib
,但是他们分布在各自独立位置上, 我们现在要做的是把他们绑定到一块,这步操作称作fix-ups.
对于fix-ups我们所了解的是因为有code signing
的存在,所以我们不能改变指令(instruction)。那么,如果不能改变一个dylib
如何调用的指令,那么它如何调用另一个dylib
呢?答案是我们依然通过添加中间层来解决。在Mach 平台这个code-gen(代码产生器)叫做dynamic PIC(Position Independent Code)。它定位独立的代码,这意味着代码可以加载到地址中,并且是动态的,意味着它是间接寻址的。
这意味着一个对象调用另一个对象时,co-gen实际上在DATA段中创建了一个指针,该指针指向所要调用的东西。代码加载那个指针并跳到指向的地址。所以dyld
要做的事件就是修复指针和数据。
fix-ups主要包含两个方面:rebasing和binding。rebasing
就是如果有一个指针,并且这个指针指向镜像内的某个地方,可以通过它进行调整。binding
就是指向镜像外的某个地方。它需要做些不同的操作,来看下下面的步骤。
但是首先,如果你好奇的话,有一个命令,上面有很多选项。 你可以在任何二进制文件上运行它,你将看到dyld
为准备该二进制文件所必须做的所有修复。
[~]> xcrun dyldinfo -rebase -bind -lazy_bind myapp.app/myapp
rebase information:
segment section
__DATA __const
__DATA __const
__DATA __const
__DATA __const
...
address type
0x10000C1A0 pointer
0x10000C1C0 pointer
0x10000C1E0 pointer
0x10000C210 pointer
bind information:
segment section
__DATA __objc_classrefs 0x10000D1E8 pointer 0 CoreFoundation _OBJC_CLASS_$_NSObject
symbol
0x10000D4D0 pointer 0 CoreFoundation _OBJC_METACLASS_$_NSObject
0x10000D558 pointer 0 CoreFoundation _OBJC_METACLASS_$_NSObject
0x10000C018 pointer 0 libswiftCore __TMSS
address type add dylib
__DATA __data
__DATA __data
__DATA __got
...
lazy binding information:
segment section
__DATA __la_symbol_ptr 0x10000C0A8 0x0000 libSystem
__DATA __la_symbol_ptr 0x10000C0B0 0x0014 libSystem
__DATA __la_symbol_ptr 0x10000C0B8 0x002B libSystem
...
在以前你可能为每一个dylib
分配一个加载地址,这个加载地址是静态链接器和dyld
在一块工作的地方,当加载dylib
到指定的地址,所有的指针和数据都是内部的,它们都是正确的所以不需要修复。但是现在由于ASLR的存在,它滑动到了其它的地址,那就意味着指针和数据仍然指向旧的地址。为了修复这些指针和数据,我们需要计算偏移量。对于每一个内部的指针,需要在原来的基础上加上偏移量。所以rebasing
意味着遍历所有的数据指针,在原来的基础上加上偏移量。概念非常简单,读、加、写。但是数据指针在哪?数据指针被编码在了LINKEDIT段。现在所有的东西都进行了映射,当开始rebasing
的时候我们实际上是在所有的DATA页引起page fault
。当改变他们的时候出现copy on write
,所以rebasing
是昂贵的,因为它会对所有涉及到的io进行操作。但是苹果开发人员做了一部分技巧那就是顺序的执行操作,从内核的角度看,所有的page fault
都是顺序的发生。当明白这一点,内核将会为我们提前读取以此减少代价。
接下来是binding
,它其实是根据名字进行限制的。它们实际上是字符串,malloc分配的空间信息存储在LINKEDIT,那就是说数据指针需要指向malloc。所以在运行期间,dyld
要做的是在符号表里找到symbol的实现,这将花费大量的计算。一旦找到,就把值存储在数据指针内。这一步的复杂的计算量是rebasing
是不能比的,但是做的io操作会很少,因为rebasing
阶段已经做了大部分。
3.3、ObjC
接下来ObjC包含大量DATA结构,其中类DATA结构是一个指针指向自己的方法,另一个指向指向super gloss
等等。经过rebasing
和binding
所有的东西都被修复。
但是ObjC在运行的时候需要有一些额外的东西。
首先,ObjC是一门动态的语言,你可以根据类名实现一个类。那就意味着ObjC运行时必须包含所有名字的一张表,每个名字映射到对应的类。所以每次加载一些东西,它定义一个类,它的名字需要注册到全局的表中。
在c++中你可能听过fragile ivar
问题,fragile base class
问题,但是在ObjC中这些问题都不存在,因为在加载的时候,修复阶段中其中一个阶段就是动态地改变所有变量的偏移量。在ObjC中可以定义分类来改变另一个定义的方法的实现。有时被定义分类的原始类不在自己的镜像文件中而是在另一个dylib
中,这些方法需要在这个阶段进行修复。
最后ObjC是基于唯一的selector的,所以必须保证selector是唯一的。
3.4、initializers
现在轮到我们动态修复DATA。在c++中,你可以有一个初始化器,在这里你可以添加任何的表达。任意的表达需要在这里运行,并且已经运行了,所以C++为这些任意的DATA初始化产生初始化器。在ObjC中也有一个类似的方法叫+load
。现在+load
被废弃,不建议使用。建议用+initialize
方法.
到目前为止,上述的东西形式了一个庞大的图表,main executable
在顶层,下面是所有依赖的dylib
。在这张图表中,我们要必须运行初始化化器方法,但是初始化的顺序是什么样的呢?答案是自底向上。原因是当initialize运行的时候它可能调用一些dylib
,你要确保依赖的dylib
已经被加载。所以通过自底向上一直到app类这个过程运行各自初始化器,你可以安全地调用你所依赖的东西。一旦所有的初始化器完成,我们最终可以调用主dyld
程序了。
四、减少启动时间
想要启动速度多快?
启动速度在不同的平台是不同的,但是,一个很好的经验法则是400毫秒是一个很好的启动时间。原因是当我们看到app从桌面到该应用的时候,这个过程会有一个过渡的动画,给我们一种连续性的感觉。这些动画需要时间,它给我们一个隐藏启动时间的机会。很明显它是不同的,在不同的环境中,它有不同的启动时间。phone 、TV和watch是不同的平台,但是400毫秒会是一个好的目标。不要将启动时间超过20s,OS会将其杀死,这样的话它就会进入一个无限的循环。
另外,在支持的最低设备上测试app启动时间是非常重要的。如果你现在在6s上测试的时间是400毫秒,那么它在iPhone 5上测试的时间可能超过400毫秒。
App启动的时间都需要做什么? 我们需要解析镜像,映射镜像,rebase镜像,bind镜像,运行镜像的初始化器,然后调用main。在那之后会调用UIApplicationMain,你可能在ObjC 应用中看到,但是在Swift 应用中,它被隐式处理了。启动过程还会做其它的事情,包括运行framework的初始化器,加载nib等,最后在application delegate中, 我们会得到一个回调。在400毫秒的启动时间里其实已经将最后两步的时候计算在内。
4.1、冷启动VS热启动
当启动app的时间,我们讨论的是冷启动和热启动。
热启动是app已经在内存中,可能是因为它之前已经启动并退出了,但它仍然位于内核的discache中,也可能是因为刚刚对它进行了复制。
冷启动是应用程序没有在discache中,冷启动的启动时间测量是非常重要的,原因是当重启手机之后或者在很长时间之后第一次启动app,这时的启动时间是我们真正需要的。为了测量,你需要在两次测量之间重启app。话虽如此,但是如果你提高了热启动的启动时间,那么相应的冷启动的启动时间也会有所降低。可以针对热启动执行快速开发,但之后每隔一段时间,都要用冷启动进行测试。那么如何测量main之前的启动时间?可以通过在dyld
中的测量系统测量,就是设置下环境变量。dyld
可以打印数据。它其实已经在过渡的操作系统中已经存在,但是它会打印一些可能没有用的debug信息,并且可能遗漏一些你想要的信息。在新的操作系统中已经进行了显著的提高,它只打印可能对提高启动时间有帮助的相关信息。
为了解析应用程序中的符号并加载断点,调试器必须在每次加载dylib
时暂停启动,而这可能会非常耗时。但dyld
知道这一点,它从注册的数字中减去了调试器超时。
所以你不必担心,但是你注意到了,因为dyld
给你的数字比你看墙上的钟看到的要小得多。
4.2、设置环境变量
在xcode里面设置环境变量DYLD_PRINT_STATISTICS
,如下图所示:
设置完之后,你就会在控制台得新的输出日志。
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%)
前面提到,操作系统在编译的时候已经预计算了一些数据,但是不可能包含每个app中所有dylib
。当进程加载这些dylib
的时候我们就会经历一个非常慢的进程。解决方案是尽量少的使用dylib
。具体执行的方案有如下:
一是可以使用静态归档文件,并将它们链接到两个应用程序中,以这种方式连接到应用程序中。
二是对dylib
进行懒加载其加载方式是通过dlopen。但是dlopen会造成微妙的性能问题和正确性的问题,它可能导致以后会做许多工作。所以这个方法废弃。
这里有一个包含26个dylib
的app,将他们全部加载需要花费240毫秒的时间,但是将这些库合并成两个dylib
,它只占用了20毫秒。合并到一块,仍然有这些功能,仍然可能共享他们,但是对这些dylib
进行限制是非常有作用的。
这是开发便利和启动时间之间的一种权衡。因为dylib
越多,你可能更容易编译和链接你的app,加快开发周期。
所以你绝对可以而且应该使用一些,但最好是把目标设定在有限的数量上,我想说,一个好的目标大约是6个。
4.3、binding and rebasing
rebasing
因为io操作往往是很缓慢的,binding
往往是计算比较花费时间但是io操作几乎做完。因此它们的io操作是混合到一块的,所以时间也是混合到一块的。可以发现fix-up修复的主要是DATA段的指针,所以我们要做的就是减少指针的数量。
dyld
info指令可以帮助我们查看DATA段的什么指针将被修复,它会指出dylib
中包含什么segment和section,从而会让你对正在修复的问题有一个很好的了解。
例如:如果在ObjC部分中看到一个ObjC类的符号,那么很可能有许多ObjC类。
所以,你可以做的一件事就是减少ObjC类对象和ivar的数量。
有许多编码风格鼓励使用轻量级的类,它们可能只有一个或两个函数,但是随着类的增多,这种特殊的模式会让的你的应用的启动逐渐减慢,对于你要注意这个情况。
App包含100或10000个类,这不是个问题,但是我们可以看到随着app的类从5,10,15 到20000增加的过程中,当内核将他们加载进内存时,app的启动时间增加了7或800毫秒。
另一件减少启动时间的方法是可以减少c++ 虚函数,他们比OjbC元数据小,但是他们对某些应用程序是非常重要,替代的方法是使用Swift的结构体。Swift倾向于使用较少的包含需要修复的指针的数据。Swift具有更好的内联特性,可以更好的避免这一点。所以迁移到Swift是提高减少启动时间的一种选择。
另外,你要注意机器产生的代码,当你采用DSL或自定义的语言描述一些数据结构,你可能有一些程序将这些描述生成代码,如果生成的代码中包含大量的指针,他们变得非常昂贵的,因为他们产生了非常大的结构。但好处是,你通常拥有大量的控制权,因为你可以更改代码生成器,使其使用非指针的内容,例如基于偏移量的结构。
这将是一个巨大的胜利。
4.4、initializers
有两种类型的初始化器一种是显式初始化器比如+ load,它应该被 +initialize代替,因为它会在类被证实存在的时候而不是文件被加载的时候执行你的代码。
或者,在C/C++中,有一个属性可以放在函数上(__attribute__((constructor))
),从而生成初始化器,所以这是一个显式的初始化器,不建议使用。
建议使用site initializers,其指的是像dispatch once
的初始化器。
在跨平台代码可以使用pthread once
, c++代码可以使用std once
。
上述的这些函数基本上都有相似的功能,即block里面的代码只会在第一次命中是执行,仅有一次。dispatch once
在苹果系统上做了很大的优化。在第一次执行之后,如果再次执行,block相应于一个空任务,什么也不做。苹果系统开发者强烈建议使用dispatch once
而不是显式的初始化器。
隐式初始化器指的是有关c++全局变量的non-trivial
初始化器。可以用site 初始化器代替,当然,有些地方可以放置具有non-trivial
的全局变量或指向要初始化的对象的指针。可以不使用non-trival初始化器,代替的是c++中的POD( a plain old data)
。如果是一个POD对象,静态链接器会为DATA 段预计算所有的数据,没必要运行,没必要修复。
隐式初始化器很难被发现,可以用-Wglobal-constructors
设置编译器来产生相应的警告。
另外一个选择是可以用swift进行重写。原因是swift有全局变量,它们会被初始化。他们会在你使用之前被初始化。它底层的实现不是利用initializer而是利用的dispatch once,site initializers中的一种。所以利用swift会自动处理这些东西。
在初始化器中不要调用 dlopen ,它会产生性能问题。app启动之前,dyld
正在运行,我们能做的是关掉锁,因为现在处于单线程中。只要dlopen调用了,情况就会发生改变,初始化器如何运行的整体构造将会改变。我们可能处在多线程中,不得不打开锁,它将会产生很糟糕的性能问题。你也可能遇到不易察觉的死锁问题或者无法预期的行为。作为同样的原因,不要在初始化器中开启线程。
总结:
- 移除不需要用到的动态库
- 移除不需要用到的类
- 合并功能类似的类和扩展
- 尽量避免在+load方法里执行的操作,可以推迟到+initialize方法中。
- 使用swift。