2018-11-21 19:16:28 qq_18505715 阅读数 1421
文章目录
       一、何为冷启动
       1、冷启动
       2、热启动
       二、冷启动时间
       1、什么是冷启动时间
       2、冷启动过程做了什么 
       三、pre-main()阶段
       1、pre-main阶段加载
       2、pr-main节点时间测量及其优化
       四、main()阶段
       1、main()阶段加载
       2、main()优化

一、何为冷启动

1、冷启动: app第一次启动的过程(或者app被kill后,重新启动的过程)。

2、热启动: app处于悬挂状态,被重新切换回app的过程。

二、冷启动时间

1、什么是冷启动时间: 简而言之,就是用户点击app的iocn那一刻开始到看到第一个界面的这段时间。

2、冷启动过程做了什么

将其划分为两部分,一个是pre-main,另一部分是main

1.pre-main阶段
    1.1 加载应用的可执行文件
    1.2 加载动态链接库加载器dyld(dynamic loader)
    1.3 dyld递归加载应用所有依赖的dylib(dynamic library 动态链接库)
        包括iOS系统的以及APP依赖的第三方库。
    
2.main阶段    
   2.1 dyld调用main() 
   2.2 调用UIApplicationMain() 
   2.3 调用applicationWillFinishLaunching(这个一般用不到)
   2.4 调用didFinishLaunchingWithOptions

三、如何查询冷启动时间

1、pre-main阶段加载
具体的加载步骤如下:
(1)加载dylib,分析每个dylib(大部分是iOS系统的),找到其Mach-O文件,
打开并读取验证有效性,找到代码签名注册到内核,
最后对dylib的每个segment调用mmap()。

(2)rebase/bind (指针重定向):
dylib加载完成之后,它们处于相互独立的状态,需要绑定起来。
在dylib的加载过程中,系统为了安全考虑,引入了ASLR
(Address Space Layout Randomization)技术和代码签名。
由于ASLR的存在,镜像(Image,包括可执行文件、dylib和bundle)会在随机的地址上加载,
和之前指针指向的地址(preferred_address)会有一个偏差,dyld需要修正这个偏差,来指向正确的地址。
rebase在前,Bind在后。rebase做的是将镜像读入内存,修正镜像内部的指针性能消耗主要在IO。**bind做的是查询符号表,设置指向镜像外部的指针,**性能消耗主要在CPU计算

(3)ObjC setup
OC的runtime需要维护一张类名与类的方法列表的全局表。
dyld做了如下操作:
对所有声明过的OC类,将其注册到这个全局表中;
将category的方法插入到类的方法列表中
检查每个selector的唯一性
(4)initializer(这是pre-main阶段最耗时的部分)
dyld运行APP的初始化函数,调用每个OC类的+load方法,调用C++的构造器函数(attribute((constructor))修饰),创建非基本类型的C++静态全局变量,然后执行main函数。

2、pr-main节点时间测量及其优化
dyld 提供了内建的测量方法, Xcode 中 Edit scheme -> Run -> Auguments 将环境变量 DYLD_PRINT_STATISTICS 设为1或者YES。

# 优化前的
Total pre-main time: 1.4 seconds (100.0%)
         dylib loading time:  28.64 milliseconds (2.0%) 
        rebase/binding time:  41.42 milliseconds (2.9%)
            ObjC setup time:  37.08 milliseconds (2.6%)  
           initializer time: 1.3 seconds (92.4%)

分别做如下优化
(1) 移除不需要用到的动态库
(2) 移除不需要用到的类, 减少selector、category的数量
(3) 尽量避免在+load方法里执行的操作,可以推迟到+initialize方法中

# 优化后的
Total pre-main time: 1.1 seconds (100.0%)
         dylib loading time:  22.88 milliseconds (1.9%)
        rebase/binding time:  35.48 milliseconds (2.9%)
            ObjC setup time:  29.95 milliseconds (2.5%)
           initializer time: 1.1 seconds (92.5%)

四、main()阶段

1、main()阶段加载: main方法执行只有到didFinishLaunchingWithOptions执行结束这段时间。

主要工作就是初始化必要的服务,显示首页内容等。而我们的优化也是围绕如何能够快速展现首页来开展。简要来说,只需要关注这个didFinishLaunchingWithOptions方法即可。
其实在这方法里面,我们主要是初始化第三方sdk,项目配置,设置根视图控制器等。
我们可以借助打点计时器BLStopwatch来度量didFinishLaunchingWithOptions每行代码的初始时间。

