一个 App 的启动时间对于用户的使用体验有一定影响,如果我们的启动时间超过 20s 那么我们的 App 将会被系统 kill 掉,不是出现什么 bug 的话,一般不会达到这个阈值的。不过我们希望用户在第一时间或者是尽快能看到我们 App 的内容,那么优化启动的时间就很有必要了,App 启动的时间分为两部分:

  • pre-main 时间

  • main() 函数之后的时间

一些概念的介绍

App 的启动过程:解析 Info.plist 文件,Mach-O 加载,调用 main()、UIApplicationMain()、applicationWillFinishLaunching 函数

  • 热启动:在用户按 Home 健,App 切换到后台之后,用户再点击 App 打开。我们认为是一次热启动。对应在 AppDelegate 里的方法是applicationWillEnterForeground

  • 冷启动:App 没有在后台运行或者 App 被 kill 掉之后,用户再点击 App 打开,我们认为是一次冷启动,对应在 AppDelegate 里的方法是 application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: \[UIApplicationLaunchOptionsKey: Any\]?) -> Bool

我们主要关注的是 App 的冷启动时间。
在项目中我们会引用很多库,库就是已经编译好的二进制代码,加上头文件之后我们就可以使用。从库的链接方式不同可以分为两种库:

  • 动态库:在 Mac 下的 .dylib 文件(现在好像已经改成 .tbd 文件了),动态库在程序编译阶段不会被拷贝到程序中,只会存放指向动态库的引用,等到程序运行时,才会加载进来。

  • 静态库:在 Mac 下的 .a 文件,静态库就是在编译阶段直接拷贝一份到目标程序中。

从上面的概念来看,我们可以了解到他们各自一些优缺点。动态库的优势可以共享库减少包的大小。缺点就是在运行时需要额外花费一些时间加载我们所用到的符号文件。静态库的优势在于因为在编译阶段已经将二进制代码拷贝到程序中,在运行时不需要额外的加载时间;缺点就是打出来的包可能会比较大。

如何检测 main() 方法之前的加载时间

官方提供的方法是在 Edit Scheme -> Arguments -> Environment Variables 中添加 DYLD_PRINT_STATISTICS 为 1,在 App 启动的时候在 console 中就会打印以下信息:

1
2
3
4
5
6
7
Total pre-main time: 673.18 milliseconds (100.0%)
dylib loading time: 575.76 milliseconds (85.5%)
rebase/binding time: 26.91 milliseconds (3.9%)
ObjC setup time: 31.83 milliseconds (4.7%)
initializer time: 38.57 milliseconds (5.7%)
slowest intializers :
libSystem.B.dylib : 7.17 milliseconds (1.0%)

其中就包含了动态链接库的流程,从数据来看 dylib loading time 占时间的绝大部分。(这部分是在优化之后的数据),在一个 App 启动的过程系统需要加载 100 到 400 多个动态库,这部分系统的动态库官方做了大量的优化,所以对于一些系统级别的动态库耗时不多。

其实还有一种方法来获取 main() 方法之前的加载时间,就是通过 instruments 工具中的 Time Profiler 来检测这个时间。打开 Time Profiler 选择 Narrative 就可以看到以下信息(iphone6s iOS11.3 设备上检测的结果)

1
2
3
Initializing application's address space and dynamic linking required frameworks took 703.22 ms.
Application took 95.46 ms between when UIApplicationMain() or NSApplicationMain() started and when the did-finish-launching notifications finished.
Currently running in the foreground...

信息告诉我们初始化 App 地址和动态链接 framework 用了 703.22 ms,App 在 UIApplicationMain() 开始到 did-finish-launching 用了 95.46 ms

通过这些统计的信息我们需要了解一下在 App 启动的过程中发生了什么?

  1. load dylibs

  2. rebase

  3. bind

  4. objc setup

  5. initalizers

大致上在 App 启动解析完 Info.plist 之后做了以上五步,大部分对应上了 console 打印的信息。我们就不多深究内部的机制, 这几步大概的过程是这样的。解析依赖的动态库,找到对应的 mach-o 文件,验证文件并注册签名,调用每个片段的 mmap() 函数,然后递归加载所有的依赖库,将加载进来的 mach-o 文件内部的指针重定位,地址绑定符号。然后注册 Objc 类,将各种 categories 加入各自的 method lists,并完成各种初始化。

