[iOS] App启动流程 & 优化探讨

强推:深入理解iOS App的启动过程

1. Mach-O

首先我们每个app其实都是一个可执行文件而已,也就是我们熟悉的胖二进制文件Mach-O,这个文件的布局大概是下面酱紫的:

[iOS] App启动流程 & 优化探讨_第1张图片
mach-o

Mach-O文件主要有3部分组成:

  • Header:保存了一些基本信息,包括了该文件运行的平台、文件类型、LoadCommands的个数等等。Headers的主要作用就是帮助系统迅速的定位Mach-O文件的运行环境,文件类型。保存了一些dyld重要的加载参数

  • LoadCommands:可以理解为加载命令,在加载Mach-O文件时会使用这里的数据来确定内存的分布以及相关的加载命令。比如我们的main函数的加载地址,程序所需的dyld的文件路径,以及相关依赖库的文件路径。

  • Data: 每一个segment的具体数据都保存在这里,这里包含了具体的代码、数据等等。

感觉其实真的很像操作系统的加载,就是通过setup程序,先加载section0,然后再一步一步加载代码。

[iOS] App启动流程 & 优化探讨_第2张图片
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_STATISTICSDYLD_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函数。

[iOS] App启动流程 & 优化探讨_第3张图片
pre-main
[iOS] App启动流程 & 优化探讨_第4张图片
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函数被调用

这里我们发现第四步开始出现了一个名词:dylddyld加载动态链接库的库,该库在加载可执行文件的时候,递归加载所需要的所有动态库。动态库包括iOS操作系统的系统framework,oc的runtime系统libobjc,系统级别的库libSystem,例如libdispatch(GCD)、libsystem_block(Block)。


首先我们先看看dyld2的工作流程~

[iOS] App启动流程 & 优化探讨_第5张图片
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 修正外部指针指向
image

之所以需要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的缓存已经被其他应用占据,那么这次启动所花费的时间就要长一点,这就分别是热启动和冷启动的概念。


[iOS] App启动流程 & 优化探讨_第6张图片
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函数的调用加载

优化措施:
  1. 减少ObjC的类膨胀问题,清理没有使用的类,合并松散无用的类
  2. 减少静态变量的声明和初始化的分离
static int x;
static short conv_table [128];
//更换为
static int x = 0;
static short conv_table [128] = {0};
  1. 减少静态变量的使用
  2. 减少符号表的导出
    通过设置-exported_symbols_list或-unexported_symbols_lis来限制符号表的导出,从而减少dyld的工作量
  3. 去除没有使用的动态库依赖,明确所依赖的frameworks是require还是optional,optional会动态进行额外检查
  4. 删除没有用的方法or类,合并Category和功能类似的类。比如:UIView+Frame,UIView+AutoLayout…合并为一个
  5. 减少+load函数的实现,并减少在其中操作的逻辑,尽量不要用C++虚函数(创建虚函数表有开销)。
  6. 对某些经常调用的代码进行二进制化,生成静态库,多使用静态库代替动态库,将多个静态库框架,集中制作成静态framework,从而能够减少dyld的链接工作
  7. 合并动态库,比如公司内部由私有Pod建立了如下动态库:XXTableView, XXHUD, XXLabel,强烈建议合并成一个XXUIKit来提高加载速度。
  8. 内存上优化:类和方法名不要太长。iOS每个类和方法名都在__cstring段里都存了相应的字符串值,所以类和方法名的长短也是对可执行文件大小是有影响,及影响加载速度也消耗内存;因为OC的动态特性,都是加载后通过类/方法名反射找到这个类/方法进行调用,OC的对象模型会把类/方法名字符串都保存下来(压缩算法TinyPNG)。
main之后优化
main到app显示

从上图可以得到,影响main阶段的启动时间因素是:

  • AppDelegate代理的加载生命周期回调
  • Application Window的布局、绘制和加载
  • RootViewController的加载

优化措施:
  1. 压缩和减小启动图片
  2. 尽量不使用storyboard或者是nib来布局rootViewController
  3. 在didFinishLaunchingWithOptions阶段,尽可能减少阻塞代码的执行,可以利用多线程进行加载逻辑的处理,注意多线程对主线程同步阻塞可能造成的黑屏问题。(能延迟初始化的尽量延迟初始化,不能延迟初始化的尽量放到后台初始化。)
  4. 将非同步需求的初始化逻辑进行异步加载
  5. 延迟初始化那些不必要的UIViewController。启动的时候只是一个UIViewController作为占位符给TabController,等到用户点击了再去进行真正的数据和视图的初始化工作。
  6. Time Profiler在分析时间占用,针对性解决问题。

参考:
iOS操作系统-- App启动流程分析与优化
iOS App启动时发生了什么?
iOS-APP的启动流程和生命周期
【性能优化】今日头条iOS客户端启动速度优化

你可能感兴趣的:([iOS] App启动流程 & 优化探讨)