例如:
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions 
{
   NSLog(@"didFinishLaunchingWithOptions 开始执行");
   self.window = [[UIWindow alloc] initWithFrame:UIScreen.mainScreen.bounds];
   self.window.backgroundColor = [UIColor whiteColor];
   UITabBarController *tabVC = [[UITabBarController alloc] init];
   self.window.rootViewController =  tabVC;
   [self.window makeKeyAndVisible];
    
   BLStopwatch *timer = [BLStopwatch sharedStopwatch];
   [timer start];
   [self initShareSDK];
   [timer splitWithDescription:@"初始化分享SDK"];
   [timer stop];
   NSLog(@"%@",timer.prettyPrintedSplits);
   // #1 初始化SDK: 0.523  这是个假设时间
   return YES;
}

我们可以根据打点最后输出的时间对我们的工程进行优化。我们的目的是尽快展现第一个页面给用户。首页最好使用春代码创建,毕竟用sb还有一次解码过程

所以,为了优化启动速度,我们需要对didFinishLaunchingWithOptions必须给任务进行分级分时

* 日志、统计等必须在 APP 一启动就最先配置的事件
* 项目配置、环境配置、用户信息的初始化 、推送、IM等事件
* 其他 SDK 和配置事件

其实,总结来说,就是必须一进app就必须初始化和可以延迟初始化的。就上例而言,初始化一个SDK需要耗时0.5s,如果在该SDK 不是非必须初始化,可以放HomeVC的viewdidLoad或者viewDidAppear去做又或者需要用到才初始化。

2、main()优化
1、展示的首页尽量用纯代码创建,结合缓存更加。
2、结合BLStopwatch对启动服务进行分级分时。
3、对一些非必要的初始化操作,可以放到viewDidAppear,因为到viewDidAppear开始执行的时候,用户已经看到了APP的首屏,即宣告启动结束
4、一般仅针对测试版本进行log打印
5、建议超过0.1的都看下,是否有优化空间
6、对didFinishLaunching里的函数考虑能否挖掘可以延迟加载或者懒加载,

总之:性价比最高的优化阶段就是main函数之后的一些逻辑整理,尽量将不需要的耗时操作延迟到首屏展示之后执行。

2019-06-09 10:55:17 Batac_Lee 阅读数 422

概念

1.热启动:就是按下home键的时候,app还存在一段时间,这时点击app马上就能恢复到原状态,这种启动我们称为热启动。当                       APP 启动时需要的 dylibs 仍然停留在设备的磁盘缓存的时候,这个时候就是热启动,热启动的速度会更快。

2.冷启动:app被kill掉之后,重新打开启动过程为冷启动。

 

 

1.热启动优化。

 一.数据优化,将耗时操作做异步处理。

二.检查NSUserDefaults的存储,NSUserDefaults实际上是在Library文件夹下会生产一个plist文件,加载的时候是整个plist配置文件全部load到内存中。所以非常频繁的存取大量数据也是有可能导致APP启动卡顿的

2.冷启动优化

利用DYLD_PRINT_STATISTICS分析main()函数之前的耗时

重新梳理架构,减少动态库、ObjC类的数目,减少Category的数目

定期扫描不再使用的动态库、类、函数,例如每两个迭代一次

用dispatchonce()代替所有的__attribute__((constructor))函数、C++静态对象初始化、ObjC的+load

在设计师可接受的范围内压缩图片的大小,会有意外收获

利用锚点分析applicationWillFinishLaunching的耗时

将不需要马上在applicationWillFinishLaunching执行的代码延后执行

rootViewController的加载,适当将某一级的childViewController或subviews延后加载

如果你的App可能会被后台拉起并冷启动,可考虑不加载rootViewController

 

2015-08-17 18:51:17 lzyzk 阅读数 259

应用启动流程

首先,与传统C语言一样,IOS应用的执行入口也是定义在main.m中的main函数,代码如下

#import <UIKit/UIKit.h>
#import "AppDelegate.h"

int main(int argc, char * argv[]) {
    @autoreleasepool {
        return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
    }
}

这段代码只干了一件事情——调用UIApplicationMain并返回。那么,这个UIApplicationMain又完成了什么工作呢?

UIApplicationMain的声明如下:

int UIApplicationMain ( int argc, char *argv[], NSString *principalClassName, NSString *delegateClassName );

Apple官方文档描述如下:

This function is called in the main entry point to create the application object and the application delegate and set up the event cycle.

也就是说这个作为在ios应用入口点被调用的函数主要做了两件事情,一是创建了UIApplication实例和UIApplicationDelegate实例,二是建立了事件循环用来接收系统事件。