所以我们可以了解到的优化方案:

  1. 尽量减少动态库的数量

  2. 移除多余的 Objc 类

  3. 在类的 load 方法中尽可能减少耗时操作、延迟这些操作

  4. 在 swift 项目中尽可能使用 struct (为了减少符号量)

这部分遇到的瓶颈是由于我们项目依赖比较多的第三方 framework(暂时还没有替代方案) 导致我们在链接静态库的耗时比较大,在优化的过程中,删除多余的 framework 和一些系统的 dyld。

ps:由于 swift 目前还不支持静态库,所以我们在使用 cocoapods 的时候往往会添加 use_frameworks! ,之后 cocoapods 的库将被认为是动态库。

main() 方法之后加载时间检测

main() 方法之后的加载时间检测主要是关于 application(didFinishLaunchingWithOptions) 这个方法调用结束到 App 首页渲染完毕的时间。所以我们比较容易想到的优化方案:

  1. 一些第三方的 SDK 的初始化方法延迟或者异步化

  2. 首页视图使用纯代码,而不是使用 storyboard 或者是 xib 来实现

该如何检测这部分时间,在 swift 中我们需要做一点额外的工作,因为在 swift 中不像 OC 的项目含有一个 main.m 的文件,取而代之的是 @UIAppliactionMain 这个修饰,在 AppDelegate 之前添加了这个修饰,编译器会自动帮助我们生成好 main.swift 这个文件,这个文件的是如下内容

1
2
3
4
5
6
7
8
9
10
11
var LaunchAppDuration = Date().timeIntervalSince1970

UIApplicationMain(
CommandLine.argc,
UnsafeMutableRawPointer(CommandLine.unsafeArgv)
.bindMemory(
to: UnsafeMutablePointer<Int8>.self,
capacity: Int(CommandLine.argc)),
NSStringFromClass(MyApplication.self),
NSStringFromClass(AppDelegate.self)
)

实例化一个 UIApplicationMain 对象,删掉 @UIApplicationMain 系统自动读取这个文件,并加载我们的 App。

我们需要在这个文件中加入一个变量 LaunchAppDuration 来记录加载 main.swift 文件的时间戳。然后在首页的 viewDidAppear 方法中再获取一次时间戳计算时间间隔。

1
2
3
4
5
6
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)

let now = Date().timeIntervalSince1970
print("\(now) - \(LaunchAppDuration) = LaunchAppDuration = \(now - LaunchAppDuration)")
}

分别在 HomeTableViewController 和 HomeViewController 两个视图控制器的 viewDidAppear 中获取时间:

1
HomeViewController LaunchAppDuration = 1.42967915534973

这里的 1.4s 的意思是启动到用户看见首页流需要 1.4s。这部分主要的耗时我猜测是:

  1. 之前将第三方库延迟初始化(延迟到 HomeViewController 的 ViewDidLoad 中)导致(大概有 0.5s 左右)

  2. 我们目前的 HomeTabViewController 是通过 storyboard 加载的

至于第一点延迟加载确实加快了 App 的启动,但是发现这里需要一秒多乃至两秒,对用户的体验不是很好,所以同时也可以考虑从产品的角度来改善体验,比如:在闪屏之后加入广告图或者其他之类图片,同时进行初始化一部分第三方 SDK,这样不至于让用户等待。更好的是类似 Facebook 等 App 在载入首页的时候增加占位视图。

总结

App 启动时间 = pre-main 加载的时间 + main() 之后到 did-finish-launch 执行完的时间。从这次优化的过程来看,延迟一些第三方 SDK 初始化的效果比较明显,目前还是比较暴力的将初始化延迟到 HomeViewController 的 viewDidLoad 方法中,更好的方案是某些 SDK 可能实现按需初始化,等到真正要使用之前再初始化,这样比较合理。在 pre-main 的优化由于业务的需求大量的 framework 不能移除,并切暂时没有找到合并的方案。但是在 main() 函数之后还是有些地方可以继续优化的:纯代码编写 HomeTabViewController,懒惰初始化 SDK

参考

Optimizing App Startup Time

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