iOS - App启动优化

作者: Longshihua | 来源:发表于2019-08-05 11:44 被阅读14次

    前言

    一般情况下,在App早期阶段,启动不会有明显的性能问题。启动性能问题也不是在某个版本突然出现的,而是随着版本迭代,App功能越来越复杂,启动任务越来越多,启动时间也一点点延长,因而导致启动变慢,影响用户体验

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

    启动的类型(冷启动 VS 热启动)

    屏幕快照 2019-08-02 下午5.39.37.png
    • 冷启动

    App尚未运行,启动App,加载并构建整个应用,完成初始化的工作,这时候称为冷启动。

    • 热启动

    如果你刚刚启动过App,这时候App的启动所需要的数据仍然在缓存中行(常见的场景是用户按了 Home 按钮),再次启动的时候称为热启动。

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

    App的启动过程

    app_launch_fg.png

    启动时间

    一般而言,把iOS冷启动的过程定义为:从用户点击App图标开始到appDelegate的didFinishLaunchingWithOptions方法执行完成为止。这个过程主要分为两个阶段:

    屏幕快照 2019-08-02 下午2.20.55.png

    T(App 总启动时间) = T1(main()之前的加载时间) + T2(main()之后的加载时间)。

    • T1:main()函数之前,即操作系统加载App可执行文件到内存,然后执行一系列的加载&链接等工作,最后执行至Appmain()函数。

    • T2:main()函数之后,即从main()开始,到appDelegatedidFinishLaunchingWithOptions方法执行完毕。

    然而,当didFinishLaunchingWithOptions执行完成时,用户还没有看到App的主界面,也不能开始使用App。例如:在App中,App还需要做一些初始化工作,然后经历定位、首页请求、首页渲染等过程后,用户才能真正看到数据内容并开始使用,这个时候冷启动才算完成,把这个过程定义为T3。

    image

    综上App冷启动过程定义为:从用户点击App图标开始到用户能看到App主界面内容为止这个过程,即T1+T2+T3。在App冷启动过程当中,这三个阶段中的每个阶段都存在很多可以被优化的点。

    Mach-O

    Mach-O 是针对不同运行时可执行文件的文件类型。哪些名词指的是Mach-o

    • Executable 可执行文件
    • Dylib 动态库
    • Bundle 无法被连接的动态库,只能通过dlopen()加载

    Image 指的是ExecutableDylib或者Bundle的一种,Framework 是动态库和对应的头文件和资源文件的集合

    Apple出品的操作系统的可执行文件格式几乎都是mach-o,iOS当然也不例外。 mach-o可以大致的分为三部分:

    4010043-95623a9950ee3f2d.png
    • Header 头部

    包含可以执行的CPU架构,比如x86,arm64Headers的主要作用就是帮助系统迅速的定位Mach-O文件的运行环境,文件类型。保存了一些dyld重要的加载参数

    • Load commands 加载命令

    包含文件的组织架构和在虚拟内存中的布局方式。比如我们的main函数的加载地址,程序所需的dyld的文件路径,以及相关依赖库的文件路径。

    • Data 数据

    包含load commands中需要的各个段(Segment)的数据,每一个Segment大小都得是Page的整数倍。

    MachOView打开工程的可以执行文件,来验证下mach-o的文件布局:

    MachOView is a visual Mach-O file browser.

    屏幕快照 2019-03-13 下午4.24.43.png

    那么Data部分又包含哪些Segment呢?绝大多数mach-o包括以下三个段(支持用户自定义Segment,但是很少使用)

    • __TEXT 代码段

    只读,包括函数,字符串,上图中类似__TEXT,__text的都是代码段

    • __DATA 数据段

    读写,包括可读写的全局变量等,上图类似中的__DATA,__data都是数据段

    • __LINKEDIT

    __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启动应用的过程如图:

    屏幕快照 2019-03-13 下午4.53.25.png

    大致的过程如下:

    加载dyld到App进程
    加载动态库(包括所依赖的所有动态库)
    Rebase
    Bind
    初始化Objective C Runtime
    其它的初始化代码

    1、加载动态库

    在每个动态库的加载过程中, dyld需要执行:

    • 分析所依赖的动态库
    • 找到动态库的mach-o文件
    • 打开文件
    • 验证文件
    • 在系统核心注册文件签名
    • 对动态库的每一个segment调用mmap()

    具体来说:dyld会首先读取mach-o文件的Header和load commands。 接着就知道了这个可执行文件依赖的动态库。例如加载动态库A到内存,接着检查A所依赖的动态库,就这样的递归加载,直到所有的动态库加载完毕。通常一个App所依赖的动态库在100-400个左右,其中大多数都是系统的动态库,它们会被缓存到dyld shared cache,这样读取的效率会很高。

    所有动态链接库和我们App中的静态库.a和所有类文件编译后的.o文件最终都是由dyld(the dynamic link editor)来加载到内存中。

    查看mach-o文件所依赖的动态库,可以通过MachOView的图形化界面(展开Load Command就能看到),也可以通过命令行otool

    otool -L ./Test.app/Test
    ./Test.app/Test:
        /System/Library/Frameworks/Foundation.framework/Foundation (compatibility version 300.0.0, current version 1570.15.0)
        /usr/lib/libobjc.A.dylib (compatibility version 1.0.0, current version 228.0.0)
        /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1252.250.1)
        /System/Library/Frameworks/UIKit.framework/UIKit (compatibility version 1.0.0, current version 61000.0.0)
        @rpath/libswiftCore.dylib (compatibility version 1.0.0, current version 1001.0.69)
        @rpath/libswiftCoreFoundation.dylib (compatibility version 1.0.0, current version 1001.0.69)
        @rpath/libswiftCoreGraphics.dylib (compatibility version 1.0.0, current version 1001.0.69)
        @rpath/libswiftCoreImage.dylib (compatibility version 1.0.0, current version 1001.0.69)
    

    使用 otool 命令查看 App 所使用的动态库

    常用的查看Mach-O文件命令

    4010043-f135740b920cdcc6.png 4010043-fa81141647bc02c1.png

    2、Rebase && Bind

    两种主要的技术来保证应用的安全:ASLRCode Sign

    • ASLR

    ASLR的全称是Address space layout randomization,翻译过来就是“地址空间布局随机化”。App被启动的时候,程序会被映射到逻辑的地址空间,这个逻辑的地址空间有一个起始地址,而ASLR技术使得这个起始地址是随机的。如果是固定的,那么黑客很容易就可以由起始地址+偏移量找到函数的地址。

    • Code Sign

    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外部的内容。这些指向外部的指针被符号(symbol)名称绑定,dyld需要去符号表立查找,找到symbol对应的实现

    屏幕快照 2019-03-13 下午4.59.52.png

    之所以需要Rebase,是因为刚刚提到的ASLR使得地址随机化,导致起始地址不固定,另外由于Code Sign,导致不能直接修改ImageRebase的时候只需要增加对应的偏移量即可。待Rebase的数据都存放在__LINKEDIT中。

    3、Objective C setup

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

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

    4、Initializers

    接下来就是必要的初始化部分了,主要包括几部分:

    • +load方法
    • C/C++静态初始化对象和标记为__attribute__(constructor)的方法
    • 非基本类型的C++静态全局变量的创建

    这里要提一点的就是,+load方法已经被弃用了,如果你用Swift开发,你会发现根本无法去写这样一个方法,官方的建议是实用initialize。区别就是,load是在类装载的时候执行,而initialize是在类第一次收到message前调用。

    dyld3

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

    屏幕快照 2019-03-13 下午5.04.06.png

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

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

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

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

    启动时间统计

    • main函数之前的时间

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

    4010043-54179d2c2df3f120.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%)
    

    可以看到main函数之前总的执行时间为43.00 milliseconds,然后各个阶段的加载时间分布

    • main函数之后的时间统计

    先来看看如何通过打点的方式统计main函数之后的时间,下面代码是有些文章给出的一种实现方式

    CFAbsoluteTime StartTime;
    
    int main(int argc, char * argv[]) {
        @autoreleasepool {
            StartTime = CFAbsoluteTimeGetCurrent();
            return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
        }
    }
    
    extern CFAbsoluteTime StartTime;
     ...
     
    // 在 applicationDidFinishLaunching:withOptions: 方法的最后统计
    dispatch_async(dispatch_get_main_queue(), ^{
        NSLog(@"Launched in %f sec", CFAbsoluteTimeGetCurrent() - StartTime);
    });
    

    上述代码使CFAbsoluteTimeGetCurrent()方法来计算时间,CFAbsoluteTimeGetCurrent()的概念和NSDate非常相似,只不过参考点是以 GMT 为标准的,2001年一月一日00:00:00这一刻的时间绝对值。CFAbsoluteTimeGetCurrent()也会跟着当前设备的系统时间一起变化,也可能会被用户修改。他的精确度可能是微秒(μs)

    其实还可以通过mach_absolute_time()来计算时间,这个一般很少用,他表示CPU的时钟周期数(ticks),精确度可以达到纳秒(ns),mach_absolute_time()不受系统时间影响,只受设备重启和休眠行为影响。示例代码如下

    static uint64_t loadTime;
    static uint64_t applicationRespondedTime = -1;
    static mach_timebase_info_data_t timebaseInfo;
    
    static inline NSTimeInterval MachTimeToSeconds(uint64_t machTime) {
        return ((machTime / 1e9) * timebaseInfo.numer) / timebaseInfo.denom;
    }
    
    @implementation XXStartupMeasurer
    
    + (void)load {
        loadTime = mach_absolute_time();
        mach_timebase_info(&timebaseInfo);
        
        @autoreleasepool {
            __block id<NSObject> obs;
            obs = [[NSNotificationCenter defaultCenter] addObserverForName:UIApplicationDidFinishLaunchingNotification
                                                                    object:nil queue:nil
                                                                usingBlock:^(NSNotification *note) {
                dispatch_async(dispatch_get_main_queue(), ^{
                    applicationRespondedTime = mach_absolute_time();
                    NSLog(@"StartupMeasurer: it took %f seconds until the app could respond to user interaction.", MachTimeToSeconds(applicationRespondedTime - loadTime));
                });
                [[NSNotificationCenter defaultCenter] removeObserver:obs];
            }];
        }
    }
    

    因为类的+ load方法在main函数执行之前调用,所以我们可以在+ load方法记录开始时间,同时监听UIApplicationDidFinishLaunchingNotification通知,收到通知时将时间相减作为应用启动时间,这样做有一个好处,不需要侵入到业务方的main函数去记录开始时间点

    Main函数之前优化

    • dylib

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

    • 减少动态库的数量
    • 合并动态库,比如公司内部由私有Pod建立了如下动态库:XXTableView, XXHUD, XXLabel,建议合并成一个XXUIKit来提高加载速度
    • 确认Framework应当设为optional还是required,如果该Framework在当前App支持的所有iOS系统版本都存在,那么就设为required,否则就设为optional,因为optional会有些额外的检查

    Rebase & Bind & Objective C Runtime

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

    • 减少__DATA段中的指针数量
    • 合并Category和功能类似的类。比如:UIView+Frame,UIView+AutoLayout…合并为一个
    • 删除无用的方法和类
    • 多用Swift中的Struct,因为Swfit Struct是静态分发的

    Initializers

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

    • 将不必须在+load方法中做的事情延迟到+initialize中,用initialize替代load
    • 减少atribute((constructor))的使用,而是在第一次访问的时候才用dispatch_once等方式初始化
    • 不要创建线程
    • 使用Swfit重写代码
    • 减少C++静态对象

    Main函数之后优化启动时间

    在main()之后主要工作是各种启动项的执行,主界面的构建,例如TabBarVC,HomeVC等等。资源的加载,如图片I/O、图片解码、archive文档等。这些操作中可能会隐含着一些耗时操作,靠单纯阅读非常难以发现,如何发现这些耗时点呢?

    1、Time Profiler

    Time Profiler是Xcode自带的时间性能分析工具,它按照固定的时间间隔来跟踪每一个线程的堆栈信息,通过统计比较时间间隔之间的堆栈状态,来推算某个方法执行了多久,并获得一个近似值。Instruments Tutorial with Swift: Getting Started

    Time Profiler在分析时间的时候注意:

    • 在打包模式下分析(一般是Release),这样和线上环境一样。
    • 记得开启dsym,不然无法查看到具体的函数调用堆栈

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

    屏幕快照 2019-03-13 下午5.35.34.png

    几点要注意:

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

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

    屏幕快照 2019-03-13 下午5.37.10.png

    尽可能去解决优化耗时操作,减少App的启动时间

    优化的核心思想

    能延迟初始化的尽量延迟初始化,不能延迟初始化的尽量放到后台初始化,另外按需加载

    AppDelegate

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

    • didFinishLaunchingWithOptions
    • applicationDidBecomeActive

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

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

    针对上面的任务,我们可以执行的有效方式:

    • 不使用xib,直接视用代码加载首页视图
    • 除了用户看到的第一屏内容所依赖的初始化方法(UI和基础服务),尽量以异步,甚至是后台线程的方式来做初始化。比如:延迟初始化那些不必要的UIViewController

    比如网易新闻:

    屏幕快照 2019-03-13 下午5.28.24.png

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

    • 数据库操作,尽可能的使用异步,如果必须同步那么只加载当前需要的内容。 比如:使用NSUserDefaults加载数据,NSUserDefaults实际上是在Library文件夹下会生产一个plist文件,如果文件太大的话一次能读取到内存中可能很耗时,这个影响需要评估,如果耗时很大的话需要拆分
    • 每次用NSLog方式打印会隐式的创建一个Calendar,因此需要删减启动时各业务方打的log,或者仅仅针对内测版输出log
    • 对实现了+load()方法的类进行分析,尽量将load里的代码延后调用,使用initialize替代load
    • Swift的全局变量和静态变量都是以lazy方式加载,不需要担心不必要的工作量,因此尽量使用Swift

    美团外卖的分阶段启动流程值得参考

    早期由于业务比较简单,所有启动项都是不加以区分,简单地堆积到didFinishLaunchingWithOptions方法中,但随着业务的增加,越来越多的启动项代码堆积在一起,性能较差,代码臃肿而混乱。

    屏幕快照 2019-08-02 下午2.34.23.png

    通过对SDK的梳理和分析,我们发现启动项也需要根据所完成的任务被分类,有些启动项是需要刚启动就执行的操作,如Crash监控、统计上报等,否则会导致信息收集的缺失;有些启动项需要在较早的时间节点完成,例如一些提供用户信息的SDK、定位功能的初始化、网络初始化等;有些启动项则可以被延迟执行,如一些自定义配置,一些业务服务的调用、支付SDK、地图SDK等。我们所做的分阶段启动,首先就是把启动流程合理地划分为若干个启动阶段,然后依据每个启动项所做的事情的优先级把它们分配到相应的启动阶段,优先级高的放在靠前的阶段,优先级低的放在靠后的阶段。

    屏幕快照 2019-08-02 下午2.35.52.png

    下面是我们对美团外卖App启动阶段进行的重新定义,对所有启动项进行的梳理和重新分类,把它们对应到合理的启动阶段。这样做一方面可以推迟执行那些不必过早执行的启动项,缩短启动时间;另一方面,把启动项进行归类,方便后续的阅读和维护。然后把这些规则落地为启动项的维护文档,指导后续启动项的新增和维护。

    屏幕快照 2019-08-02 下午2.37.01.png

    通过上面的工作,我们梳理出了十几个可以推迟执行的启动项,占所有启动项的30%左右,有效地优化了启动项所占的这部分冷启动时间。

    具体操作实施参考美团外卖实践

    Launch Time Performance

    最后Launch Time Performance Guidelines中给出的建议,值得阅读

    1、Launch Time Tips

    • Delay Initialization Code
    • Simplify Your Main Nib File
    • Minimize Global Variables
    • Minimize Strings File Sizes
    • Reduce the Impact of Expensive Operations
    • Avoid Memory Turnover
    • Use Local Resources

    2、Gathering Launch Time Metrics

    • Measuring Launch Speed

    1、Gathering Data Using Checkpoints
    2、Using Explicit Timestamps
    3、Measuring Cocoa Application Launch Times

    • Sampling the Application Launch

    1、Using Shark
    2、Using the sample Command-Line Tool

    3、Minimizing File Access At Launch Time

    • Delay Any Unnecessary File I/O
    • Using fs_usage to Review File I/O

    4、Prebinding Your Application

    • Prebinding Your Code
    • Caveats for Prebinding

    参考

    相关文章

      网友评论

        本文标题:iOS - App启动优化

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