接下来,它又通过UIApplication调用UIApplication的委托对象UIApplicationDelegate(BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions方法初始化UIWindow和相应地根试图控制器并显示在屏幕上。

总结一下就是:

  1. 用户点击应用图标
  2. 系统调用main函数
  3. main函数调用UIApplicationMain函数
  4. UIApplication建立时间循环并创建UIApplicationUIApplicationDelegate实例
  5. UIApplication调用其委托对象UIApplicationDelegate(BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions方法完成视图初始化并将视图显示在屏幕上

用一张图片表示或许会更直观一些:

IOS应用启动大体流程

UIApplication

上面提到了UIApplication类,并且UIApplicationMain函数的第三个参数也是一个UIApplication类的名称,那么这个UIApplication类又发挥什么样的作用呢?

这个UIApplication类其实就是整个应用的控制中心,由它负责协调应用中各对象的运行、交互。

严格来说,每个应用内有且只有一个UIApplication类(不过可以是UIApplication的子类),这个类的对象由UIApplicationMain函数进行创建,并且只会有一个(单例模式)。我们可以通过下面的方法得到它的实例对象:

UIApplication * application = [UIApplication sharedApplication];

UIApplication主要完成如下工作:

  1. 接收由事件循环送来的消息,并将消息分发到相应对象
  2. 管理视图,包括控制视图的显示、行为等
  3. 负责本地通知的创建和管理
  4. 该对象有一个Appdelegate委托对象,当一些重要的系统时间或生命周期事件(如应用程序启动、进入后台、收到低内存警告)发生时,应用程序通知该UIApplication,然后由UIApplication交由它的委托对象处理。

UIApplicationDelegate

在上面的UIApplicationMain函数的声明中,第四个参数就是一个UIApplicationDelegate类的名称,UIApplicationMain就是根据第三、四个参数提供的名称进行实例化UIApplicationUIApplicationDelegate对象并让UIApplicationDelegate成为UIApplication的委托对象。

不过要注意的一点就是

UIApplicationDelegate是一个协议而不是

UIApplicationDelegate协议定义了一坨(请原谅我的量词(⊙o⊙))方法(UIApplicationDelegate中定义的方法都是有UIApplication对象调用)用来处理在整个应用的生命周期中的一些重要的事件(比如应用程序启动,状态切换等等)。

这样,通过自己实现相应方法就可以对应用的一些重要事件进行自定义的处理(UIApplicationMainUIApplication一般来说不能够也不需要进行自定义,不过UIApplication可以自定义子类,但是一般情况下不需要)。比如实现跟应用启动有关的方法就可以自定义应用启动流程,实现与应用状态切换的方法就可以自定义应用从前台进入后台或从后台进入前台时的行为。

其中,跟应用启动有关的方法有如下一些:

/*应用开始启动但是状态还未恢复时调用此方法*/
- (BOOL)application:(UIApplication *)application willFinishLaunchingWithOptions:(NSDictionary *)

/*应用启动完成并且应用已经开始运行时调用此方法,一般在这个方法内进行应用窗口、视图的初始化*/
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions

/*当应用处于前台并且能够接收消息时调用这个方法*/
- (void)applicationDidBecomeActive:(UIApplication *)application

UIApplicationDelegate的方法调用与IOS应用的启动流程图结合来看应该会更加直观一些

应用启动与UIApplicationDelegate调用

2018-12-07 14:59:02 MeituanTech 阅读数 1896

一、背景

冷启动时长是App性能的重要指标,作为用户体验的第一道“门”,直接决定着用户对App的第一印象。美团外卖iOS客户端从2013年11月开始,历经几十个版本的迭代开发,产品形态不断完善,业务功能日趋复杂;同时外卖App也已经由原来的独立业务App演进成为一个平台App,陆续接入了闪购、跑腿等其他新业务。因此,更多更复杂的工作需要在App冷启动的时候被完成,这给App的冷启动性能带来了挑战。对此,我们团队基于业务形态的变化和外卖App的特点,对冷启动进行了持续且有针对性的优化工作,目的就是为了呈现更加流畅的用户体验。

二、冷启动定义

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

  • T1:main()函数之前,即操作系统加载App可执行文件到内存,然后执行一系列的加载&链接等工作,最后执行至App的main()函数。
  • T2:main()函数之后,即从main()开始,到appDelegate的didFinishLaunchingWithOptions方法执行完毕。

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

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

三、问题现状

性能存量问题

美团外卖iOS客户端经过几十个版本的迭代开发后,在冷启动过程中已经积累了若干性能问题,解决这些性能瓶颈是冷启动优化工作的首要目标,这些问题主要包括:

注:启动项的定义,在App启动过程中需要被完成的某项工作,我们称之为一个启动项。例如某个SDK的初始化、某个功能的预加载等。

性能增量问题

一般情况下,在App早期阶段,冷启动不会有明显的性能问题。冷启动性能问题也不是在某个版本突然出现的,而是随着版本迭代,App功能越来越复杂,启动任务越来越多,冷启动时间也一点点延长。最后当我们注意到,并想要优化它的时候,这个问题已经变得很棘手了。外卖App的性能问题增量主要来自启动项的增加,随着版本迭代,启动项任务简单粗暴地堆积在启动流程中。如果每个版本冷启动时间增加0.1s,那么几个版本下来,冷启动时长就会明显增加很多。

四、治理思路

冷启动性能问题的治理目标主要有三个:

  1. 解决存量问题:优化当前性能瓶颈点,优化启动流程,缩短冷启动时间。
  2. 管控增量问题:冷启动流程规范化,通过代码范式和文档指导后续冷启动过程代码的维护,控制时间增量。
  3. 完善监控:完善冷启动性能指标监控,收集更详细的数据,及时发现性能问题。

五、规范启动流程

截止至2017年底,美团外卖用户数已达2.5亿,而美团外卖App也已完成了从支撑单一业务的App到支持多业务的平台型App的演进(美团外卖iOS多端复用的推动、支撑与思考),公司的一些新兴业务也陆续集成到外卖App当中。下面是外卖App的架构图,外卖的架构主要分为三层,底层是基础组件层,中层是外卖平台层,平台层向下管理基础组件,向上为业务组件提供统一的适配接口,上层是基础组件层,包括外卖业务拆分的子业务组件(外卖App和美团App中的外卖频道可以复用子业务组件)和接入的其他非外卖业务。

App的平台化为业务方提供了高效、标准的统一平台,但与此同时,平台化和业务的快速迭代也给冷启动带来了问题:

  1. 现有的启动项堆积严重,拖慢启动速度。
  2. 新的启动项缺乏添加范式,杂乱无章,修改风险大,难以阅读和维护。

面对这个问题,我们首先梳理了目前启动流程中所有的启动项,然后针对App平台化设计了新的启动项管理方式:分阶段启动和启动项自注册

分阶段启动

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

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

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

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

启动项自注册

确定了启动项分阶段启动的方案后,我们面对的问题就是如何执行这些启动项。比较容易想到的方案是:在启动时创建一个启动管理器,然后读取所有启动项,然后当时间节点到来时由启动器触发启动项执行。这种方式存在两个问题:

  1. 所有启动项都要预先写到一个文件中(在.m文件import,或用.plist文件组织),这种中心化的写法会导致臃肿的代码,难以阅读维护。
  2. 启动项代码无法复用:启动项无法收敛到子业务库内部,在外卖App和美团App中要重复实现,和外卖App平台化的方向不符。

而我们希望的方式是,启动项维护方式可插拔,启动项之间、业务模块之间不耦合,且一次实现可在两端复用。下图是我们采用的启动项管理方式,我们称之为启动项的自注册:一个启动项定义在子业务模块内部,被封装成一个方法,并且自声明启动阶段(例如一个启动项A,在独立App中可以声明为在willFinishLaunch阶段被执行,在美团App中则声明在resignActive阶段被执行)。这种方式下,启动项即实现了两端复用,不相关的启动项互相隔离,添加/删除启动项都更加方便。

那么如何给一个启动项声明启动阶段?又如何在正确的时机触发启动项的执行呢?在代码上,一个启动项最终都会对应到一个函数的执行,所以在运行时只要能获取到函数的指针,就可以触发启动项。美团平台开发的组件启动治理基建Kylin正是这样做的:Kylin的核心思想就是在编译时把数据(如函数指针)写入到可执行文件的__DATA段中,运行时再从__DATA段取出数据进行相应的操作(调用函数)。

为什么要用借用__DATA段呢?原因就是为了能够覆盖所有的启动阶段,例如main()之前的阶段。

Kylin实现原理简述:Clang 提供了很多的编译器函数,它们可以完成不同的功能。其中一种就是 section() 函数,section()函数提供了二进制段的读写能力,它可以将一些编译期就可以确定的常量写入数据段。 在具体的实现中,主要分为编译期和运行时两个部分。在编译期,编译器会将标记了 attribute((section())) 的数据写到指定的数据段中,例如写一个{key(key代表不同的启动阶段), *pointer}对到数据段。到运行时,在合适的时间节点,在根据key读取出函数指针,完成函数的调用。

上述方式,可以封装成一个宏,来达到代码的简化,以调用宏 KLN_STRINGS_EXPORT(“Key”, “Value”)为例,最终会被展开为:

__attribute__((used, section("__DATA" "," "__kylin__"))) static const KLN_DATA __kylin__0 = (KLN_DATA){(KLN_DATA_HEADER){"Key", KLN_STRING, KLN_IS_ARRAY}, "Value"};

使用示例,编译器把启动项函数注册到启动阶段A:

KLN_FUNCTIONS_EXPORT(STAGE_KEY_A)() { // 在a.m文件中,通过注册宏,把启动项A声明为在STAGE_KEY_A阶段执行
    // 启动项代码A
}
KLN_FUNCTIONS_EXPORT(STAGE_KEY_A)() { // 在b.m文件中,把启动项B声明为在STAGE_KEY_A阶段执行
    // 启动项代码B
}

在启动流程中,在启动阶段STAGE_KEY_A触发所有注册到STAGE_KEY_A时间节点的启动项,通过对这种方式,几乎没有任何额外的辅助代码,我们用一种很简洁的方式完成了启动项的自注册。

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    // 其他逻辑
    [[KLNKylin sharedInstance] executeArrayForKey:STAGE_KEY_A];  // 在此触发所有注册到STAGE_KEY_A时间节点的启动项
    // 其他逻辑
    return YES;
}

完成对现有的启动项的梳理和优化后,我们也输出了后续启动项的添加&维护规范,规范后续启动项的分类原则,优先级和启动阶段。目的是管控性能问题增量,保证优化成果。

六、优化main()之前

在调用main()函数之前,基本所有的工作都是由操作系统完成的,开发者能够插手的地方不多,所以如果想要优化这段时间,就必须先了解一下,操作系统在main()之前做了什么。main()之前操作系统所做的工作就是把可执行文件(Mach-O格式)加载到内存空间,然后加载动态链接库dyld,再执行一系列动态链接操作和初始化操作的过程(加载、绑定、及初始化方法)。这方面的资料网上比较多,但重复性较高,此处附上一篇WWDC的Topic:Optimizing App Startup Time

加载过程—从exec()到main()

真正的加载过程从exec()函数开始,exec()是一个系统调用。操作系统首先为进程分配一段内存空间,然后执行如下操作:

  1. 把App对应的可执行文件加载到内存。
  2. 把Dyld加载到内存。
  3. Dyld进行动态链接。

下面我们简要分析一下Dyld在各阶段所做的事情:

阶段 工作
加载动态库 Dyld从主执行文件的header获取到需要加载的所依赖动态库列表,然后它需要找到每个 dylib,而应用所依赖的 dylib 文件可能会再依赖其他 dylib,所以所需要加载的是动态库列表一个递归依赖的集合
Rebase和Bind - Rebase在Image内部调整指针的指向。在过去,会把动态库加载到指定地址,所有指针和数据对于代码都是对的,而现在地址空间布局是随机化,所以需要在原来的地址根据随机的偏移量做一下修正
- Bind是把指针正确地指向Image外部的内容。这些指向外部的指针被符号(symbol)名称绑定,dyld需要去符号表里查找,找到symbol对应的实现
Objc setup - 注册Objc类 (class registration)
- 把category的定义插入方法列表 (category registration)
- 保证每一个selector唯一 (selector uniquing)
Initializers - Objc的+load()函数
- C++的构造函数属性函数
- 非基本类型的C++静态全局变量的创建(通常是类或结构体)

最后 dyld 会调用 main() 函数,main() 会调用 UIApplicationMain(),before main()的过程也就此完成。

了解完main()之前的加载过程后,我们可以分析出一些影响T1时间的因素:

  1. 动态库加载越多,启动越慢。
  2. ObjC类,方法越多,启动越慢。
  3. ObjC的+load越多,启动越慢。
  4. C的constructor函数越多,启动越慢。
  5. C++静态对象越多,启动越慢。

针对以上几点,我们做了如下一些优化工作:

代码瘦身

随着业务的迭代,不断有新的代码加入,同时也会废弃掉无用的代码和资源文件,但是工程中经常有无用的代码和文件被遗弃在角落里,没有及时被清理掉。这些无用的部分一方面增大了App的包体积,另一方便也拖慢了App的冷启动速度,所以及时清理掉这些无用的代码和资源十分有必要。

通过对Mach-O文件的了解,可以知道__TEXT:__objc_methname:中包含了代码中的所有方法,而__DATA__objc_selrefs中则包含了所有被使用的方法的引用,通过取两个集合的差集就可以得到所有未被使用的代码。核心方法如下,具体可以参考:objc_cover:

def referenced_selectors(path):
    re_sel = re.compile("__TEXT:__objc_methname:(.+)") //获取所有方法
    refs = set()
    lines = os.popen("/usr/bin/otool -v -s __DATA __objc_selrefs %s" % path).readlines() # ios & mac //真正被使用的方法
    for line in lines:
        results = re_sel.findall(line)
        if results:
            refs.add(results[0])
    return refs
}

