背景
之前有收到用户反馈 App 的启动时间较长,在和市面上大部分 App 启动时间相比后,确实发现 App 启动较慢,于是开始分析项目中导致启动时间变长的原因,并对启动时间进行优化。
现状分析
一般而言,启动时间指用户从点击 App 那一刻开始到看到 App 第一个页面之间消耗的时间。
苹果将启动时间分为两部分:pre-main 的时间和 main() 之后的时间(当然还有我们人为加上去的闪屏显示时间)。
pre-main时间:即调用 main() 函数之前的加载时间,在这段时间里系统会进行加载动态库、注册 Objc 类等系统操作。
main() 之后的时间:即从调用 main( ) 函数到看到第一个页面之间的时间(从 main 函数开始到第一个页面的 - viewDidAppear 被调用)。
统计结果
以下为各个机型启动时间的统计结果,由于冷启动的启动时间受系统影响波动较大,启动时间均测试5次以上取平均值,统计时间均不包含闪屏广告页的时间(单位为秒):
iPhone 8 Plus | iPhone 6s Plus | iPhone SE | |
---|---|---|---|
pre-main 时间 | 0.879 | 0.869 | 0.958 |
main之后的时间 | 1.622 | 1.762 | 1.885 |
总时间 | 2.501 | 2.631 | 2.843 |
以下为main之后时间的统计结果:
Launch时间:main 开始到 didFinishLaunchingWithOptions 结束的时间
首页渲染时间:didFinishLaunchingWithOptions 结束到 RootViewController 的 - viewDidAppear 被调用的时间
iPhone 8 Plus | iPhone 6s Plus | iPhone SE | |
---|---|---|---|
Launch时间 | 0.701 | 0.740 | 0.849 |
首页渲染时间 | 0.921 | 1.022 | 1.036 |
总时间 | 1.622 | 1.762 | 1.885 |
经过统计 pre-main 的时间基本稳定在 0.8s-0.9s 左右,Launch 时间稳定在 0.8s 左右,首页渲染时间稳定在1s左右,均存在优化空间。
优化方案
pre-main 阶段优化
以下为 iPhone 6s Plus 正常启动消耗的pre-main时间(苹果提供了内建的测量方法,在 Xcode 中 Edit scheme -> Run -> Auguments 将环境变量 DYLD_PRINT_STATISTICS 设为 1
):
Total pre-main time: 866.86 milliseconds (100.0%)
dylib loading time: 328.28 milliseconds (37.8%)
rebase/binding time: 49.19 milliseconds (5.6%)
ObjC setup time: 62.85 milliseconds (7.2%)
initializer time: 426.38 milliseconds (49.1%)
slowest intializers :
libSystem.B.dylib : 7.52 milliseconds (0.8%)
libMainThreadChecker.dylib : 37.19 milliseconds (4.2%)
libglInterpose.dylib : 61.17 milliseconds (7.0%)
libMTLInterpose.dylib : 22.23 milliseconds (2.5%)
MyMoney : 392.50 milliseconds (45.2%)
pre-main时间主要由 4 部分组成:
-
dylib loading:
这一阶段 dyld 会分析应用依赖的 dylib ,所以,依赖的 dylib 越少越好。在这一步,我们能做的优化就是检查是否存在不需要的 dylib ,移除不必要的 dylib 。
在项目优化实践中,我们移除了一个没有必要的动态库,并将几个动态库合成为一个动态库,减少动态库数量。
-
rebase/binding:
这一阶段系统主要注册 Objc 类。所以,指针数量越少越好。这一步能做的优化有:
清理项目中无用的类
删减没有被调用到或者已经废弃的方法
删减一些无用的静态变量
可通过 AppCode 等工具扫描项目中未使用的代码。
-
Objc srtup:
这一阶段没有什么特别能优化的地方,如果 rebase/binding 阶段优化的好这步耗时也会很少。
-
initializer:
这一阶段,dyld 开始运行程序的初始化函数,调用每个 Objc 类和分类的 +load 方法,调用 C/C++ 中的构造器函数。initializer阶段执行完后,dyld 开始调用 main() 函数。在这一步,检查 +load 方法,尽量把事情推迟到 +initiailize 方法里执行。
在这里我们修改了部分原本代码中直接在 +load 函数初始化逻辑改为在 +initialize 中加载,也就是到使用时才加载。
main()函数之后的优化
didFinishLaunchingWithOptions 优化
思路:目前 App 的 didFinishLaunchingWithOptions 方法里执行了几十项业务,有一大部分业务并不是一定要在这里执行的,如支付配置、客服配置、分享配置等。整理该方法里的业务,能延迟加载的就往后推迟,防止其影响启动时间。
通过打点计时器统计各个业务的耗时时间(iPhone 6s Plus):
整理 didFinishLaunchingWithOptions ,将业务分级,对于非必须的业务移到首页显示后加载。同时,为了防止以后新加的业务继续往 didFinishLaunchingWithOptions 里扔,可以新建一个类负责启动事件,新加的业务可以往这边添加。
首页渲染优化
- 减少启动期间创建的 UIViewController 数量
通过打符号断点-[UIViewController viewDidLoad]
发现 App 启动过程中创建了 12 个 UIViewController(包括闪屏),即在启动过程中创建了 12 个视图控制器,导致首页渲染时间较长。
- 延迟首页耗时操作
App 首页有个侧滑页面及侧滑手势,并且该页面是用 xib 构建的,将该 ViewController 改为代码构建,同时延迟该页面的创建时机,等首页显示后再创建该页面及侧滑手势,这个改动节省了 300-400ms。
- 去除启动时没必要及不合理的操作
项目中使用了自定义的侧滑返回,在每次 push 的时候都会截图,启动的时候自定义导航栏会截取两张多余首页的图片,并且截图用的 API (renderInContext) 性能较差,耗时 800ms 左右,去掉启动截图的操作。
闪屏请求回调里写plist文件的操作放在主线程,导致启动时占用主线程,将文件读写移到子线程操作。
闪屏优化
闪屏在启动的时候也占据了很长的时间,合理的闪屏显示逻辑同样能大大的减少用户的等待时间。
闪屏显示通常都是有一个倒计时,倒计时结束后显示首页。通常倒计时都是使用 NSTimer ,且每秒倒计时结束都需要修改页面上的文字,这些操作必须在主线程做,并且 NSTimer 依赖于主线程的 runloop 状态,主线程阻塞会导致定时器不准。所以为了保证闪屏倒计时的正常显示,首页是在闪屏显示结束后才去创建的。
主线程工作状态如下:
这种情况下用户等待的时间是从“创建闪屏”到“创建、显示首页”结束的时间。
由于闪屏显示时间较长(通常都有几秒),用户在等待闪屏结束的这段时间如果用来创建首页内容肯定是够的,于是就考虑能不能先显示闪屏,在闪屏倒计时的同时去创建、显示首页。
在将首页创建、显示逻辑提前后发现并没有那么简单,就像前面提到的闪屏倒计时使用 NSTimer,NSTimer 又依赖于 runloop,且每秒倒计时结束都必须修改文字,这些操作必须依赖于主线程。只是把首页创建逻辑提前就导致闪屏倒计时不准确,主要表现在第一秒格外的长,因为闪屏显示后主线程就去做创建首页的工作(其实还包括之前被移到首页显示后才启动的服务,也会在这时候一起被主线程处理),必须等首页内容创建完成后 NSTimer 的倒计时才会触发,也必须等首页内容创建完成后才能修改文字。
这时候的主线程工作状态如下:
这时用户等待的时间并没有明显缩短,而且还出现了倒计时不准的 bug 。
出现上面这种情况主要是由两个原因导致的:
使用的定时器依赖于主线程。
修改文字操作依赖于主线程。
第一个问题比较容易解决,不使用 NSTimer,改用 GCD 定时器放在其他线程即可。可是第二个问题是更新 UI ,苹果爸爸明确表示更新 UI 必须放在主线程,一时好像就无解了,但是真的无解吗?
回过头再看一下需求,其实我们要做的就是每秒修改用户看到的文字,并且不依赖于主线程,不依赖于主线程第一时间就想到了动画,iOS 动画是在另一个进程(BackBoard 进程)实现的,主线程阻塞是不会影响 iOS 动画的,利用这一特性就能完美的解决这个问题。我们将要每秒要显示的文字提前生成图片,然后使用生成的图片数组做动画,这样在闪屏显示期间主线程就可以去做其他事情,保证闪屏显示时间的同时减少了用户的等待时间。
优化后的工作状态:
总结
对于启动时间优化其实就是遵循一个原则:尽早让用户看到首页内容。根据这一原则将一些非必须的操作尽量往后移,通常是移到首页显示后执行,同时对于无法往后移的操作,尽可能不占用主线程,主线程尽量只做 UI 操作,将其他操作移到子线程(或者像上述动画一样移到其他进程)。
亦可参考其他文档(https://www.jianshu.com/p/bae1e9bddcc9)