一般情况下, App 的启动分为冷启动和热启动.
- 冷启动是指, App 点击启动前, 它的进程不在系统里, 需要系统新创建一个进程分配给它启动的情况. 这是一次完整的启动过程.
- 热启动是指, App 在冷启动后用户将 App 退后台, 在 App 的进程还在系统里的情况下, 用户重新启动进入 App 的过程, 这个过程做的事情非常少.
一般而言, App 的启动时间, 指的是从用户点击 App 开始到用户看到第一个界面之间的时间, 总结来说, App的启动主要包括三个阶段:
- main()函数执行前
- main()函数
- 首屏渲染
1 main()函数执行前
主要工作是操作系统加载App可执行文件到内存, 然后执行一系列的加载&链接等工作,最后执行至App的main()函数.
- 真正的加载过程从exec()函数开始, exec()是一个系统调用. 用fork()函数新建立一个进程, 再为进程分配一段内存空间, 然后让进程去执行exec调用.
- 把App对应的可执行文件(Mach-O格式)加载到内存空间.
- 读取dyld路径, 加载动态链接库dyld到内存, 运行dyld动态连接器.
- dyld 加载程序所需的动态库(load dylibs image)
- dyld 对所有 image 进行 rebase 以及 bind 操作
- ObjC SetUp
- Run initializers
整个事件由dyld主导, 完成运行环境的初始化后, 配合ImageLoader 将二进制文件按格式加载到内存, 动态链接依赖库, 并由runtime负责加载成objc 定义的结构, 所有初始化工作结束后, dyld调用真正的main函数.
1.1 Mach-O
Mach-O 是针对不同运行时可执行文件的文件类型. Mach-O的文件格式是 OS X 与 iOS 系统上的可执行文件格式, 类似于windows的 PE格式, linux上的elf格式. 我们编译产生的.o文件、程序可执行文件和各种库等都是Mach-O文件. mach-o文件类型主要为:
- Executable: 应用的主要二进制
- Dylib Library: 动态链接库(又称DSO或DLL), 例如.so文件. .so文件是linux的可执行文件, 可以用于多个进程的共享使用.
- Static Library: 静态链接库, 例如.a文件. .a文件实质上就是.o文件打了个包, 一般把它叫做静态库文件
- Bundle: 不能被链接的Dylib, 只能在运行时使用dlopen( )加载, 可当做macOS的插件
- Relocatable Object File: 可重定向文件类型, 例如.o文件. .o文件是源码编译出的二进制文件
两个特殊的名词解释:
- image: 镜像, 指的是executable, dylib 或 bundle
- Framework: 包含Dylid以及资源文件和头文件的文件夹
Mach-O文件主要有3部分组成:
- Header: 保存了一些基本信息,包括了该文件运行的平台、文件类型、LoadCommands的个数等等。Headers的主要作用就是帮助系统迅速的定位Mach-O文件的运行环境,文件类型。保存了一些dyld重要的加载参数
- LoadCommands: 可以理解为加载命令,在加载Mach-O文件时会使用这里的数据来确定内存的分布以及相关的加载命令。比如我们的main函数的加载地址,程序所需的dyld的文件路径,以及相关依赖库的文件路径。
- Data: 每一个segment的具体数据都保存在这里, 这里包含了具体的代码、数据等等.
1.2 dyld
dyld(the dynamic link editor) Apple的动态链接器, 系统内核做好启动程序的初始准备后, 交给 dyld 负责, dyld 在应用进程中运行的工作是加载应用依赖的所有动态链接库, 准备好运行所需的一切. 对 dyld 作用顺序的概括如下:
- 从内核留下的原始调用栈引导和启动自己
- 将程序依赖的动态链接库递归加载进内存,当然这里有缓存机制
- non-lazy 符号立即 link 到可执行文件,lazy 的存表里
- Runs static initializers for the executable
- 找到可执行文件的 main 函数,准备参数并调用
- 程序执行中负责绑定 lazy 符号、提供 runtime dynamic loading services、提供调试器接口
- 程序main函数 return 后执行 static terminator
- 某些场景下 main 函数结束后调 libSystem 的 _exit 函数
1.2.1 load dylibs
这一阶段dyld会分析应用依赖的dylib. 从主执行文件的 header 获取到需要加载的所依赖动态库列表, 而 header 早就被内核映射过, 然后它需要找到每个dylib, 然后打开文件读取文件起始位置. 找到其mach-o文件, 打开和读取这些文件并验证其有效性, 接着会找到代码签名注册到内核. 最后对dylib的每一个segment调用mmap(), 应用所依赖的 dylib 文件可能会再依赖其他 dylib, 所以 dyld 所需要加载的是动态库列表一个递归依赖的集合.
1.2.2 rebase && bind
传统方式下,进程每次启动采用的都是固定可预见的方式,这意味着一个给定的程序在给定的架构上的进程初始虚拟内存都是基本一致的,而且在进程正常运行的生命周期中,内存中的地址分布具有非常强的可预测性,这给了黑客很大的施展空间, 所以采用了ASLR(Address Space Layout Randomization)技术.
采用ASLR, 地址空间布局随机化, 镜像会在随机的地址上加载. 进程每次启动, 地址空间都会被简单地随机化. 在加载所有的动态链接库之后, 它们只是处在相互独立的状态, 需要将它们绑定起来,这就是 Fix-ups. Fix-up 有两种类型, rebasing 和 binding.
Rebasing: 在镜像内部调整指针的指向, 针对mach-o在加载到内存中不是固定的首地址(ASLR)这一现象做数据修正的过程.
Binding: 将指针指向镜像外部的内容, binding就是将这个二进制调用的外部符号进行绑定的过程. 这些指向外部的指针被符号(symbol)名称绑定, dyld需要去符号表里查找, 找到symbol对应的实现
Objective-C 中有很多数据结构都是靠 Rebasing 和 Binding 来修正(fix-up)的, 比如 Class 中指向父类的指针和指向方法的指针.
1.2.3 ObjC SetUp
objc prepare images, 通知 runtime 准备镜像, 这里做的事情比较多,主要是 runtime 的初始化
- 此时大部分的ObjC初始化已经完成, 读取二进制文件的 DATA 段内容, 找到与 objc 相关的信息
- 注册所有的objc_class, ObjC 是个动态语言, 可以用类的名字来实例化一个类的对象. 这意味着 ObjC Runtime 需要维护一张映射类名与类的全局表。当加载一个 dylib 时, 其定义的所有的类都需要被注册到这个全局表中
- 更新ivars的偏移量
- 把分类的方法插入到方法列表
- 检查selector的唯一性
1.2.4 initializers
到了这一阶段,dyld开始运行程序的初始化函数:
- 调用每个Objc类和分类的+load方法,
- 调用C/C++ 中的构造器函数(用attribute((constructor))修饰的函数),
- 创建非基本类型的C++静态全局变量(通常是类或结构体).
Objc的load函数和C++的静态构造函数采用由底向上的方式执行,来保证每个执行的方法,都可以找到所依赖的动态库
- dyld开始将程序二进制文件初始化
- 交由ImageLoader读取image,其中包含了我们的类、方法等各种符号
- 由于runtime向dyld绑定了回调,当image加载到内存后,dyld会通知runtime进行处理
- runtime接手后调用mapimages做解析和处理,接下来loadimages中调用 callloadmethods方法,遍历所有加载进来的Class,按继承层级依次调用Class的+load方法和其 Category的+load方法.
Initializers阶段执行完后,dyld开始调用main()函数.
2 main()函数
main()函数之后指的是从main()开始,到appDelegate的didFinishLaunchingWithOptions
方法执行完毕.
3 首屏渲染
此处就是APP启动后的业务逻辑, 有可能有定位, 网络请求, I/O操作, 性能监控, 基础配置, 自定义配置, 统计上报等功能.
4 启动优化
启动优化要结合上面启动时的特点来按步骤, 分阶段优化.
4.1 load dylibs阶段
该阶段主要做了分析依赖的动态库, 找到动态库的mach-o, 打开并验证, 加载. 所以针对的优化措施是:
- 减少非系统库的依赖
- 使用静态库而不是动态库
- 合并非系统动态库为一个动态库
4.2 Rebase && Binding
这里主要是对内存地址的Fix-ups, 所以可以采用下面的优化措施:
- 减少Objc类数量, 减少selector数量, 把未使用的类和函数都可以删掉.
- 减少C++虚函数数量
- 转而使用swift stuct(其实本质上就是为了减少符号的数量,使用swift语言来开发?)
4.3 ObjC SetUp
这里主要是对Rebase && Binding后的结果进行处理, 所以没什么可以优化的
4.4 initializers
这里主要做了load, C++静态全局变量的创建等. 所以优化措施主要有:
- 使用 +initialize 来替代 +load
- 不要使用 atribute((constructor)) 将方法显式标记为初始化器,而是让初始化方法调用时才执行. 比如使用 dispatch_once(),pthread_once() 或 std::once()。也就是在第一次使用时才初始化,推迟了一部分工作耗时. 也尽量不要用到C++的静态对象
4.5 main()
总体原则无非就是减少启动的时候的步骤, 以及每一步骤的时间消耗. main阶段的优化大致有如下几个点:
- 减少启动初始化的流程,能懒加载的就懒加载,能放后台初始化的就放后台,
能够延时初始化的就延时,不要卡主线程的启动时间,已经下线的业务直接删掉; - 优化代码逻辑,去除一些非必要的逻辑和代码,减少每个流程所消耗的时间;
- 启动阶段使用多线程来进行初始化,把CPU的性能尽量发挥出来;
- 使用纯代码而不是xib或者storyboard来进行UI框架的搭建,尤其是主UI框架比如TabBarController这种, 尽量避免使用xib和storyboard,因为xib和storyboard也还是要解析成代码来渲染页面,多了一些步骤.
4.6 首屏渲染
这里主要针对具体耗时的业务做优化.
- 推迟&减少I/O操作, 减少动画图片组的数量,替换大图资源等。因为相比于内存操作,硬盘I/O是非常耗时的操作
- 发现隐晦的耗时操作
- 推迟执行的一些任务, 如一些资源的I/O,一些布局逻辑,对象的创建时机等
5. 优化实践
5.1 pre-main阶段的优化实践
- 减少依赖不必要的库,不管是动态库还是静态库;如果可以的话,把动态库改造成静态库;
如果必须依赖动态库,则把多个非系统的动态库合并成一个动态库; - 检查下 framework应当设为optional和required, 如果该framework在当前App支持的所有iOS系统版本都存在,那么就设为required,否则就设为optional, 因为optional会有些额外的检查;
- 合并或者删减一些OC类和函数;关于清理项目中没用到的类,使用工具AppCode代码检查功能,查到当前项目中没有用到的类(也可以用根据linkmap文件来分析,但是准确度不算很高); 有一个叫做FUI的开源项目能很好的分析出不再使用的类,准确率非常高,唯一的问题是它处理不了动态库和静态库里提供的类,也处理不了C++的类模板。
- 删减一些无用的静态变量,
- 删减没有被调用到或者已经废弃的方法,
方法见这里和这里。 - 将不必须在+load方法中做的事情延迟到+initialize中,尽量不要用C++虚函数(创建虚函数表有开销)
- 类和方法名不要太长:iOS每个类和方法名都在__cstring段里都存了相应的字符串值,所以类和方法名的长短也是对可执行文件大小是有影响的. 因还是object-c的动态特性,因为需要通过类/方法名反射找到这个类/方法进行调用,object-c对象模型会把类/方法名字符串都保存下来;
- 用dispatch_once()代替所有的 attribute((constructor)) 函数、C++静态对象初始化、ObjC的+load函数;
- 在设计师可接受的范围内压缩图片的大小,会有意外收获. 压缩图片为什么能加快启动速度呢?因为启动的时候大大小小的图片加载个十来二十个是很正常的, 图片小了,IO操作量就小了,启动当然就会快了,比较靠谱的压缩算法是TinyPNG。
5.2 业务优化
- 把启动时RN包的删除和拷贝操作,仅在APP安装后第一次启动时才做,之后的启动不再做这操作,
而是等到网络请求RN数据回来,根据是否需要更新RN包的判断,再去做这些IO操作从而避免启动的耗时。 - 打点统计模块里的定位服务权限请求改成异步
- 友盟的分享服务,没有必要在启动的时候去初始化,初始化任务丢到后台线程解决,大概600-800ms
- UserAgentManager里对于webview是否为UIWebview的判断,以前是新创建一个对象使用对象方法来判断,
- 修改为直接使用类方法,避免创建对象的消耗, 否则子线程消耗的时间太长了;
- 采用两个线程来进行启动流程的初始化. 但是要针对业务区分开,并不是把一部分业务拆分到子线程,就可以让整体的启动速度更快;因为如果子线程有一些操作是要在主线程做的,有可能会出现等待主线程空闲再继续的情况;
- 或者当两个线程的耗时操作都是IO时,拆开到两个线程,并不一定比单个线程去做IO操作要快。
- 主UI框架tabBarController的viewDidLoad函数里,去掉一些不必要的函数调用。
- NSUserDefaults的synchronize函数尽量不要在启动流程中去调用,统一在APP进入后台,
willTerminate和完全进入前台后把数据落地;
参考资料:
1.iOS 程序 main 函数之前发生了什么
2.iOS开发 APP启动main()调用之前的加载过程
3.iOS App 启动流程
4.优化 App 的启动时间
5.App 启动时间:过去,现在和未来
6.深入理解iOS App的启动过程
7.美团外卖iOS App冷启动治理
8.iOS-APP的启动流程和生命周期
9.iOS 程序启动流程解密
10.深入理解iOS App的启动过程
11.从Mach-O到iOS Library
12.iOS-APP的启动流程和生命周期
13.dyld 加载 Mach-O
14.iOS启动时间优化
15.2016WWDC-Optimizing App Startup Time
16.深入iOS系统底层之静态库