App 的启动速度不仅影响我们调试,也直接关系到用户体验。之前有些很久没有打开过的项目,需要花费很长的时间才完成编译;对应的 App 在点击后,许久才出现启动画面。你是否为这些问题苦恼过呢?
这是我观看 WWDC2016 Sessions406 《Optimizing App Start Time》的博客笔记。虽然没有字幕听起来很吃力,但光看 Slide 还是有不少收获的。原文有点长,包括理论是实践两部分,为了方便阅读这里只放出一部分。
Mach-O 文件是如何被加载的
在 mian() 函数执行之前,操作系统为我们做了什么?
exec() -> main()
exec()执行过程:
内核随机分配一段可用的内存给应用程序。但有个规则,即不分配低地址的内存空间,该地址空间大小由处理器的位数决定,情况如下:
32位处理器保留 4K
64位处理器保留 4G
低地址保留,用于保存空指针、异常错误信息等。
加载动态库
内存分配完成后,处理器运行动态加载器(Dynamic loader)来加载动态依赖库(dylibs)。
加载过程:
映射所有的直接依赖库,递归间接调用的依赖库(Map all dependent dylibs,recurse).
重建所有的图片(Rebase all images).
绑定所有图片(Bind all images).
准备代码层面的加载(ObjC prepare images)
执行初始化方法(Run initializes).
直接加载:
解析所有的动态库
找出需要的 mach-O 文件
打开并读取文件
认证 mach-O 文件
登记代码签名
为每个 segment 调用
mmap()
映射函数
递归加载:
当所有直接依赖库加载完毕后,还存在直接依赖库依赖于其他库的情况。递归加载间接依赖库,一般 App 需要加载 100 到 400 个动态库!其中绝大多数为系统库,苹果已经最优化了系统库的加载。
2. 重建(Rebasing)
将图片资源根据其地址进行加载,重建信息被编码在 LINKEDIT
segment 中。重建的过程按照地址顺序执行,所以可以被内核预取。
3. 绑定(Binding)
应用程序对动态库的引用只是字符层(symbol)面上,绑定过程中需要加载器通过函数名来查找,相对于重建这个过程需要更多的计算。
4. ObjC 准备阶段
完成重建和绑定后的配置工作
登记定义的 ObjC 类
更新实例变量对应的内存位置
分类的方法被插入到主类
5. 初始化(Initializer)
静态分配内存的对象的初始化
调用 +load 方法
调用相关联的动态库
最后,执行 main()函数
启动优化
对于不同的设备启动速度都不相同,苹果认为完美的启动时间是在 400 毫秒以内。App 的最大允许启动时间为20秒,如果超过这个时间就会被 Killed。
我们可以通过编辑工程的 Schemes 来打印 App 的启动中各项时间花费,从而来分析和优化启动过程。
在 Arguments -> Environment Variables 中加入 DYLY_PRINT_STATISTICS
。
设置后,控制台部分项时间花费输出:
1. 加载动态库
加载动态库需要很大的开销,解决方法是尽可能减少引入不必要的动态库,合并现有可操作的动态库,使用静态文件,使用懒加载等
2. 重建和绑定过程
减少指针变量的使用
减少 Objective-C 类
减少 C++ 虚基类的使用(少见)
建议使用 Swift 结构体
尽可能将属性设置为只读
3. 初始化
将
+load
函数的操作尽可能在+initialize
方法中执行简化 C++ 构造函数
不要在初始化方法里面调用
dlopen()
不要在初始化方法里面创建线程
+initialized
会在任何类加载之前被调用,而 +load
是所在类被加载到系统的时候被调用,这通常比 +initialized
调用的时机要早。在 +load
函数里面做操作会延缓系统的启动时间。
dlopen()
函数用于打开 Bundle 尽可能的延迟读取本地的 Bundld 资源,也有利于加快启动速度。
线程的创建需要很大的开销,不要在 App 启动的过程创建子线程。
参考阅读
1. https://developer.apple.com/videos/play/wwdc2016/406/
2. http://objccn.io/issue-6-3/
3. https://code.facebook.com/posts/1675399786008080/optimizing-facebook-for-ios-start-time/