通过这种方法,我们排查了十几个无用类和250+无用的方法。

+load优化

目前iOS App中或多或少的都会写一些+load方法,用于在App启动执行一些操作,+load方法在Initializers阶段被执行,但过多+load方法则会拖慢启动速度,对于大中型的App更是如此。通过对App中+load的方法分析,发现很多代码虽然需要在App启动时较早的时机进行初始化,但并不需要在+load这样非常靠前的位置,完全是可以延迟到App冷启动后的某个时间节点,例如一些路由操作。其实+load也可以被当做一种启动项来处理,所以在替换+load方法的具体实现上,我们仍然采用了上面的Kylin方式。

使用示例:

// 用WMAPP_BUSINESS_INIT_AFTER_HOMELOADING声明替换+load声明即可,不需其他改动
WMAPP_BUSINESS_INIT_AFTER_HOMELOADING() { 
    // 原+load方法中的代码
}
// 在某个合适的时机触发注册到该阶段的所有方法,如冷启动结束后
[[KLNKylin sharedInstance] executeArrayForKey:@kWMAPP_BUSINESS_INITIALIZATION_AFTER_HOMELOADING_KEY] 
}

七、优化耗时操作

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

Time Profiler

Time Profiler是Xcode自带的时间性能分析工具,它按照固定的时间间隔来跟踪每一个线程的堆栈信息,通过统计比较时间间隔之间的堆栈状态,来推算某个方法执行了多久,并获得一个近似值。Time Profiler的使用方法网上有很多使用教程,这里我们也不过多介绍,附上一篇使用文档:Instruments Tutorial with Swift: Getting Started

