App启动流程及优化
App启动是最直接影响用户体验的,你是否足够了解它呢?
一. 启动流程:
当我们创建一个项目时,一定能在main.m会看到这句代码:
int main(int argc, char * argv[]) {
@autoreleasepool {
return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
}
}
看似简单的一句代码却能让程序运行起来并且一直跑下去,是不是很神奇?虽然看不到外部调用代码,但是内部却已经做了许多事情了,调用可以分2部分:
即:**App的启动时间t(App总启动时间) = t1(main()之前的加载时间) + t2(main()之后的加载时间)。 **
t1 = 系统dylib(动态链接库)和自身App可执行文件的加载;
t2 = main方法执行之后到AppDelegate类中的- (BOOL)Application:(UIApplication *)Application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions方法执行结束前这段时间,主要是构建第一个界面,并完成渲染展示。
1. 程序启动前
即从点击icon系统调用
exec()
到执行main()
函数之前的加载过程。它会执行系统dylib(动态链接库)和自身App可执行文件的加载。
1.1. 参数说明
当点击App开始启动后,系统首先会去加载可执行文件(自身App的所有.o文件的集合),然后加载动态链接库dyld,dyld是一个专门用来加载动态链接库的库。系统会将其映射到进程中的随机地址,然后将寄存器设为 dyld
的地址并运行。
dyld会从可执行文件的依赖开始, 递归加载所有的依赖动态链接库。
动态链接库包括:iOS 中用到的所有系统 framework,加载OC runtime方法的libobjc,系统级别的libSystem,例如libdispatch(GCD)和libsystem_blocks (Block)。
使用动态链接库的优点:
动态库:链接时不复制,程序运行时由系统动态加载到内存,供程序调用,系统只加载一次,多个程序共用,节省内存。iOS中动态库有:.dylib和.framework
-
代码共用:很多程序都动态链接了这些 lib,但它们在内存和磁盘中中只有一份。
-
易于维护:由于被依赖的 lib 是程序执行时才链接的,所以这些 lib 很容易做更新,比如libSystem.dylib 是 libSystem.B.dylib 的替身,哪天想升级直接换成libSystem.C.dylib 然后再替换替身就行了。
-
减少可执行文件体积:相比静态链接,动态链接在编译时不需要打进去,所以可执行文件的体积要小很多。
另:静态库的优点:
静态库:链接时完整地拷贝至可执行文件中,被多次使用就有多份冗余拷贝。iOS中静态库有:.a和.framework
- 方便共享代码,便于合理使用。
- 实现iOS程序的模块化。可以把固定的业务模块化成静态库。
- 和别人分享你的代码库,但不想让别人看到你代码的实现。
- 开发第三方sdk的需要。
.a是一个纯二进制文件,.framework中除了有二进制文件之外还有资源文件。
.a文件不能直接使用,至少要有.h文件配合,.framework文件可以直接使用。
.a + .h + sourceFile = .framework。
比如:CocoaPods 项目最终会编译成一个名为 libPods.a 的文件,主项目只需要依赖这个 .a 文件即可。这样,依赖库源码管理工作都从主项目移到了 Pods 项目中。对于资源文件,CocoaPods 提供了一个名为 Pods-resources.sh 的 bash 脚本,该脚本在每次项目编译的时候都会执行,将第三方库的各种资源文件复制到目标目录中。
其实无论对于系统的动态链接库还是对于App本身的可执行文件而言,他们都算是image(镜像),而每个App都是以image(镜像)为单位进行加载的,那么image究竟包括哪些呢?
image:
- executable:应用的主要二进制,比如.o文件。
- dylib: 动态链接库(又称 DSO 或 DLL), framework就是动态链接库和相应资源以及头文件包含在一起的一个文件夹结构。
- bundle: 资源文件,不能被链接的 Dylib,只能在运行时使用
dlopen()
加载,可当做 macOS 的插件。
所有动态链接库和我们App中的静态库.a和所有类文件编译后的.o文件最终都是由dyld(the dynamic link editor)加载到内存中。每个image都是由一个叫做ImageLoader的类来负责加载(一一对应),那么ImageLoader又是什么呢?
ImageLoader:
ImageLoader
是一个用于加载可执行文件的基类,它负责链接镜像,但不关心具体文件格式,因为这些都交给子类去实现。既然每一个 image 是一个二进制文件(可执行文件或 so 文件),里面是被编译过的符号、代码等,那么 ImageLoader 作用是将这些文件加载进内存,且每一个文件对应一个ImageLoader实例来负责加载。
流程:
- 在程序运行时它先将动态链接的 image 递归加载。 因为 dylib 之间有依赖关系,所以
ImageLoader
很多操作可以沿着依赖链递归操作。 - 再从可执行文件 image 递归加载所有符号。在 Rebasing 和 Binding 前会判断是否已经 Prebinding。如果已经进行过预绑定(Prebinding),那就不需要 Rebasing 和 Binding 这些 Fix-up 流程了。
1.2. 动态链接库加载的具体流程
分为5步:
- load dylibs image 读取库镜像文件
- Rebase image 在镜像内部调整指针的指向
- Bind image 将指针指向镜像外部的内容
- Objc setup
- initializers
1.3. 详细解析
1. load dylibs image
在每个动态库的加载过程中, dyld需要:
- 分析所依赖的动态库
- 找到动态库的mach-o文件(OS X和iOS中可执行文件是Mach-o格式的)
- 打开文件
- 验证文件
- 在系统核心注册文件签名
- 对动态库的每一个segment调用mmap()
通常的,一个App需要加载100到400个dylibs, 但是其中的系统库被优化,可以很快的加载。 针对这一步骤的优化有:
- 减少非系统库的依赖
- 合并非系统库
- 使用静态资源,比如把代码加入主程序,比如加载内嵌(embedded)的 dylib 文件很占时间,所以尽可能把多个内嵌 dylib 合并成一个来加载。
2-3. Fix-ups : rebase/bind
由于ASLR(address space layout randomization)的存在,可执行文件和动态链接库在虚拟内存中的加载地址每次启动都不固定,所以需要这2步来修复镜像中的资源指针,来指向正确的地址。rebase修复的是指向当前镜像内部的资源指针; 而bind指向的是镜像外部的资源指针。 这两步也叫做 Fix-ups。
rebase步骤先进行,需要把镜像读入内存,并以page为单位进行加密验证,保证不会被篡改,所以这一步的瓶颈在IO。
Binding在其后进行,Binding 是处理那些指向 dylib 外部的指针,它们实际上被符号(symbol)名称绑定,也就是个字符串。 __LINKEDIT
段中也存储了需要 bind 的指针,以及指针需要指向的符号。dyld
需要找到 symbol 对应的实现,这需要很多计算,去符号表里查找。找到后会将内容存储到 __DATA
段中的指针中。
由于要查询符号表,来指向跨镜像的资源,加上在rebase阶段,镜像已被读入和加密验证,所以这一步的瓶颈在于CPU计算。
通过命令行可以查看相关的资源指针:
xcrun dyldinfo -rebase -bind -lazy_bind myApp.App/myApp
优化该阶段的关键在于减少__DATA segment中的指针数量。我们可以优化的点有:
减少Objc类数量, 减少selector数量
减少C++虚函数数量
转而使用swift stuct(其实本质上就是为了减少符号的数量)
4. Objc setup
这一步主要工作是:
ObjC 是个动态语言,可以用类的名字来实例化一个类的对象。这意味着 ObjC Runtime 需要维护一张映射类名与类的全局表。当加载一个 dylib 时,其定义的所有的类都需要被注册到这个全局表中。
- 注册Objc类 (class registration)
- 把category的定义插入方法列表 (category registration)
- 保证每一个selector唯一 (selctor uniquing)
由于之前2步骤的优化,这一步实际上没有什么可做的。
5. initializers
以上三步属于静态调整(fix-up),都是在修改__DATA segment中的内容,而这里则开始动态调整,开始在堆和堆栈中写入内容。 那么执行初始化器的顺序是什么?自顶向上!按照依赖关系,先加载叶子节点,然后逐步向上加载中间节点,直至最后加载根节点。这种加载顺序确保了安全性,加载某个 dylib 前,其所依赖的其余 dylib 文件肯定已经被预先加载。在这里的工作有:
- Objc的+load()函数
- C++的构造函数属性函数 形如attribute((constructor)) void DoSomeInitializationWork()
- 非基本类型的C++静态全局变量的创建(通常是类或结构体)(non-trivial initializer) 比如一个全局静态结构体的构建,如果在构造函数中有繁重的工作,那么会拖慢启动速度
1.4 main()之前的加载时间如何衡量
Warm launch: App 和数据已经在内存中
Cold launch: App 不在内核缓冲存储器中
注:如果程序刚刚被运行过,那么程序的代码会被dyld缓存,因此即使杀掉进程再次重启加载时间也会相对快一点,如果长时间没有启动或者当前dyld的缓存已经被其他应用占据,那么这次启动所花费的时间就要长一点,这就分别是热启动和冷启动的概念。
怎么衡量main()之前也就是time1的耗时呢,苹果官方提供了一种方法,那就是在真机调试的时候在 Xcode 中 Edit scheme -> Run -> Auguments 将环境变量 DYLD_PRINT_STATISTICS
设为 1
。

