iOS面试题/2019iOS进阶iOS性能调优

iOS - App启动优化

2019-08-05  本文已影响14人  Longshihua

前言

一般情况下,在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()之后的加载时间)。

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

image

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

Mach-O

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

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

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

4010043-95623a9950ee3f2d.png

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

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

包含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的都是代码段

读写,包括可读写的全局变量等,上图类似中的__DATA,__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就是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需要执行:

具体来说: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的全称是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工作,即帮助应用程序找到这些符号的实际地址。

主要包括两部分

屏幕快照 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方法已经被弃用了,如果你用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会做如下事情:

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

启动时间统计

在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函数之后的时间,下面代码是有些文章给出的一种实现方式

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函数之前优化

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

Rebase & Bind & Objective C Runtime

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

Initializers

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

Main函数之后优化启动时间

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

1、Time Profiler

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

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

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

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

几点要注意:

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

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

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

优化的核心思想

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

AppDelegate

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

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

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

比如网易新闻:

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

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

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

早期由于业务比较简单,所有启动项都是不加以区分,简单地堆积到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

2、Gathering Launch Time Metrics

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

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

3、Minimizing File Access At Launch Time

4、Prebinding Your Application

参考

上一篇 下一篇

猜你喜欢

热点阅读