火焰图

除了Time Profiler,火焰图也是一个分析CPU耗时的利器,相比于Time Profiler,火焰图更加清晰。火焰图分析的产物是一张调用栈耗时图片,之所以称为火焰图,是因为整个图形看起来就像一团跳动的火焰,火焰尖部是调用栈的栈顶,底部是栈底,纵向表示调用栈的深度,横向表示消耗的时间。一个格子的宽度越大,越说明其可能是瓶颈。分析火焰图主要就是看那些比较宽大的火苗,特别留意那些类似“平顶山”的火苗。下面是美团平台开发的性能分析工具-Caesium的分析效果图:

通过对火焰图的分析,我们发现了冷启动过程中存在着不少问题,并成功优化了0.3S+的时间。优化内容总结如下:

优化点 举例
发现隐晦的耗时操作 发现在冷启动过程中archive了一张图片,非常耗时
推迟&减少I/O操作 减少动画图片组的数量,替换大图资源等。因为相比于内存操作,硬盘I/O是非常耗时的操作
推迟执行的一些任务 如一些资源的I/O,一些布局逻辑,对象的创建时机等

八、优化串行操作

在冷启动过程中,有很多操作是串行执行的,若干个任务串行执行,时间必然比较长。如果能变串行为并行,那么冷启动时间就能够大大缩短。