控制台输出的内容如下:

2. 执行main函数
即main函数执行之后到AppDelegate的-
(BOOL)Application:(UIApplication *)Application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
方法执行结束前这段时间,主要是构建第一个界面,并完成渲染展示。
2.1 调用流程:
- 根据principalClassName提供的类名,创建一个UIApplication对象。
- 根据delegateClassName提供的类名,创建一个UIApplication的代理对象。(所有UIApplication的代理方法,比如:
application:didFinishLaunchingWithOptions
) - 开启一个
Main Runloop
循环,它是保持程序一直在运行,并将autoreleasepool进行压栈,然后调用 UIApplicationDelegate中的函数进行事件处理。 - 加载Info.plist和启动图片,并且判断Info.plist有没有指定Main.storyboard,如果指定,就去加载; 如果没有配置,则根据代码来创建UIWindow--->UIWindow的rootViewController-->显示
2.2 UIApplicationMain说明
// If nil is specified for principalClassName, the value for NSPrincipalClass from the Info.plist is used. If there is no
// NSPrincipalClass key specified, the UIApplication class is used. The delegate class will be instantiated using init.
UIKIT_EXTERN int UIApplicationMain(int argc, char *argv[], NSString * __nullable principalClassName, NSString * __nullable delegateClassName);
-
argc 表示参数个数
-
argv 表示函数参数的数组
-
是UIApplication类名或者是其子类名,如果是nil,则就默认使用UIApplication类名
-
是协议UIApplicationDelegate的实例化对象名,如果是nil,则从main nib文件中加载委托对象。这个对象就是UIApplication对象,会在监听到系统变化的时候通知执行的相应方法
2.3 UIApplication
作用:
-
设置应用程序图标右上角的红色提醒数字
注:苹果为了增强用户体验,在iOS8以后我们需要创建通知才能实现图标右上角提醒,iOS8之前直接设置
applicationIconBadgeNumber
的值即可。 -
设置联网指示器的可见性
-
管理状态栏
-
openURL:方法
-
判断程序运行状态
//判断程序运行状态 /* UIApplicationStateActive, UIApplicationStateInactive, UIApplicationStateBackground */ UIApplication *app = [UIApplication sharedApplication]; if(app.applicationState ==UIApplicationStateInactive){ NSLog(@"程序在运行状态"); }
-
阻止屏幕变暗进入休眠状态
//阻止屏幕变暗,慎重使用本功能,因为非常耗电。 UIApplication *app = [UIApplication sharedApplication]; app.idleTimerDisabled =YES;
说明:
- 每一个应用程序都有自己的UIApplication对象,而且是单例。
- 一个iOS程序启动后创建的第一个对象就是UIApplication对象。
- 通过
UIApplication *app = [UIApplication sharedApplication];
可以获得这个单例对象。
二. 启动优化:
对于main()调用之前的耗时可以优化的点有:
- 减少不必要的framework,因为动态链接比较耗时
- check framework应当设为optional和required,如果该framework在当前App支持的所有iOS系统版本都存在,那么就设为required,否则就设为optional,因为optional会有些额外的检查
- 合并或者删减一些OC类;删减一些无用的静态变量;删减没有被调用到或者已经废弃的方法;以及将不必须在+load方法中做的事情延迟到+initialize中,关于清理项目中没用到的类,可以使用工具AppCode代码检查功能,查到当前项目中没有用到的类如下:

