项目启动时间优化

App的启动时间是影响用户体验的一大因素,整理了一些关于App启动时间优化的方法,并结合我们自己的项目实践,记录在这里。

App启动过程

首先需要知道当我们点击手机屏幕上App的图标,到App的首页呈现在面前,系统经历了怎样的过程。这里以main函数为分界,将启动过程分成main函数前和main函数后两个阶段,,这两部分的时间分别用T1和T2来表示。

T1所包含的时间

####加载可执行文件(.o文件)####

Mach-OOSX系统的可执行文件,它的文件格式如下

mach-o

它包含三分的内容

  • Header,指明了目标架构、文件类型、Load Commands个数等基本信息
  • Load Commands, 描述文件在虚拟内存中的逻辑与布局。在Mach-O文件中可以有多个Segment, 每个Segment可能包含一个或多个Section。
  • Data,Segment的具体数据,包含了代码和数据等

想了解关于Mach-O更加详细内容的,可以参考下面两篇文章

系统加载完可执行文件后,通过分析文件来获得dyld所在路径来加载dyld (the dynamic link editor 动态链接器)。接下来就由Dyld进行动态链接了。

Dyld的整个流程如下:

mach-o

Load dylibs

从主执行文件的 header 获取到需要加载的所依赖动态库列表,而 header 早就被内核映射过。然后它需要找到每个 dylib,然后打开文件读取文件起始位置,确保它是 Mach-O 文件。接着会找到代码签名并将其注册到内核。然后在 dylib 文件的每个 segment 上调用 mmap()。应用所依赖的 dylib 文件可能会再依赖其他 dylib,所以 dyld 所需要加载的是动态库列表一个递归依赖的集合。一般应用会加载 100 到 400 个 dylib 文件,但大部分都是系统 dylib,它们会被预先计算和缓存起来,加载速度很快。

Fix-ups

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

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

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

Rebasing&Binding

Rebasing

在过去,dylib会被加载到指定地址,所有指针和数据对于代码来说都是正确的,如今由于ASLR的存在,可执行文件和动态链接库在内存中的加载地址是不固定的,dylib会被加载到新的随机地址上(actual_address),这个随机的地址跟代码和数据指向的旧地址(preferred_address)会有偏差,dyld需要修正这个偏差(slide),做法就是将dylib内部的指针地址都加上这个偏移量,偏移量的计算方法如下:

Slide = actual_address - preferred_address

  • ASLR (Address Space Layout Randomization): 地址空间布局随机化,镜像会在随机的地址上加载。

    传统方式下,进程每次启动采用的都是固定可遇见的方式,这意味着一个给定的程序在给定的架构上的进程初始虚拟内存都是基本一致的,而且在进程正常运行的生命周期中,内存中的地址分布具有很强的可预测性,给了黑客可乘之机。

    如果采用ASLR,进程每次启动、地址都会被简单地随机化,但是只是偏移,不是搅乱。大体布局—程序文本、数据和库是一样的,但是具体的地址都不同了,可以阻挡黑客对地址的猜测。

Binding

将指针指向镜像外部的内容,binding就是将这个二进制调用的外部符号进行绑定的过程。比如我们objc代码中需要使用到NSObject, 即符号_OBJC_CLASS_$_NSObject,但是这个符号又不在我们的二进制中,在系统库 Foundation.framework 中,因此就需要binding这个操作将对应关系绑定到一起;

Binding处理那些只指向dylib外部的指针,他们实际上被符号(symbol)名称绑定。dyld需要找到symbol对应的实现。这需要很多计算,去符号表里查找。找到后会将内容存储到 __DATA 段中的那个指针中。Binding 看起来计算量比 Rebasing 更大,但其实需要的 I/O 操作很少,Binding的时间主要是耗费在计算上,因为IO操作之前 Rebasing 已经替 Binding 做过了,所以这两个步骤的耗时是混在一起的。

####Objc Setup####

这一步的主要工作是:

  1. 读取二进制文件的DATA段内容,找到与objc相关信息。
  2. 注册Objc类 (class registration),OC runtime 需要维护一张映射类名与类的全局表。当加载一个dylib时,其定义的所有的类都需要被注册到这个全局表中。
  3. 把category的定义插入方法列表 (category registration)
  4. 确保每一个selector唯一(selector uniquing)

####Initializers####

以上三步属于静态调整, 都是在修改__DATA segment中的内容,而这一步开始则是动态调整,开始在堆栈中写入内容。

  1. Objc的+load()函数
  2. C++的构造函数属性函数
  3. 最后 dyld 会调用 main() 函数。main() 会调用 UIApplicationMain()。

如果程序刚刚被运行过,那么程序的代码会被dyld缓存,因此即使杀掉进程再次重启加载时间也会相对快一点,如果长时间没有启动或者当前dyld的缓存已经被其他应用占据,那么这次启动所花费的时间就要长一点,这就分别是热启动冷启动的概念

T2所包含的时间

  1. didFinishLauchWithOptions方法
  2. 第一个页面渲染的时间

启动时间测量

苹果官方建议我们要在最低支持机型上测量启动时间,我们的项目支持iPhone5及以上机型安装。但是公司只有5s的测试机,所以我暂且拿5s来进行测量。并且在这里冷启动才是我们要重点关注的,所以要先把手机重启来测量app的冷启动时间。

这里我们也要分为T1和T2来分别测量,对于T1,我们只需在Xcode->Project->Scheme->Edit Scheme 找到 Run -> Enviroment Variables, 添加并勾选DYLD_PRINTT_STATISTICS环境变量就可以测量了。

