整个文章由来是一次内部的分享, 是分享关于程序启动的一些事。从创建进程到内存分配,以及这些过程中的时间花费。整个分享分为三部分,分别为:
1.理论部分:预备知识以及从点击icon到程序启动完成都经过了哪些过程。
2.实践部分:具体看看程序启动有哪些流程和启动时间花在哪了。
3.具体建议:根据启动流程及时间瓶颈谈谈优化建议,建议更偏向每一个人能做的,如果是优化工程团队的具体实践请参考美团关于APP冷启动时间优化的实践。
下面开始分享。
首先是简单编译原理介绍:
我们的 .h、.m 、亦或是.c、.mm、.swift 都会从高级语言被预处理、编译、汇编、链接之后成为二进制可执行文件。
然后讲一些预备知识,如下:
- executable :就是我们 APP 的可执行二进制文件;
- dylib:动态库,一般指程序启动时动态链接的动态库;
- Bundle:动态库的一种,但是需要使用 dlopen() 打开,一般用于运行时动态打开;
- Framework:包含相关的资源和头文件的动态库,如下图中微信的 SDK;
我们来看看实际中我们的程序包是怎样的:
将 .ipa 改成 .zip 然后打开,由于这是直接编译的 release 包,可以看到里面有符号表,一些extension 的支持以及支持 Swift 的动态库,再往里就是 .app 文件以及打开后的二进制文件、一堆资源和签名文件等。
关于我们的二进制文件,可以用 MachOView.app 打开看到格式化后的一些信息,如下图:
可以看到里面有加载这个二进制文件到内存中的一些信息,例如CPU类型,文件的类型等。
然后简单介绍二进制文件被加载到内存中后是怎样的映射关系:
引用 linux 内存图,可以看到:
- _PAGEZERO 填充在初始区域 (4G用捕获空指针引用);
- _Text 映射到代码段,_DATA 映射到数据段等;
- 引导开辟了堆,共享库映射区,栈,dyld,内核代码区等;
以上仅作为参考和教学用,实际情况相差较大且更为复杂。比如上图没有画出因为 ASLR 随机地址映射引起的段偏移,并且实际情况下每个线程都是有自己的线程栈的,但是图中都是表示为一块区域。
下面我们进入正题,关于启动流程和启动时间我们需要关心的步骤:
两部分:
pre-main阶段:
1.1. 加载应用的可执行文件;
1.2. 加载动态链接库加载器dyld(dynamic loader);
1.3. dyld递归加载应用所有依赖的dylib(dynamic library 动态链接库);main()阶段:
2.1. dyld调用main();
2.2. 调用UIApplicationMain();
2.3. 调用applicationWillFinishLaunching;
2.4. 调用didFinishLaunchingWithOptions;
表示为图即是:
然后详细介绍每个步骤:
1.execve(const char *filename, const char *argv[], const char *envp[]) : 一个内核级系统调用函数,根据参数可知,传入一个文件名、一些命令行参数、一些环境变量然后开始打开这个文件(包含 fork 进程并找到文件等过程),以下是细化的两个步骤:
①. parse header:解析可执行文件的 header 读取文件的加载信息;
②. mmap 和 copyOnWrite:将可执行文件映射进内存以及写时复制技术(自行脑补懒加载);
2.load dylib: 加载动态库的过程,大多为系统的动态库,下面细化:
①. 依据Apple的动态链接器 dylib 进行递归加载动态库及其动态库依赖的动态库;
②. rebase (ASLR,page fault 和 COW) ,由于 ASLR 随机地址偏移的存在,所以需要修正我们指针的引用,这一步主要是读取并修正我们二进制文件中的指针引用,主要是IO操作 ;
③. bind (lazy bind) ,由于动态库共享内存的使用,我们需要链接动态库后才知道我们二进制文件中指向动态库内容的指针实际的地址值,所以需要链接时修正这些值,大部分是 (lazy bind) 即第一次访问这个指针才去寻找这个指针实际指向的地址值并绑定供后续使用,所以首次访问会慢一些,这一步主要是 CPU 寻找和计算指针值;
3.objc runtime(objc setup):这一步主要是OC运行时的一些初始化工程,以下细分:
①. register class (class name map to class):类注册,动态语言运行时的需要,会将类注册到全局注册表中,这个注册表是一个字典,key 是类名的字符串,类对象本身是值;
②. 读取 protocol、category :读取协议以及分类,并将分类方法加入对应类的方法列表并保证其唯一性;
4.Initializers:这一步主要是+ load 这个方法以及一些全局变量的初始化
①. + load
添加标志就能在启动时在控制台打印启动时间:
我们来看看APP的启动时间:
可以看到四个过程对应本文之前说的四个过程。
最后针对四个过程谈谈我们平时编程中有什么事可以做的更好的:
1.Dylib Loading:
①.减少或者合并动态库;
②.这里和我们相关无非是引入第三方时斟酌这个第三方导致引入动态库相关的问题,这里和平时开发相关性较小就跳过了;
2.Objc setup:
相关过程:
①. class registration;
②.Non-fragile ivars offsets updated;
③.category registration;
④.selector uniquing 。
我们能做的:
①. 不要滥用分类,继承等特性,合并小类;
②. 不宜过长的类和方法名
③. 重构、改版的时候及时删除不再使用但是还有引用的老类和方法等,不要害怕以后会用到就用注释掉的方式,害怕丢失版本管理也可以找到;
④. 属性可以标记为 readonly 就不要标记 readwrite 可以少生成方法;
3.rebase/binding:
这里主要就是修正指针的消耗,例如类、分类和成员变量和方法,这里能做的在之前讲 Objc setup 的时候已经说过这里就不再赘述。
参考清理无用方法:
http://stackoverflow.com/questions/35233564/how-to-find-unused-code-in-xcode-7
https://developer.Apple.com/library/ios/documentation/ToolsLanguages/Conceptual/Xcode_Overview/CheckingCodeCoverage.html
参考清理无用类的一些清理工具:
AppCode
FUI
4.Initializers:
这一步和我们相关主要是 + load 这个方法以及一些全局变量的初始化。
①.不要在初始化时做耗时工作;
②.尽量不要使用 + load 方法,我们工程中大量在 + load 中加逻辑,很多其实是没有必要的;
③.使用 + initialize 或者别的地方并配合 dispatch_once 是更好的选择;
④. 如果是为了执行一次,在 + load 中是不能保证的,因为这个方法是可以被调用的,比如 [People load] ;
⑤.即使写在 + load 里配合 dispatch_once 也是更安全的选择;
⑥.使用 swift , swift 实现静态变量初始化时是使用类似 dispatch_once 这样的技术在首次调用时初始化的;
最后(重点):
重点谈谈 Swift 对我们的帮助:
- 值类型(value type) 例如 struct、enum 是在栈上分配的,我们知道栈上是运行时分配,并且值类型是不需要像类一样全局注册的,也没有那么多指针。这里重点谈谈栈的好处:
①. 效率更高,因为不需要像堆一样维护全局引用计数,计算计数完成还要去寻找空内存,分配完毕之后还需要释放,释放后还要合并小内存等一系列需要加锁保证同步的繁琐工作;每个线程有自己的线程栈,所以是不需要同步的,也没有那么多维护加锁引用计数的开销,所以栈上分配是你更好的选择;
②. 面向协议变成是完全可以替代面向对象(类)编程的,并且你还有 Swift 强大的泛型系统支持;
③. OC 的 Category 开销是很大的,因为是链接阶段进行的并且会引入大量的指针,而 Swift 的 extension 就没有这方面问题,因为它最后会和它相关的 class 或 struct、enum 一起编译。
④. 更强大的 feature 让你写出更少,更优雅的代码;
⑤. 强大的泛型系统,和泛型特化等优化会让你的很多代码可以被内联优化,这样可以减少大量指针;
⑥. 你可以使用 final、private 等修饰符让IDE去保证而不是依靠文档,并且这些优化关键字配合 Swift whole module 优化能力可以让你的代码将方法动态派发转化为静态派发,不仅提升了效率还减少的指针。
⑦. Swift 中值类型以及 class 可以没有父类的特性让你可以避免 OC 庞大而复杂的继承体系,这样也会让APP跑的更快,内存使用更小。
⑧. 命名空间和更严格的作用域也让编程更加愉快。
希望这些建议能对编写代码有更高追求的你以帮助,以上。
引用:
WWDC 2016:Optimizing App Startup Time
WWDC 2015: Optimizing Swift Performance
WWDC 2016: Understanding Swift Performance