闪屏页的使用

现在许多App在启动时并不直接进入首页,而是会向用户展示一个持续一小段时间的闪屏页,如果使用恰当,这个闪屏页就能帮我们节省一些启动时间。因为当一个App比较复杂的时候,启动时首次构建App的UI就是一个比较耗时的过程,假定这个时间是0.2秒,如果我们是先构建首页UI,然后再在Window上加上这个闪屏页,那么冷启动时,App就会实实在在地卡住0.2秒,但是如果我们是先把闪屏页作为App的RootViewController,那么这个构建过程就会很快。因为闪屏页只有一个简单的ImageView,而这个ImageView则会向用户展示一小段时间,这时我们就可以利用这一段时间来构建首页UI了,一举两得。

缓存定位&首页预请求

美团外卖App冷启动过程中一个重要的串行流程就是:首页定位–>首页请求–>首页渲染过程,这三个操作占了整个首页加载时间的77%左右,所以想要缩短冷启动时间,就一定要从这三点出发进行优化。

之前串行操作流程如下:

优化后的设计,在发起定位的同时,使用客户端缓存定位,进行首页数据的预请求,使定位和请求并行进行。然后当用户真实定位成功后,判断真实定位是否命中缓存定位,如果命中,则刚才的预请求数据有效,这样可以节省大概40%的时间首页加载时间,效果非常明显;如果未命中,则弃用预请求数据,重新请求。

