本文从以下几个方面来分析iOS系统的APP的启动流程,以及我们在优化启动时间时应该在哪些方面入手
- 专业术语解释
- Mach-O
- dyld
- Virtual Memory
- Page fault
- Dirty Page & Clean Page
- main函数之前
- dyld2
- dyld3
- main函数之后
- 启动时间优化
- 冷启动 VS 热启动
- 分析方法
- 优化实践
专业术语解释
Mach-O
哪些名词指的是Mach-o
- Executable 可执行文件
- Dylib 动态库
- Bundle 无法被连接的动态库,只能通过dlopen()加载
- Image 指的是Executable,Dylib或者Bundle的一种,文中会多次使用Image这个名词。
- Framework 动态库和对应的头文件和资源文件的集合
Apple出品的操作系统的可执行文件格式几乎都是mach-o,iOS当然也不例外。
mach-o可以大致的分为三部分:
- Header 头部,包含可以执行的CPU架构,比如x86,arm64
- Load commands 加载命令,包含文件的组织架构和在虚拟内存中的布局方式
- Data,数据,包含load commands中需要的各个段(segment)的数据,每一个Segment都得大小是Page的整数倍。
我们用MachOView打开Demo工程的可以执行文件,来验证下mach-o的文件布局:
那么Data部分又包含哪些segment呢?绝大多数mach-o包括以下三个段(支持用户自定义Segment,但是很少使用)
- TEXT 代码段,只读,包括函数,和只读的字符串,上图中类似`TEXT,__text`的都是代码段
- DATA 数据段,读写,包括可读写的全局变量等,上图类似中的`DATA,__data`都是数据段
- LINKEDIT `LINKEDIT`包含了方法和变量的元数据(位置,偏移量),以及代码签名等信息。
关于mach-o更多细节,可以看看文档:《Mac OS X ABI Mach-O File Format Reference》。
dyld
dyld的全称是dynamic loader,它的作用是加载一个进程所需要的image,dyld是开源的。
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,同时会被复制。
想要了解更多细节,可以阅读文档:Memory Usage Performance Guidelines
main函数之前
dyld3
使用dyld2启动应用的过程如图:
大致的过程如下:
加载dyld到App进程
加载动态库(包括所依赖的所有动态库)
Rebase
Bind
初始化Objective C Runtime
其它的初始化代码
加载动态库
dyld会首先读取mach-o文件的Header和load commands。
接着就知道了这个可执行文件依赖的动态库。例如加载动态库A到内存,接着检查A所依赖的动态库,就这样的递归加载,直到所有的动态库加载完毕。通常一个App所依赖的动态库在100-400个左右,其中大多数都是系统的动态库,它们会被缓存到dyld shared cache,这样读取的效率会很高。
查看mach-o文件所依赖的动态库,可以通过MachOView的图形化界面(展开Load Command就能看到),也可以通过命令行otool。
1 | 192:Desktop Leo$ otool -L demo |
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
中。
由于ASLR(address space layout randomization)的存在,可执行文件和动态链接库在虚拟内存中的加载地址每次启动都不固定,所以需要这2步来修复镜像中的资源指针,来指向正确的地址。 rebase修复的是指向当前镜像内部的资源指针; 而bind指向的是镜像外部的资源指针。
rebase步骤先进行,需要把镜像读入内存,并以page为单位进行加密验证,保证不会被篡改,所以这一步的瓶颈在IO。bind在其后进行,由于要查询符号表,来指向跨镜像的资源,加上在rebase阶段,镜像已被读入和加密验证,所以这一步的瓶颈在于CPU计算
可以通过MachOView查看:Dynamic Loader Info -> Rebase Info
也可以通过命令行:
1 | 192:Desktop Leo$ xcrun dyldinfo -bind demo |
Rebase解决了内部的符号引用问题,而外部的符号引用则是由Bind解决。在解决Bind的时候,是根据字符串匹配的方式查找符号表,所以这个过程相对于Rebase来说是略慢的。
同样,也可以通过xcrun dyldinfo来查看Bind的信息,比如我们查看bind信息中,包含UITableView的部分:
1 | 192:Desktop Leo$ xcrun dyldinfo -bind demo | grep UITableView |
Objc SetUp
Objective C是动态语言,所以在执行main函数之前,需要把类的信息注册到一个全局的Table中。同时,Objective C支持Category,在初始化的时候,也会把Category中的方法注册到对应的类中,同时会唯一Selector,这也是为什么当你的Cagegory实现了类中同名的方法后,类中的方法会被覆盖。
另外,由于iOS开发时基于Cocoa Touch的,所以绝大多数的类起始都是系统类,所以大多数的Runtime初始化起始在Rebase和Bind中已经完成。在 runtime 的初始化函数 _objc_init 中,有这样的代码:
1 | void _objc_init(void) { |
Dyld 在 bind 操作结束之后,会发出 dyld_image_state_bound 通知,然后与之绑定的回调函数 map_2_images 就会被调用,它主要做以下几件事来完成 Objc Setup:
- 读取二进制文件的 DATA 段内容,找到与 objc 相关的信息
- 注册 Objc 类
- 确保 selector 的唯一性
- 读取 protocol 以及 category 的信息
除了 map_2_images,我们注意到 _objc_init 还注册了 load_images 函数,它的作用就是调用 Objc 的 + load 方法,它监听 dyld_image_state_dependents_initialized 通知。
虽然我说的很简单,但是在读源码的时候,我发现这部分内容其实是十分复杂而又十分有趣的,今天在这不多讨论。
Initializers
Objc SetUp 结束后,Dyld 便开始运行程序的初始化函数,该任务由 initializeMainExecutable 函数执行。整个初始化过程是一个递归的过程,顺序是先将依赖的动态库初始化,然后在对自己初始化。初始化需要做的事情包括:
- 调用 Objc 类的 + load 函数
- C/C++静态初始化对象和标记为
__attribute__(constructor)
的方法 - 非基本类型的 C++ 静态全局变量的创建
这里要提一点的就是,+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之类的符号
- 把上述结果写入缓存
这样,在应用启动的时候,就可以直接从缓存中读取数据,加快加载速度。
main函数之后
下面是大家都很熟悉的main.m文件,简单的几行代码,却包含了main函数开始,app启动执行的好几项操作呢~
1 | #import <UIKit/UIKit.h> |
很显然,main()函数只做了一件事,那就是调用UIApplicationMain()函数,接下来还是先了解下UIApplicationMain()函数吧~
下面我们先来了解一下UIApplicationMain函数的参数
1 | * @param argc 系统参数 |
argc、argv:直接传递给UIApplicationMain进行相关处理即可 principalClassName:指定应用程序类名(app的象征),该类必须是UIApplication(或子类)。如果为nil,则用UIApplication类作为默认值 delegateClassName:指定应用程序的代理类,该类必须遵守UIApplicationDelegate协议
总结UIApplicationMain函数作用: argc:系统或者用户传入的参数 argv:系统或用户传入的实际参数 1.根据传入的第三个参数,创建UIApplication对象 2.根据传入的第四个产生创建UIApplication对象的代理 3.设置刚刚创建出来的代理对象为UIApplication的代理 4.开启一个事件循环(可以理解为里面是一个死循环)这个时间循环是一个队列(先进先出)先添加进去的先处理 5.加载Info.plist文件,看是否指定了main.storyboard,如果设置了就去加载main.storyboard
UIApplication
UIApplication对象是应用程序的象征,一个UIApplication对象就代表一个应用程序。每一个应用都有自己的UIApplication对象,而且是单例的,如果试图在程序中新建一个UIApplication对象,那么将报错提示。一个iOS程序启动后创建的第一个对象就是UIApplication对象,且只有一个。利用UIApplication对象,能进行一些应用级别的操作。可以参考iOS开发之UIApplication和delegate
UIApplication Delegate
1 | 所有的移动操作系统都有个问题:APP很容易受到打扰。比如来电或者锁屏会导致APP进入后台甚至被终止。还有很多其它类似的情况会导致app受到干扰,在app受到干扰时,会产生一些系统事件,这时UIApplication会通知它的delegate对象,让delegate代理来处理这些系统事件。 |
1 | - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions{ return YES; } |
UIWindow的创建
1 | UIWindow 是特殊的 UIView ,通常一个App中只有UIWindows,当程序启动完毕后,创建的第一个视图控件就是UIWindow,接着创建控制器的UIView,将控制器的View添加到UIWindow上,控制器的 UIView 就显示在屏幕上。注意 UIWindow 本身不做显示,是控制器的UIView做展示,UIWindow 会给视图分发事件。 |
UIWindow作用: 1.UIWindow作为一个容器,容纳所有的UIView 2.UIWindow会其他事件消息传递给UIView
控制器的创建
当UIWindow创建完成后,必须指定一个根控制器或者在UIWIndow上添加子视图,这样才能显示出来,用户才能看得到,因为前面提到过,UIWindow本身不做显示。当指定了UIWindow的根控制器,该控制器的view会自动添加在UIWindow上,并显示出来。控制器的创建可以看这篇文章
view的创建
视图控制器就是控制器视图在屏幕上的显示,对于一个控制器来说也是不具备显示的,只有它的view才具有显示能力,所以创建完一个控制器的时候,要给它指定一个根视图。具体的控制器view的创建可以查看这篇文章。
应用程序的状态
程序的五种状态:
State | 状态 | 描述 |
---|---|---|
Not running | 未运行 | 程序没启动 |
Inactive | 未激活 | 程序在前台运行,但没有接收到事件。在没有事件处理情况下程序通常停留在这个状态 |
Active | 激活 | 程序在前台运行,并且接收到了事件。这也是前台的一个正常的模式 |
Backgroud | 后台 | 程序在后台,并且能执行代码。大多数程序进入这个状态后会在这个状态上停留一会,但到一定时间之后会进入挂起状态(Suspended)。有的程序经过特殊的请求后可以长期处于Backgroud状态 |
Suspended | 挂起 | 程序在后台,不能执行代码。系统会自动把程序变成这个状态而且不会发出通知。当挂起时,程序还是停留在内存中的,但当系统内存低时,系统就把挂起的程序清除掉,为前台程序提供更多的内存启动时间检测 |
启动时间检测
冷启动 VS 热启动
如果你刚刚启动过App,这时候App的启动所需要的数据仍然在缓存中,再次启动的时候称为热启动。如果设备刚刚重启,然后启动App,这时候称为冷启动。
启动时间在小于400ms是最佳的,因为从点击图标到显示Launch Screen,到Launch Screen消失这段时间是400ms。启动时间不可以大于20s,否则会被系统杀掉。
启动耗时的测量
在进行优化之前,我们首先应该能测量各阶段的耗时。
pre-main阶段
对于pre-main阶段,Apple提供了一种测量方法,在 Xcode 中 Edit scheme -> Run -> Auguments 将环境变量DYLD_PRINT_STATISTICS 设为1 :
设置好后把程序跑起来,控制台会有如下输出,pre-main阶段各过程的耗时一览无余(Apple这个Demo有点过于夸张…)
pre-main阶段启动耗时测量.png
main()阶段
对于main()阶段,主要是测量main()函数开始执行到didFinishLaunchingWithOptions
执行结束的耗时,就需要自己插入代码到工程中了。先在main()
函数里用变量StartTime
记录当前时间:
1 | CFAbsoluteTime StartTime; |
再在AppDelegate.m文件中用extern声明全局变量StartTime
1 | extern CFAbsoluteTime StartTime; |
最后在didFinishLaunchingWithOptions
里,再获取一下当前时间,与StartTime
的差值即是main()阶段运行耗时。
1 | double launchTime = (CFAbsoluteTimeGetCurrent() - StartTime); |
用Time Profiler找到元凶
Time Profiler在分析时间占用上非常强大。实用的时候注意三点
- 在打包模式下分析(一般是Release),这样和线上环境一样。
- 记得开启dsym,不然无法查看到具体的函数调用堆栈
- 分析性能差的设备,对于支持iOS 8的,一般分析iphone 4s或者iphone 5。
几点要注意:
- 分析启动时间,一般只关心主线程
- 选择Hide System Libraries和Invert Call Tree,这样我们能专注于自己的代码
- 右侧可以看到详细的调用堆栈信息
优化启动时间
启动时间这个名词,不同的人有不同的定义。在我看来,
启动时间是用户点击App图标,到第一个界面展示的时间。
以main函数作为分水岭,启动时间其实包括了两部分:main函数之前和main函数到第一个界面的viewDidAppear:
。所以,优化也是从两个方面进行的,个人建议优先优化后者,因为绝大多数App的瓶颈在自己的代码里。
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…合并为一个
- 删除无用的方法和类。
- 减少不必要的framework,因为动态链接比较耗时
- check framework应当设为optional和required,如果该framework在当前App支持的所有iOS系统版本都存在,那么就设为required,否则就设为optional,因为optional会有些额外的检查
- 合并或者删减一些OC类,关于清理项目中没用到的类,使用工具AppCode代码检查功能,查到当前项目中没有用到的类如下:
- 多用Swift Structs,因为Swfit Structs是静态分发的。感兴趣的同学可以看看我之前这篇文章:《Swift进阶之内存模型和方法调度》
Initializers
通常,我们会在+load
方法中进行method-swizzling,这也是Nshipster推荐的方式。
- 用initialize替代load。不少同学喜欢用method-swizzling来实现AOP去做日志统计等内容,强烈建议改为在initialize进行初始化。
- 减少
__atribute__((constructor))
的使用,而是在第一次访问的时候才用dispatch_once等方式初始化。 - 不要创建线程
- 使用Swfit重写代码。
Main函数之后
我们首先来分析下,从main函数开始执行,到你的第一个界面显示,这期间一般会做哪些事情。
- 执行AppDelegate的代理方法,主要是
didFinishLaunchingWithOptions
- 初始化Window,初始化基础的ViewController结构(一般是
UINavigationController
+UITabViewController
) - 获取数据(Local DB/Network),展示给用户。
UIViewController
延迟初始化那些不必要的
UIViewController
。
在启动的时候只需要初始化首页的页面即可。像“要闻”,“我的”等页面,则延迟加载,即启动的时候只是一个UIViewController作为占位符给TabController,等到用户点击了再去进行真正的数据和视图的初始化工作。
AppDelegate
通常我们会在AppDelegate的代理方法里进行初始化工作,主要包括了两个方法:
didFinishLaunchingWithOptions
applicationDidBecomeActive
优化这些初始化的核心思想就是:
能延迟初始化的尽量延迟初始化,不能延迟初始化的尽量放到后台初始化。
这些工作主要可以分为几类:
- 三方SDK初始化,比如Crash统计; 像分享之类的,可以等到第一次调用再出初始化。
- 初始化某些基础服务,比如WatchDog,远程参数。
- 启动相关日志,日志往往涉及到DB操作,一定要放到后台去做
- 业务方初始化,这个交由每个业务自己去控制初始化时间。
对于didFinishLaunchingWithOptions
的代码,建议按照以下的方式进行划分:
1 | @interface AppDelegate () |
然后,你会得到一个非常干净的AppDelegate文件:
1 | - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { |
由于对这些初始化进行了分组,在开发期就可以很容易的控制每一个业务的初始化时间:
1 | CFTimeInterval startTime = CACurrentMediaTime(); |
- 纯代码方式而不是storyboard加载首页UI。
- 对didFinishLaunching里的函数考虑能否挖掘可以延迟加载或者懒加载,需要与各个业务方pm和rd共同check 对于一些已经下线的业务,删减冗余代码。
对于一些与UI展示无关的业务,如微博认证过期检查、图片最大缓存空间设置等做延迟加载 - 对实现了+load()方法的类进行分析,尽量将load里的代码延后调用。
- 上面统计数据显示展示feed的导航控制器页面(NewsListViewController)比较耗时,对于viewDidLoad以及viewWillAppear方法中尽量去尝试少做,晚做,不做。
小结
不同的App在启动的时候做的事情往往不同,但是优化起来的核心思想无非就两个:
- 能延迟执行的就延迟执行。比如SDK的初始化,界面的创建。
- 不能延迟执行的,尽量放到后台执行。比如数据读取,原始JSON数据转对象,日志发送。