本文为 (WWDC)优化应用启动时间 —— 理论篇 的续篇。
内容概览
- 多快才算快?
- 如何度量?
- 什么导致了启动慢?
- 优化启动时间
- 总结
当你启动一个应用并且你需要等待数秒才能开始使用这个应用
,你会产生什么想法?
卸载它,是吗?
多快才算快?
不同平台有不同的要求,不过 400ms
是一个比较适合的启动等待时长。
我们需要一段时间来完成主页到应用首页的转场动画。
在这个动画播放的时候,我们有机会去完成加载应用的工作。
如果你的应用启动等待时长超过 20s
,操作系统会假设你的应用已经陷入死循环,然后它会终结这个应用的进程。
因此,你需要在应用所兼容的性能最差的设备上进行测试,以此来保证应用确实兼容这些设备。
回顾应用启动的过程
在上述过程结束后,
main()
和 UIApplicationMain()
会被调用,最后 AppDelegate
中的 applicationWillFinishLaunching
回调函数也被调用。
最后这两个步骤,在 400ms
启动等待时间中占据了重要部分,不过在本文中不会对此进行讨论。
如果你想了解这两个步骤的细节,强烈推荐观看 iOS App Performance: Responsiveness 。
冷启动 & 热启动
热启动:应用和数据已经被加载到内存中
冷启动:应用尚未被加载到操作系统内核的缓冲缓存
冷启动和热启动的时间不同,我们更需要关注冷启动的耗时。
在测试冷启动的耗时时,需要重启设备。
在你优化热启动过程的时候,冷启动过程也会被优化。
所以在多次测量热启动过程之后,也可以进行一次冷启动过程的测量。
如何度量?
- 在
main()
函数被调用之前进行测量是很困难的 Dyld
内置了测量工具- 在
scheme
中启用DYLD_PRINT_STATISTICS
环境变量(在已发布的系统中都是可用的,而且新版系统对此进行了优化,从 seed 2 版本开始就可以启用)
- 在
- 调试器会在加载每个动态库时暂停,然后将解析应用里的符号并加载断点,以及进行耗时的USB线路传输操作
Dyld
知道这个过程,所以这些时间会从测量时间里减去- 如果你自己用计时器测量的时间远大于工具测量的时间,这是正常的
什么导致了启动慢?
在配置了 DYLD_PRINT_STATISTICS
环境变量后,你可以在控制台看到类似的输出:
假设下面整个的进度条是全部的启动时间,那么虚线处就代表耗时为 400ms
,很明显这个应用没有达标。
优化启动时间
首先,我们需要理解控制台中输出的内容。
dylib loading time
这个过程需要加载很多 dylibs
,平均为 100 - 400
个,其中大多数为系统内置的 dylib
。
- 加载这些
dylibs
是昂贵的耗时操作 - 我们无法提前计算加载这些
dylibs
所需的时间,解决方案就是尽量少加载dylibs
- 合并现有的
dylibs
- 使用静态库
- 使用
dlopen()
进行懒加载(dlopen()
可能导致其他问题,甚至会做更多工作,所以不推荐)
- 合并现有的
比如,现在需要用到 26
个动态库:
加载时间为 240 ms
。
然后,把这些动态库合并为 2
个动态库:
现在,加载时间仅为 21 ms
。
优化后的大致效果:
当然,这样做是有代价的!
更多的库可以有效节省构建和重新链接的时间,从而加速开发过程。
所以,推荐将动态库的数量控制在一定范围内,推荐的动态库数量为 6
个。
rebase/binding time
这个过程的耗时是 351 ms
。
在这个过程中,变基需要进行 IO
操作,绑定需要进行计算操作( IO
操作已经由变基完成)。
这个过程主要就是修复 __DATA
段中的指针。
优化方案:
- 减少
__DATA
段中需要修复的指针的数量 - 减少
Objective-C
中的元数据(classes
,selectors
, andcategories
) - 减少
C++
虚函数的数量 - 优先使用
Swift
中的struct
- 检查工具生成的代码(避免使用指针,可使用结构体进行替代,或者标记为只读)
现在,这个项目中有超过 10000
个类,事实上是 20000
个!
如果把这个数量缩减到 1000
个,耗时会从 351 ms
变为不到 20 ms
。
ObjC setup time
在这个过程中,主要进行类注册、处理 Non-fragile ivars offset
更新、分类(Category
)注册、Selector
唯一化处理等操作。
通过处理变基、绑定过程,这个过程也得到了优化。所以,这里不需要做什么特殊处理。
这部分的优化效果微乎其微,从 11.8 ms
变为了 4.6 ms
:
initializer time
这个过程的耗时占了最大的比例,所以需要着重优化。
主要是对显式、隐式的初始化方法进行优化。
显示初始化:
- 对于
ObjC
,使用+initiailize
替换+load
。这可以使代码在运行时加载,而不是源代码文件被加载时。 - 对于
C/C++
,__attribute__((constructor))
会在这个阶段生成初始化方法,尽量避免使用。 - 使用调用点初始化方法(在第一次被调用时才会执行):
dispatch_once()
pthread_once()
std::once()
dispatch_once()
已经被深度优化。在第一次执行后,再次执行的效果类似于空操作,所以强烈推荐使用这个方法而不是显式的初始化方法。
隐式初始化:
C++
中的非轻量级构造器- 使用调用点初始化方法替代
- 仅仅设置为简单的值(
PODs - plain old data
),静态链接器就可以计算__DATA
段的内存布局,就不再需要进行后续的指针修复操作 - 为编译器添加
-Wglobal-constructors
标识参数,帮助你找到 - 使用
Swift
重写 (Swift
全局变量使用dispatch_once()
完成初始化)
- 不要在初始化方法中调用
dlopen()
,因为这会造成性能问题(加锁、死锁、undefined behaviors
) - 不要在初始化方法中创建线程,原因同上
这个项目中有一个非轻量级(non-trivial)的构造器:
把 Pause onLaunch(10);
注释掉之后,耗时从 10 s
缩减为不到 4 ms
:
总结
- 设置
DYLD_PRINT_STATISTICS
环境变量,测量启动耗时 - 减少启动耗时的方法:
a. 减少dylibs
的数量
b. 缩减ObjC
类和分类(Category
)的数量
c. 消除静态初始化方法 - 尽可能使用
Swift
- 尽可能不使用
dlopen()
(可能引发性能和死锁问题)
参考内容:
转载请注明出处,谢谢~