main()调用之后的加载时间可以优化的点:
在main()被调用之后,App的主要工作就是初始化必要的服务,显示首页内容等。 App通常在AppDelegate类中的- (BOOL)Application:(UIApplication *)Application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions方法中创建首页需要展示的view,然后在当前runloop的末尾,主动调用CA::Transaction::commit完成视图的渲染。
而视图的渲染主要涉及三个阶段:
- 准备阶段 这里主要是图片的解码
- 布局阶段 首页所有UIView的- (void)layoutSubViews()运行
- 绘制阶段 首页所有UIView的- (void)drawRect:(CGRect)rect运行
再加上启动之后必要服务的启动、必要数据的创建和读取,这些就是我们可以尝试优化的地方
因此,对于main()函数调用之后的可以优化的点有:
- 不使用xib,直接视用代码加载首页视图
- NSUserDefaults实际上是在Library文件夹下会生产一个plist文件,如果文件太大的话一次能读取到内存中可能很耗时,这个影响需要评估
- 每次用NSLog方式打印会隐式的创建一个Calendar,控制log打印
- 梳理应用启动时发送的所有网络请求,是否可以统一在异步线程请求
- 压缩资源图片,启动的时候大大小小的图片加载个十来二十个是很正常的,图片小了,IO操作量就小了,启动当然就会快了
- 优化rootViewController加载,把不相关的业务做一些迁移,使用更合理的分层结构
使用Time Profiler分析启动时间
选择Product->Profile可以启动,当点击Time Profiler启动程序后,就能获取到整个应用程序运行消耗时间分布和百分比。为了保证数据分析在统一使用场景真实行有如下点需要注意:使用真机,并且选择发布配置。 因为模拟器的CPU和GPU都是Mac在软件层面模拟出来的,真实性能数据并不准确;而发布环境较开发环境会做性能优化,例如去掉调试符号或者移除并重新组织代码,添加Watch Dog,“看门狗”会监测应用的性能,并在超出规定时间终止应用进程。
点击Record 开始运行:

这里显示的是执行代码的完整路径,所以系统和应用本身一些调用路径完全揉捏在一起了。完全看不到我们关心的应用程序中实际代码执行耗时和代码路径实际所在位置。简单的方式可以快速勾选右边Call Tree中Separate Thread和Hide System Libraries两个选项。接下来就可以做具体代码分析了,另x为ms毫秒。

Separate By Thread:线程分离,只有这样才能在调用路径中能够清晰看到占用CPU最大的线程.
Invert Call Tree:从上到下跟踪堆栈信息.这个选项可以快捷的看到方法调用路径最深方法占用CPU耗时,比如FuncA{FunB{FunC}},勾选后堆栈以C->B->A把调用层级最深的C显示最外面.
Hide Missing Symbols:如果dSYM无法找到你的APP或者调用系统框架的话,那么表中将看到调用方法名只能看到16进制的数值,勾选这个选项则可以隐藏这些符号,便于简化分析数据.
Hide System Libraries:这个就更有用了,勾选后耗时调用路径只会显示app耗时的代码,性能分析普遍我们都比较关系自己代码的耗时而不是系统的.基本是必选项.注意有些代码耗时也会纳入系统层级,可以进行勾选前后前后对执行路径进行比对会非常有用.
参考文献: