美文网首页
App启动流程分析及优化

App启动流程分析及优化

作者: 荒漠现甘泉 | 来源:发表于2019-03-07 23:34 被阅读0次

    前言

    启动时间是衡量应用品质的重要指标。

    本文首先会从原理上出发,讲解iOS系统是如何启动App的,然后从main函数之前和main函数之后俩个角度去分析如何优化启动时间。

    基础概念

    Mach-O

    Mach-O是一种文件的格式,是iOS/Mac OS上存储程序以及库的标准格式。

    1、常见的Mach-O格式的文件

    • MH_OBJECT目标文件
      • .o
      • .a/.framework静态库,静态库即多个.o文件存放在一起实现特定的功能
    • MH_EXECUTE可执行文件
      • .app/MyApp
      • .out
    • MH_DYLIB动态库
      • .framework/xxx
      • /dylib
    • MH_DYLINKER动态链接器
      • usr/lib/dyld
    • MH_DSYM存储二进制文件符号信息的文件
      • .dYSM/Contents/Resources/DWARF/MyApp

    2、查看项目targetMach-O文件的类型

    • MH_EXECUTE类型
      target的Mach-O类型.png

    Mach-O文件的基本结构

    Mach-O文件包含以下三个主要区域:


    Mach-O文件结构.png
    • Header头部,包含可以执行的CPU架构,比如x86,arm64;
    • Load commands加载命令,包含文件的组织架构和在虚拟内存中的布局方式;
    • Data,数据,包含Load commands中需要的各个段(segment)的数据,每个Segment的大小都是Page的整数倍。

    我们用MachOView打开Demo工程的可执行文件,来验证下Mach-O的文件布局:

    Mach-O文件布局.png

    图中分析的mach-o文件来源于PullToRefreshKit。这是一个纯Swift的编写的工程。
    我们再来看看Load Commands的目录结构:

    Load Commands结构.png

    从上图可知 Load Commands 主要包含了有多个 Segment 段,每个中又包含了多个 Section 段。每一部分都是系统执行指令。可以看到主要包含了一下几个Segment段。

    • __TEXT段,主要包含程序代码和只读的常量,这个段的内容如果是系统动态库的内容,那么所有进程公用。只读可执行。
    • __DATA段主要包含全局变量和静态变量,这个段的内容每个进程单独进行维护。可读写。
    • __LINKEDIT主要包含链接器使用的符号和其他的表(比如函数名称、地址等)这个段的内容也是可以多进程公用。只读。

    dyld 动态链接器

    dyld的全称是Dynamic Loader(动态链接器),它的作用是加载一个进程所需要的image,dyld是开源的

    Virtual Memory 虚拟内存

    虚拟内存是在物理内存上建立的一个逻辑地址空间,它向上(应用)提供了一个连续的逻辑地址空间,向下隐藏了物理内存的细节。
    虚拟内存使得逻辑地址可以没有实际的物理地址,也可以让多个逻辑地址对应到一个物理地址。
    虚拟内存被划分为一个个大小相同的Page(64位系统上是16KB),提高管理和读写的效率。 Page又分为只读和读写的Page。

    虚拟内存是建立在物理内存和进程之间的中间层。在iOS上,当内存不足的时候,会尝试释放那些只读的Page,因为只读的Page在下次被访问的时候,可以再从磁盘读取。如果没有可用内存,会通知在后台的App(也就是在这个时候收到了memory warning),如果在这之后仍然没有可用内存,则会杀死在后台的App。

    Page fault

    在应用执行的时候,它被分配的逻辑地址空间都是可以访问的,当应用访问一个逻辑Page,而在对应的物理内存中并不存在的时候,这时候就发生了一次Page fault。当Page fault发生的时候,会中断当前的程序,在物理内存中寻找一个可用的Page,然后从磁盘中读取数据到物理内存,接着继续执行当前程序。

    Dirty Page & Clean Page

    • 如果一个Page可以从磁盘上重新生成,那么这Page称为Clean Page
    • 如果一个Page包含了进程相关信息,那么这个Page称为Dirty Page

    像代码段这种只读的Page就是Clean Page。而像数据段(_DATA)这种读写的Page,当写数据发生的时候,会触发COW(Copy on write),也就是写时复制,Page会被标记成Dirty,同时会被复制。

    想要了解更多细节,可以阅读文档:Memory Usage Performance Guidelines

    启动过程

    使用dyld2启动应用的过程如图:

    启动过程.png

    大致的过程如下:

    1.加载dyld到App进程
    2.加载动态库(包括所依赖的所有动态库)
    3.Rebase
    4.Bind
    5.初始化Objective C Runtime
    6.Initializers

    Load Dyld 加载动态链接器

    App开始启动后,系统首先记载可执行文件(Mach-O文件),然后加载动态链接器dyld,dyld是一个专门用来加载动态链接库的库。执行从dyld开始,dyld从可执行文件依赖的动态库开始,递归加载所有的依赖动态库链接库。

    Load Dylibs 加载动态库

    dyld会首先读取mach-o文件的Header和load commonds。
    接着就知道了这个可执行文件依赖的动态库。例如加载动态库A到内存,接着检查A所依赖的动态库,就这样的递归加载,知道所有的动态库加载完毕。通常一个App所依赖的动态库在100~400个左右,其中大多数都是系统的动态库,它们会被缓存到(共享缓存)dyld share 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

    在加载所有的动态链接库之后,它们只是处在相互独立的状态,需要将它们绑定起来,这就是Fix-ups。代码签名使得我们不能修改指令,那样就不能让一个dylib调用另外一个dylib。这时需要加很多间接层。

    现代 code-gen 被叫做动态 PIC(Position Independent Code),意味着代码可以被加载到间接的地址上。当调用发生时,code-gen 实际上会在 __DATA 段中创建一个指向被调用者的指针,然后加载指针并跳转过去。

    所以 dyld 做的事情就是修正(fix-up)指针和数据。Fix-up 有两种类型,rebasing 和 binding。

    Rebasing:在镜像内部调整指针的指向
    Binding:将指针指向镜像外部的内容

    这里先来讲讲为什么要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&Bind.png

    之所以需要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
    

    ObjC Runtime

    Objective C是动态语言,所以在执行main函数之前,需要把类的信息注册到一个全局的Table中。同时,Objective C支持Category,在初始化的时候,也会把Category中的方法注册到对应的类中,同时会确定唯一Selector,这也是为什么当你的Cagegory实现了类中同名的方法后,类中的方法会被覆盖。

    另外,由于iOS开发时基于Cocoa Touch的,所以绝大多数的类其实都是系统类,所以大多数的Runtime初始化其实在Rebase和Bind中已经完成。

    runtime加载过程.png

    Runtime具体的加载过程包括:

    1、所有类型的定义和注册,Objective-C的类不是编译器决定的,是运行时动态载入到全局表中的
    2、非脆弱的ivars变量抵消更新,修改实例变量的内存地址偏移问题
    3、分类替换并添加到方法列表中,将分类中的方法加载到方法列表中
    4、确认选择器全局唯一

    Initializers

    在Runtime系统加载以后,开始进行初始化

    Initializers.png

    1.Objc的+load()函数
    2.C++的构造函数属性函数 形如attribute((constructor)) void DoSomeInitializationWork()
    3.非基本类型的C++静态全局变量的创建(通常是类或结构 体)(non-trivial initializer) 比如一个全局静态结构体的构建,如果在构造函数中有繁重的工作,那么会拖慢启动速度

    dyld3

    上文的讲解是dyld2的加载方式。而最新的是dyld3加载方式略有不同:

    dyld2和dyld3的区别.png

    dyld2是纯粹的in-process,也就是在程序进程内执行的,也就意味着只有当应用程序被启动的时候,dyld2才能开始执行任务。

    dyld3则是部分out-of-process,部分in-process。图中,虚线之上的部分是out-of-process的,在App下载安装和版本更新的时候会去执行,out-of-process会做如下事情:

    • 分析Mach-o Headers
    • 分析依赖的动态库
    • 查找需要Rebase & Bind之类的符号
    • 把上述结果写入缓存

    这样,在应用启动的时候,就可以直接从缓存中读取数据,加快加载速度。

    启动时间

    冷启动VS热启动

    • 冷启动:App被杀掉进程以后一切从头开始启动的过程
    • 热启动:按下home键的时候,iOS APP进入后台还没被杀掉进程的一段时间,这时点击APP马上就能恢复到原状态,这种启动我们称为热启动。

    还有一种冷热启动说法如下:

    如果你刚刚启动过App,这时候App的启动所需要的数据仍然在缓存中,再次启动的时候称为热启动。

    如果设备刚刚重启,然后启动App,这时候称为冷启动。

    启动时间在小于400ms是最佳的,因为从点击图标到显示Launch Screen,到Launch Screen消失这段时间是400ms。启动时间不可以大于20s,否则会被系统杀掉。

    在Xcode中,可以通过设置环境变量来查看App的启动时间,DYLD_PRINT_STATISTICS和DYLD_PRINT_STATISTICS_DETAILS。

    设置查看启动时间.png
    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%)
    
    

    对于这个libMainThreadChecker.dylib估计很多同学会有点陌生,这是XCode 9新增的动态库,用来做主线程检查的。

    优化启动时间

    启动时间这个名词,不同的人有不同的定义。在我看来,

    启动时间是用户点击App图标,到第一个界面展示的时间。

    以main函数作为分水岭,启动时间其实包括了两部分:main函数之前和main函数到第一个界面的viewDidAppear:。所以,优化也是从两个方面进行的,个人建议优先优化后者,因为绝大多数App的瓶颈在自己的代码里。

    Main函数之后

    我们首先来分析下,从main函数开始执行,到你的第一个界面显示,这期间一般会做哪些事情。

    • 执行AppDelegate的代理方法,主要是didFinishLaunchingWithOptions
    • 初始化Window,加载rootViewController
    • 获取数据(Local DB/Network),展示给用户。

    UIViewController

    延迟初始化那些不必要的UIViewController。

    比如网易新闻:

    uiviewController示例.png

    在启动的时候只需要初始化首页的头条页面即可。像“要闻”,“我的”等页面,则延迟加载,即启动的时候只是一个UIViewController作为占位符给TabController,等到用户点击了再去进行真正的数据和视图的初始化工作。

    AppDelegate

    通常我们会在AppDelegate的代理方法里进行初始化工作,主要包括了两个方法:

    • didFinishLaunchingWithOptions
    • applicationDidBecomeActive

    能延迟初始化的尽量延迟初始化,不能延迟初始化的尽量放到后台初始化

    这些工作主要可以分为几类:

    • 三方SDK初始化,比如Crash统计; 像分享之类的,可以等到第一次调用再出初始化。
    • 初始化某些基础服务,比如WatchDog,远程参数。
    • 启动相关日志,日志往往涉及到DB操作,一定要放到后台去做
    • 业务方初始化,这个交由每个业务自己去控制初始化时间。

    对于didFinishLaunchingWithOptions的代码,建议按照以下的方式进行划分:

    @interface AppDelegate ()
    //业务方需要的生命周期回调
    @property (strong, nonatomic) NSArray<id<UIApplicationDelegate>> * eventQueues;
    //主框架负责的生命周期回调
    @property (strong, nonatomic) id<UIApplicationDelegate> basicDelegate;
    @end
    

    然后,你会得到一个非常干净的AppDelegate文件:

    - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
        for (id<UIApplicationDelegate> delegate in self.eventQueues) {
            [delegate application:application didFinishLaunchingWithOptions:launchOptions];
        }
        return [self.basicDelegate application:application didFinishLaunchingWithOptions:launchOptions];
    }
    

    由于对这些初始化进行了分组,在开发期就可以很容易的控制每一个业务的初始化时间:

    CFTimeInterval startTime = CACurrentMediaTime();
    //执行方法
    CFTimeInterval endTime = CACurrentMediaTime();
    

    用Time Profiler找到元凶

    Time Profiler在分析时间占用上非常强大。使用的时候注意三点

    • 在打包模式下分析(一般是Release),这样和线上环境一样。
    • 记得开启dsym,不然无法查看到具体的函数调用堆栈
    • 分析性能差的设备,对于支持iOS 8的,一般分析iphone 4s或者iphone 5。

    一个典型的分析界面如下:

    Time Profiler分析界面.png

    几点要注意:
    1、分析启动时间,一般只关心主线程
    2、选择Hide System LibrariesInvert Call Tree,这样我们能专注于自己的代码
    3、右侧可以看到详细的调用堆栈信息

    在某一行上双击,我们可以进入到代码预览界面,去看看实际每一行占用了多少时间:

    具体代码时间.png

    小结

    不同的App在启动的时候做的事情往往不同,但是优化起来的核心思想无非就两个:

    • 能延迟执行的就延迟执行。比如SDK的初始化,界面的创建。
    • 不能延迟执行的,尽量放到后台执行。比如数据读取,原始JSON数据转对象,日志发送。

    Main函数之前的优化

    Main函数之前是iOS系统的工作,所以这部分的优化往往更具有通用性。

    dylibs 加载动态库

    启动的第一步是加载动态库,加载系统的动态库使很快的,因为可以缓存,而加载内嵌的动态库速度较慢。所以,提高这一步的效率的关键是:减少动态库的数量。

    • 合并动态库,比如公司内部由私有Pod建立了如下动态库:XXTableView, XXHUD, XXLabel,强烈建议合并成一个XXUIKit来提高加载速度。

    pod的管理,使得项目中同一类的库只存在一份,cocoapods的项目可以静态库 动态库二选其一,关于这两种的区别下面会做详细解释

    默认使用静态库管理,如果想改为动态,需要在podfile内部添加use_frameworks!字段告诉pod,使用框架的方式,安装和管理第三方库

    Rebase & Bind & Objective C Runtime

    Rebase和Bind都是为了解决指针引用的问题。对于Objective C开发来说,主要的时间消耗在Class/Method的符号加载上,所以常见的优化方案是:

    • 减少__DATA段中的指针数量。
    • 合并Category和功能类似的类。比如:UIView+Frame,UIView+AutoLayout…合并为一个
    • 删除无用的方法和类。
    • 多用Swift Structs,因为Swfit Structs是静态分发的。感兴趣的同学可以看看我之前这篇文章:Swift进阶之内存模型和方法调度

    Initializers

    通常,我们会在+load方法中进行method-swizzling,这也是Nshipster推荐的方式。

    • 用initialize替代load。不少同学喜欢用method-swizzling来实现AOP去做日志统计等内容,强烈建议改为在initialize进行初始化。
    • 减少atribute((constructor))的使用,而是在第一次访问的时候才用dispatch_once等方式初始化。
    • 不要创建线程
    • 使用Swfit重写代码。

    参考资料:

    相关文章

      网友评论

          本文标题:App启动流程分析及优化

          本文链接:https://www.haomeiwen.com/subject/vywmuqtx.html