九、数据监控

Time Profiler和Caesium火焰图都只能在线下分析App在单台设备中的耗时操作,局限性比较大,无法在线上监控App在用户设备上的表现。外卖App使用公司内部自研的Metrics性能监控系统,长期监控App的性能指标,帮助我们掌握App在线上各种环境下的真实表现,并为技术优化项目提供可靠的数据支持。Metrics监控的核心指标之一,就是冷启动时间。

冷启动开始&结束时间节点

  1. 结束时间点:结束时间比较好确定,我们可以将首页某些视图元素的展示作为首页加载完成的标志。
  2. 开始时间点:一般情况下,我们都是在main()之后才开始接管App,但以main()函数作为冷启动起始点显然不合适,因为这样无法统计到T1时间段。那么,起始时间如何确定呢?目前业界常见的有两种方法,一是以可执行文件中任意一个类的+load方法的执行时间作为起始点;二是分析dylib的依赖关系,找到叶子节点的dylib,然后以其中某个类的+load方法的执行时间作为起始点。根据Dyld对dylib的加载顺序,后者的时机更早。但是这两种方法获取的起始点都只在Initializers阶段,而Initializers之前的时长都没有被计入。Metrics则另辟蹊径,以App的进程创建时间(即exec函数执行时间)作为冷启动的起始时间。因为系统允许我们通过sysctl函数获得进程的有关信息,其中就包括进程创建的时间戳。
#import <sys/sysctl.h>
#import <mach/mach.h>

+ (BOOL)processInfoForPID:(int)pid procInfo:(struct kinfo_proc*)procInfo
{
    int cmd[4] = {CTL_KERN, KERN_PROC, KERN_PROC_PID, pid};
    size_t size = sizeof(*procInfo);
    return sysctl(cmd, sizeof(cmd)/sizeof(*cmd), procInfo, &size, NULL, 0) == 0;
}

+ (NSTimeInterval)processStartTime
{
    struct kinfo_proc kProcInfo;
    if ([self processInfoForPID:[[NSProcessInfo processInfo] processIdentifier] procInfo:&kProcInfo]) {
        return kProcInfo.kp_proc.p_un.__p_starttime.tv_sec * 1000.0 + kProcInfo.kp_proc.p_un.__p_starttime.tv_usec / 1000.0;
    } else {
        NSAssert(NO, @"无法取得进程的信息");
        return 0;
    }
}

进程创建的时机非常早。经过实验,在一个新建的空白App中,进程创建时间比叶子节点dylib中的+load方法执行时间早12ms,比main函数的执行时间早13ms(实验设备:iPhone 7 Plus (iOS 12.0)、Xcode 10.0、Release 模式)。外卖App线上的数据则更加明显,同样的机型(iPhone 7 Plus)和系统版本(iOS 12.0),进程创建时间比叶子节点dylib中的+load方法执行时间早688ms。而在全部机型和系统版本中,这一数据则是878ms。

冷启动过程时间节点

我们也在App冷启动过程中的所有关键节点打上一连串测速点,Metrics会记录下测速点的名称,及其距离进程创建时间的时长。我们没有采用自动打点的方式,是因为外卖App的冷启动过程十分复杂,而自动打点无法做到如此细致,并不实用。另外,Metrics记录的是时间轴上以进程创建时间为原点的一组顺序的时间点,而不是一组时间段,是因为顺序的时间点可以计算任意两个时间点之间的距离,即可以将时间点处理成时间段。但是,一组时间段可能无法还原为顺序的时间点,因为时间段之间可能并不是首尾相接的,特别是对于异步执行或者多线程的情况。

在测速完毕后,Metrics会统一将所有测速点上报到后台。下图是美团外卖App 6.10版本的部分过程节点监控数据截图:

Metrics还会由后台对数据做聚合计算,得到冷启动总时长和各个测速点时长的50分位数、90分位数和95分位数的统计数据,这样我们就能从宏观上对冷启动时长分布情况有所了解。下图中横轴为时长,纵轴为上报的样本数。

十、总结