对于T2部分,我们使用在main()didFinishLaunchingWithOptions以及一个页面的viewDidAppear中进行记录,从而来计算T2的时间。

#define TICK NSDate *startTime = [NSDate date]

define TOCK NSLog(@"------------Time Cost: %f\n[文件名:%s]\n""[函数名:%s]\n""[行号:%d]\n-----------", [startTime timeIntervalSinceNow], __FILE__, __FUNCTION__, __LINE__)

这里定义了两个宏来记录每个函数的耗时, 在每个要统计的函数头尾分别加上这两个宏,就可以计算出函数耗时。

运行之后,可以看到pre-main的耗时是437.28ms,也就是T1,和苹果建议的400ms很接近了,不过还有优化空间。

didFinishLaunchingWithOptions的耗时为4.475018秒。

项目中的优化实践

T1的优化

其实App启动过程中每一个步骤都会影响启动性能,但是有些部分所消耗的时间几乎可以忽略不计,有些部分根本无法避免,考虑到投入产出比,这里只列出我们项目中有价值优化的部分

影响T1的因素

  • 动态库加载越多,启动越慢
  • Objc类越多,启动越慢
  • C的contructor函数越多,启动越慢
  • C++的静态对象越多,启动越慢
  • Objc的+load越多,启动越慢

在整个动态链接的过程中,我们能做的事其实不多,WWDC上提到了以下几点

mach-o

  1. Load dylibs

    对于这一步,苹果的建议是减少dylibs的使用,具体分为三点

    • 合并已经存在的dylibs
    • 减少非系统库的依赖
    • 如果可以的话,把动态库改造为静态库
  2. Rebase/Bind

    • 减少Objc类数量,减少selector和category数量
    • 减少C++虚函数数量
    • 使用swiif stuct(减少符号数量)

    随着项目的不断迭代,很多模块和方法已经被废弃但是却一直留存在项目中,导致项目越来越臃肿。在这里,我使用了CATClearProjectTool来查找项目中没有被用到的文件,在核实确认之后进行删除操作,删掉了近百个无用文件。

  3. Objc setup

    针对这一步能做的不多,暂时先不管。

  4. Initializers

    将不必须在+load方法中做的事情延迟到+initialize

    这是因为+load方法是在app启动的时候就被调用,而+initialize方法则是在Class第一次使用的时候才调用,相当于是懒加载了。可以把+load中的代码移到initialize中,并结合dispatch_once来防止重复调用。

    不过我们项目中只有在使用method swizzling的时候会在+load中调用方法。所以这一点也没什么好优化的。

可以看到优化后,启动时间缩短为359.94ms, 已经达到了苹果的推荐值。

###对于T2的优化###

影响T2的因素有以下两个

  • 执行didFinishLauchingWithOptions的耗时
  • rootViewController及其childViewController的加载、view及其subviews的加载

####优化didFinishLauchingWithOptions

对于didFinishLauchingWithOptions这部分,经过测算发现在这个函数中比较耗时的有两个操作,这里把具体函数名隐去了

didFinishLauchingWithOptions这个方法中,主要处理的就是一些三方的初始化已经项目的基本配置。

  • 日志、统计、监控等必须在一开始就初始化的
  • 项目配置、推送、客服等
  • 其他SDK和配置
  • 设置根控制器

把一部分初始化和项目配置延迟加载

通过在首页加载完成之后发出通知,在didFinishLaunchingWithOptions里收到通知后,开辟异步线程执行以下内容

  • 检测强制更新
  • 初始化客服
  • 初始化社交分享
  • 初始化阿里百川
  • 初始化支付服务
  • 初始化键盘管理
  • 获取服务城市列表

项目配置相关的多个网络请求合并为一个请求

通过上述方案,didFinishLaunchingWithOptions的执行时间缩短为1.7s左右

优化rootViewController加载

我们项目中的UI总体架构如下图所示

然后在AppDelegate中进行rootViewController的设置

1
2
3
4
5
6
7
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
self.window.backgroundColor = [UIColor whiteColor];
TGMainViewController *root = [[TGMainViewController alloc] init];;
self.window.rootViewController = root;
[self.window makeKeyAndVisible];
}

然后在TGMainViewControllerviewDidLoad中进行它的viewControllers设置,然后再进入到具体的每个viewController的viewDidLoad方法中进行各自的初始化操作。按照常规思路来讲,整个UI初始化的过程应该是

这边的TGHomeViewController是tabBarController的viewControllers的其中一个。但实际上,这样的前提是我们不在TGMainViewController中去操作TGHomeViewController的view,如果操作了的话,调用顺序会变为

因为一般我们都把界面的初始化、网络请求、数据解析、视图渲染等操作放在 viewDidLoad 方法里,这样一来每次启动 APP 的时候,在用户看到首页之前,需要把这些事件全部都处理完,才会进入到视图渲染阶段。这大大延长了T2。

为了解决这个问题,我们把App的闪屏页作为了App的rootViewController,在展示闪屏页的同时进行首页UI的构建。

总结

经过优化,App的启动时间由4.9秒缩短到2.06秒。整个流程下来后,得到的不光是app启动速度的提升,也让自己对app启动的原理有了更加深刻的理解。苹果的WWDC Session 真的是非常好的学习资料,没有谁能比得上苹果自家工程师对iOS的了解,以后要好好利用起来。

参考资料

WWDC 2016 Optimizing App Startup Time

今日头条iOS客户端启动速度优化

iOS启动时间优化