[iOS] App启动流程 & 优化探讨
1. Mach-O
首先我们每个app其实都是一个可执行文件而已,也就是我们熟悉的胖二进制文件Mach-O
,这个文件的布局大概是下面酱紫的:
Mach-O文件主要有3部分组成:
-
Header
:保存了一些基本信息,包括了该文件运行的平台、文件类型、LoadCommands的个数等等。Headers的主要作用就是帮助系统迅速的定位Mach-O文件的运行环境,文件类型。保存了一些dyld重要的加载参数 -
LoadCommands
:可以理解为加载命令,在加载Mach-O文件时会使用这里的数据来确定内存的分布以及相关的加载命令。比如我们的main函数的加载地址,程序所需的dyld的文件路径,以及相关依赖库的文件路径。 -
Data
: 每一个segment的具体数据都保存在这里,这里包含了具体的代码、数据等等。
感觉其实真的很像操作系统的加载,就是通过setup程序,先加载section0,然后再一步一步加载代码。
MachOView中的文件布局Segment主要分为三类:
-
__TEXT代码段
,只读,包含函数,和只读的字符串,上图中类似__TEXT,__text的都是代码段 -
__Data数据段
,读写,包括可读写的全局变量等,__DATA,__data都是数据段 -
__LINKEDIT
包含了方法和变量的元数据(位置,偏移量),以及代码签名等信息。
__TEXT segment
是包含可执行代码和常量数据的只读区域。按照惯例,编译器工具创建具有至少一个只读__TEXT segment
的每个可执行文件。由于该段是只读的,因此内核可以将__TEXT segment
直接从可执行文件映射到内存中一次。当segment被映射到内存时,它可以在所有进程之间共享其内容。 (这主要是框架和其他共享库的情况。)只读属性还意味着构成__TEXT segment的页面永远不必保存到后备存储。如果内核需要释放物理内存,它可以丢弃一个或多个__TEXT页面,并在需要时从磁盘重新读取它们。
__DATA segment
包含可执行文件的非常量变量。该 segement 是可读写的,因为它是可写的,所以对于与库链接的每个进程,逻辑上复制静态库或其他动态共享库的__DATA
段。当内存页面可读写时,内核会使其变为copy-on-write。此技术可以做到,动态库是在内存中共享的,可以被其他各个进程访问,但因为__DATA Segment
是可读可写的,就会通过某一进程对共享的_DATA Segment
有写操作的时候,再进行单独的_DATA
内存空间复制。
2. App启动大致流程
如果你刚刚启动过App,这时候App的启动所需要的数据仍然在缓存中,再次启动的时候称为热启动。如果设备刚刚重启,然后启动App,这时候称为冷启动。
对于一个可执行文件来说,它的加载过程分为两大部分:
- pre-main 指的是操作系统开始执行一个可执行文件,并完成进程创建、执行文件加载、动态链接、环境配置
- main 指的是从加载main函数入口以后,到app delegate完成加载回调的过程
启动时间在小于400ms是最佳的,因为从点击图标到显示Launch Screen,到Launch Screen消失这段时间是400ms。启动时间不可以大于20s,否则会被系统杀掉。
在Xcode中,可以通过设置环境变量来查看App的启动时间,DYLD_PRINT_STATISTICS
和DYLD_PRINT_STATISTICS_DETAILS
。
Total pre-main time: 43.00 milliseconds (100.0%)
dylib loading time: 19.01 milliseconds (44.2%)
rebase/binding time: 1.77 milliseconds (4.1%)
ObjC setup time: 3.98 milliseconds (9.2%)
initializer time: 18.17 milliseconds (42.2%)
slowest intializers :
libSystem.B.dylib : 2.56 milliseconds (5.9%)
libBacktraceRecording.dylib : 3.00 milliseconds (6.9%)
libMainThreadChecker.dylib : 8.26 milliseconds (19.2%)
ModelIO : 1.37 milliseconds (3.1%)
操作系统加载可执行文件,通过fork(创建一个进程)指令在新的空间内来执行可执行文件,加载依赖的可执行文件(mach-o)文件,定位其内部与外部指针引用,例如字符串与函数,执行声明为attribute((constructor))的C函数,加载扩展(Category)中的方法,C++静态对象加载,调用ObjC的+load函数。
pre-main main到页面显示3. pre-main
1.iOS系统首先会加载解析该APP的Info.plist文件,因为Info.plist文件中包含了支持APP加载运行所需要的众多Key,value配置信息,例如APP的运行条件(Required device capabilities),是否全屏,APP启动图信息等。
2.创建沙盒(iOS8后,每次启动APP都会生成一个新的沙盒路径,苹果会把你上一个路径中的数据转移到你新的路径中。你上一个路径也会被苹果毫无保留的删除,只保留最新的路径。)
3.根据Info.plist的配置检查相应权限状态
4.加载Mach-O文件读取dyld路径并运行dyld动态连接器(内核加载了主程序,dyld只会负责动态库的加载)
- 首先dyld会寻找合适的CPU运行环境
- 然后加载程序运行所需的依赖库和我们自己写的.h.m文件编译成的.o可执行文件,并对这些库进行链接。
- 加载所有方法(runtime就是在这个时候被初始化的)
- 加载C函数
- 加载category的扩展(此时runtime会对所有类结构进行初始化)
- 加载C++静态函数,加载OC+load
- 最后dyld返回main函数地址,main函数被调用
这里我们发现第四步开始出现了一个名词:dyld
,dyld
是加载动态链接库的库,该库在加载可执行文件的时候,递归加载所需要的所有动态库。动态库包括iOS操作系统的系统framework,oc的runtime系统libobjc,系统级别的库libSystem,例如libdispatch(GCD)、libsystem_block(Block)。
首先我们先看看dyld2的工作流程~
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。
192:Desktop Leo$ otool -L demo
demo:
@rpath/PullToRefreshKit.framework/PullToRefreshKit (compatibility version 1.0.0, current version 1.0.0)
/System/Library/Frameworks/Foundation.framework/Foundation (compatibility version 300.0.0, current version 1444.12.0)
/usr/lib/libobjc.A.dylib (compatibility version 1.0.0, current version 228.0.0)
@rpath/libswiftCore.dylib (compatibility version 1.0.0, current version 900.0.65)
@rpath/libswiftCoreAudio.dylib (compatibility version 1.0.0, current version 900.0.65)
//...
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中。
可以通过MachOView查看:Dynamic Loader Info -> Rebase Info
192:Desktop Leo$ xcrun dyldinfo -bind demo
bind information:
segment section address type addend dylib symbol
__DATA __got 0x10003C038 pointer 0 PullToRefreshKit __T016PullToRefreshKit07DefaultC4LeftC9textLabelSo7UILabelCvWvd
__DATA __got 0x10003C040 pointer 0 PullToRefreshKit __T016PullToRefreshKit07DefaultC5RightC9textLabelSo7UILabelCvWvd
__DATA __got 0x10003C048 pointer 0 PullToRefreshKit __T016PullToRefreshKit07DefaultC6FooterC9textLabelSo7UILabelCvWvd
__DATA __got 0x10003C050 pointer 0 PullToRefreshKit __T016PullToRefreshKit07DefaultC6HeaderC7spinnerSo23UIActivityIndicatorViewCvWvd
//...
Rebase解决了内部的符号引用问题,而外部的符号引用则是由Bind解决。在解决Bind的时候,是根据字符串匹配的方式查找符号表,所以这个过程相对于Rebase来说是略慢的。
同样,也可以通过xcrun dyldinfo来查看Bind的信息,比如我们查看bind信息中,包含UITableView的部分:
192:Desktop Leo$ xcrun dyldinfo -bind demo | grep UITableView
__DATA __objc_classrefs 0x100041940 pointer 0 UIKit _OBJC_CLASS_$_UITableView
__DATA __objc_classrefs 0x1000418B0 pointer 0 UIKit _OBJC_CLASS_$_UITableViewCell
__DATA __objc_data 0x100041AC0 pointer 0 UIKit _OBJC_CLASS_$_UITableViewController
__DATA __objc_data 0x100041BE8 pointer 0 UIKit _OBJC_CLASS_$_UITableViewController
__DATA __objc_data 0x100042348 pointer 0 UIKit _OBJC_CLASS_$_UITableViewController
__DATA __objc_data 0x100042718 pointer 0 UIKit _OBJC_CLASS_$_UITableViewController
__DATA __data 0x100042998 pointer 0 UIKit _OBJC_METACLASS_$_UITableViewController
__DATA __data 0x100042A28 pointer 0 UIKit _OBJC_METACLASS_$_UITableViewController
__DATA __data 0x100042F10 pointer 0 UIKit _OBJC_METACLASS_$_UITableViewController
__DATA __data 0x1000431A8 pointer 0 UIKit _OBJC_METACLASS_$_UITableViewController
Objective C
Objective C是动态语言,所以在执行main函数之前,需要把类的信息注册到一个全局的Table中。同时,Objective C支持Category,在初始化的时候,也会把Category中的方法注册到对应的类中,同时会唯一Selector,这也是为什么当你的Cagegory实现了类中同名的方法后,类中的方法会被覆盖。
另外,由于iOS开发时基于Cocoa Touch的,所以绝大多数的类起始都是系统类,所以大多数的Runtime初始化起始在Rebase和Bind中已经完成。
Initializers
接下来就是必要的初始化部分了,主要包括几部分:
- +load方法。
- C/C++静态初始化对象和标记为attribute(constructor)的方法
这里要提一点的就是,+load方法已经被弃用了,如果你用Swift开发,你会发现根本无法去写这样一个方法,官方的建议是实用initialize。区别就是,load是在类装载的时候执行,而initialize是在类第一次收到message前调用。
如果程序刚被运行过一次,那么程序的代码会被dyld缓存起来,因此即使杀掉进程再次重启加载时间也会相对快一点,如果长时间没有启动或者当前dyld的缓存已经被其他应用占据,那么这次启动所花费的时间就要长一点,这就分别是热启动和冷启动的概念。
dyld2 与 dyld3
dyld2
是纯粹的in-process,也就是在程序进程内执行的,也就意味着只有当应用程序被启动的时候,dyld2才能开始执行任务。
dyld3
则是部分out-of-process,部分in-process。图中,虚线之上的部分是out-of-process的,在App下载安装和版本更新的时候会去执行,out-of-process会做如下事情:
- 分析Mach-o Headers
- 分析依赖的动态库
- 查找需要Rebase & Bind之类的符号
- 把上述结果写入缓存
这样,在应用启动的时候,就可以直接从缓存中读取数据,加快加载速度。
4. main到看到app界面
我们首先来分析下,从main函数开始执行,到你的第一个界面显示,这期间一般会做哪些事情。
- 执行AppDelegate的代理方法,主要是didFinishLaunchingWithOptions
- 初始化Window,初始化基础的ViewController结构(一般是UINavigationController+UITabViewController)
- 获取数据(Local DB/Network),展示给用户。
5. 启动优化
这里也分为main之前和之后来康康可以做些什么叭~
pre-main优化
从上面可以得出以下几个结论,影响该阶段启动时间的因素如下:
- Mach-O可执行文件的加载和内存重新分配规划,对于其segment和section进行虚拟内存的分页管理的调度
- dyld动态链接内存中的公共镜像,在运行时进行检查共享数据和链接调用
- Runtime的初始化,包括class注册、category加载、变量对齐等
- C++静态对象和全局变量的加载
- ObjeC所有load函数的调用加载
优化措施:
- 减少ObjC的类膨胀问题,清理没有使用的类,合并松散无用的类
- 减少静态变量的声明和初始化的分离
static int x;
static short conv_table [128];
//更换为
static int x = 0;
static short conv_table [128] = {0};
- 减少静态变量的使用
- 减少符号表的导出
通过设置-exported_symbols_list或-unexported_symbols_lis来限制符号表的导出,从而减少dyld的工作量 - 去除没有使用的动态库依赖,明确所依赖的frameworks是require还是optional,optional会动态进行额外检查
- 删除没有用的方法or类,合并Category和功能类似的类。比如:UIView+Frame,UIView+AutoLayout…合并为一个
- 减少+load函数的实现,并减少在其中操作的逻辑,尽量不要用C++虚函数(创建虚函数表有开销)。
- 对某些经常调用的代码进行二进制化,生成静态库,多使用静态库代替动态库,将多个静态库框架,集中制作成静态framework,从而能够减少dyld的链接工作
- 合并动态库,比如公司内部由私有Pod建立了如下动态库:XXTableView, XXHUD, XXLabel,强烈建议合并成一个XXUIKit来提高加载速度。
- 内存上优化:类和方法名不要太长。iOS每个类和方法名都在__cstring段里都存了相应的字符串值,所以类和方法名的长短也是对可执行文件大小是有影响,及影响加载速度也消耗内存;因为OC的动态特性,都是加载后通过类/方法名反射找到这个类/方法进行调用,OC的对象模型会把类/方法名字符串都保存下来(压缩算法TinyPNG)。
main之后优化
main到app显示从上图可以得到,影响main阶段的启动时间因素是:
- AppDelegate代理的加载生命周期回调
- Application Window的布局、绘制和加载
- RootViewController的加载
优化措施:
- 压缩和减小启动图片
- 尽量不使用storyboard或者是nib来布局rootViewController
- 在didFinishLaunchingWithOptions阶段,尽可能减少阻塞代码的执行,可以利用多线程进行加载逻辑的处理,注意多线程对主线程同步阻塞可能造成的黑屏问题。(能延迟初始化的尽量延迟初始化,不能延迟初始化的尽量放到后台初始化。)
- 将非同步需求的初始化逻辑进行异步加载
- 延迟初始化那些不必要的UIViewController。启动的时候只是一个UIViewController作为占位符给TabController,等到用户点击了再去进行真正的数据和视图的初始化工作。
- Time Profiler在分析时间占用,针对性解决问题。
参考:
iOS操作系统-- App启动流程分析与优化
iOS App启动时发生了什么?
iOS-APP的启动流程和生命周期
【性能优化】今日头条iOS客户端启动速度优化