对于快速迭代的App,随着业务复杂度的增加,冷启动时长会不可避免的增加。冷启动流程也是一个比较复杂的过程,当遇到冷启动性能瓶颈时,我们可以根据App自身的特点,配合工具的使用,从多方面、多角度进行优化。同时,优化冷启动存量问题只是冷启动治理的第一步,因为冷启动性能问题并不是一日造成的,也不能简单的通过一次优化工作就能解决,我们需要通过合理的设计、规范的约束,来有效地管控性能问题的增量,并通过持续的线上监控来及时发现并修正性能问题,这样才能够长期保证良好的App冷启动体验。

作者简介

郭赛,美团点评资深工程师。2015年加入美团,目前作为外卖iOS团队主力开发,负责移动端业务开发,业务类基础设施的建设与维护。

徐宏,美团点评资深工程师。2016年加入美团,目前作为外卖iOS团队主力开发,负责移动端APM性能监控,高可用基础设施支撑相关推进工作。

招聘

美团外卖长期招聘Android、iOS、FE高级/资深工程师和技术专家,Base北京、上海、成都,欢迎有兴趣的同学投递简历到chenhang03@meituan.com。

2020-03-05 10:11:00 lizhaobomb 阅读数 34

APP的启动可以分为2种:

  • 冷启动(Cold Launch):从零开始启动APP

  • 热启动 (Warm Launch):APP已经在内存中,在后台存活着,再次点击图标启动APP

APP启动时间的优化

那我们通常所说的启动时间优化都是再说的冷启动的时间优化,其实Xcode是提供给我们一种分析启动时间的方式,我们接下来试一试

  • 通过添加环境变量可以打印出APP的启动时间分析(Edit Scheme -> Run -> Arguments)

    • DYLD_PRINT_STATISTICS设置为1

    我们可以看到控制台打印出了

     Total pre-main time: 238.11 milliseconds (100.0%)
         dylib loading time: 173.78 milliseconds (72.9%)
        rebase/binding time: 126687488.9 seconds (15889931.7%)
            ObjC setup time:  15.16 milliseconds (6.3%)
           initializer time:  62.59 milliseconds (26.2%)
           slowest intializers :
             libSystem.B.dylib :   6.66 milliseconds (2.7%)
    libBacktraceRecording.dylib :   7.30 milliseconds (3.0%)
    libMainThreadChecker.dylib :  40.12 milliseconds (16.8%)
    
    • 如果需要更详细的信息,那就将DYLD_PRINT_STATISTICS_DETAILS设置为1

    我们看到这次输出的信息比上次要详细很多

    total time: 981.15 milliseconds (100.0%)
    total images loaded:  334 (327 from dyld shared cache)
    total segments mapped: 21, into 370 pages
    total images loading time: 664.10 milliseconds (67.6%)
    total load time in ObjC:  20.03 milliseconds (2.0%)
    total debugger pause time: 402.89 milliseconds (41.0%)
    total dtrace DOF registration time:   0.22 milliseconds (0.0%)
    total rebase fixups:  17,951
    total rebase fixups time:   2.27 milliseconds (0.2%)
    total binding fixups: 460,826
    total binding fixups time: 229.13 milliseconds (23.3%)
    total weak binding fixups time:   0.03 milliseconds (0.0%)
    total redo shared cached bindings time: 254.48 milliseconds (25.9%)
    total bindings lazily fixed up: 0 of 0
    total time in initializers and ObjC +load:  65.34 milliseconds (6.6%)
                         libSystem.B.dylib :   9.14 milliseconds (0.9%)
               libBacktraceRecording.dylib :   7.73 milliseconds (0.7%)
                           libobjc.A.dylib :   1.55 milliseconds (0.1%)
                            CoreFoundation :   2.24 milliseconds (0.2%)
                libMainThreadChecker.dylib :  38.86 milliseconds (3.9%)
                    libLLVMContainer.dylib :   2.13 milliseconds (0.2%)
    

total symbol trie searches: 1116978
total symbol table binary searches: 0
total images defining weak symbols: 37
total images using weak symbols: 92
```

这些数据可能对大家来说不太直观,这其实只是提供给大家一个参考,一般我个人认为total time在
400~500ms之间就相对来说是比较正常的,如果时间太长就可能要进行相应的优化了,接下来我们来看看冷启动的大概分哪几个阶段

APP冷启动阶段可以概括为3大阶段

  • dyld
  • runtime
  • main

One More Thing

喜欢的朋友可以扫描关注我的公众号(您的支持是我写作的最大动力)

iOS_DevTips

iOS启动优化

阅读数 1665

没有更多推荐了,返回首页