背景
7 月 26 号我们阿里数据 iOS 端发布了 4.4.0 版本, 这次版本主要是优化了性能, 其中 main()阶段的启动耗时优化成果比较明显, 从之前的 0.5-0.7 秒, 降低为目前的 0.1-0.2 秒 (main() 第一行代码到
didFinishLaunchingWithOptions
最后一行代码的耗时), 用户体验提升明显. 在这里梳理一下优化的一些经验, 欢迎大家一起交流.
应用启动流程
iOS 应用的启动可分为 pre-main 阶段和 main()阶段, 其中系统做的事情依次是:
1. pre-main 阶段
1.1. 加载应用的可执行文件
1.2. 加载动态链接库加载器 dyld(dynamic loader)
1.3. dyld 递归加载应用所有依赖的 dylib(dynamic library 动态链接库)
2. main()阶段
2.1. dyld 调用 main()
2.2. 调用 UIApplicationMain()
2.3. 调用
applicationWillFinishLaunching
2.4. 调用
didFinishLaunchingWithOptions
启动耗时的测量
在进行优化之前, 我们首先应该能测量各阶段的耗时.
1. pre-main 阶段
对于 pre-main 阶段, Apple 提供了一种测量方法, 在 Xcode 中 Edit scheme -> Run -> Auguments 将环境变量 DYLD_PRINT_STATISTICS 设为 1 :
设置好后把程序跑起来, 控制台会有如下输出, pre-main 阶段各过程的耗时一览无余(Apple 这个 Demo 有点过于夸张...)
2. main()阶段
对于 main()阶段, 主要是测量 main()函数开始执行到
didFinishLaunchingWithOptions
执行结束的耗时, 就需要自己插入代码到工程中了. 先在 main()函数里用变量 StartTime 记录当前时间:
- CFAbsoluteTime StartTime;
- int main(int argc, char * argv[]) {
- StartTime = CFAbsoluteTimeGetCurrent();
再在 AppDelegate.m 文件中用 extern 声明全局变量 StartTime
extern CFAbsoluteTime StartTime;
最后在
didFinishLaunchingWithOptions
里, 再获取一下当前时间, 与 StartTime 的差值即是 main()阶段运行耗时.
double launchTime = (CFAbsoluteTimeGetCurrent() - StartTime);
pre-main 阶段的优化
要对 pre-main 阶段的耗时做优化, 需要再学习下 dyld 加载的过程, 根据 Apple 在 WWDC https://developer.apple.com/videos/play/wwdc2016/406/ 上的介绍, dyld 的加载主要分为 4 步:
1. Load dylibs
这一阶段 dyld 会分析应用依赖的 dylib, 找到其 mach-o 文件, 打开和读取这些文件并验证其有效性, 接着会找到代码签名注册到内核, 最后对 dylib 的每一个 segment 调用 mmap().
一般情况下, iOS 应用会加载 100-400 个 dylibs, 其中大部分是系统库, 这部分 dylib 的加载系统已经做了优化.
所以, 依赖的 dylib 越少越好. 在这一步, 我们可以做的优化有:
尽量不使用内嵌 (embedded) 的 dylib, 加载内嵌 dylib 性能开销较大
合并已有的 dylib 和使用静态库(static archives), 减少 dylib 的使用个数
懒加载 dylib, 但是要注意 dlopen()可能造成一些问题, 且实际上懒加载做的工作会更多
2. Rebase/Bind
在 dylib 的加载过程中, 系统为了安全考虑, 引入了 ASLR(Address Space Layout Randomization)技术和代码签名. 由于 ASLR 的存在, 镜像 (Image, 包括可执行文件, dylib 和 bundle) 会在随机的地址上加载, 和之前指针指向的地址 (preferred_address) 会有一个偏差(slide),dyld 需要修正这个偏差, 来指向正确的地址.
Rebase 在前, Bind 在后, Rebase 做的是将镜像读入内存, 修正镜像内部的指针, 性能消耗主要在 IO.Bind 做的是查询符号表, 设置指向镜像外部的指针, 性能消耗主要在 CPU 计算.
所以, 指针数量越少越好. 在这一步, 我们可以做的优化有:
减少 ObjC 类 (class), 方法(selector), 分类(category) 的数量
减少 C++ 虚函数的的数量(创建虚函数表有开销)
使用 Swift structs(内部做了优化, 符号数量更少)
3. Objc setup
大部分 ObjC 初始化工作已经在 Rebase/Bind 阶段做完了, 这一步 dyld 会注册所有声明过的 ObjC 类, 将分类插入到类的方法列表里, 再检查每个 selector 的唯一性.
在这一步倒没什么优化可做的, Rebase/Bind 阶段优化好了, 这一步的耗时也会减少.
4. Initializers
到了这一阶段, dyld 开始运行程序的初始化函数, 调用每个 Objc 类和分类的 + load 方法, 调用 C/C++ 中的构造器函数 (用__attribute__((constructor)) 修饰的函数), 和创建非基本类型的 C++ 静态全局变量. Initializers 阶段执行完后, dyld 开始调用 main()函数.
在这一步, 我们可以做的优化有:
少在类的 + load 方法里做事情, 尽量把这些事情推迟到 + initiailize
减少构造器函数个数, 在构造器函数里少做些事情
减少 C++ 静态全局变量的个数
main()阶段的优化
这一阶段的优化主要是减少
didFinishLaunchingWithOptions
方法里的工作, 在
didFinishLaunchingWithOptions
方法里, 我们会创建应用的 window, 指定其 rootViewController, 调用 window 的 makeKeyAndVisible 方法让其可见. 由于业务需要, 我们会初始化各个二方 / 三方库, 设置系统 UI 风格, 检查是否需要显示引导页, 是否需要登录, 是否有新版本等, 由于历史原因, 这里的代码容易变得比较庞大, 启动耗时难以控制.
所以, 满足业务需要的前提下,
didFinishLaunchingWithOptions
在主线程里做的事情越少越好. 在这一步, 我们可以做的优化有:
梳理各个二方 / 三方库, 找到可以延迟加载的库, 做延迟加载处理, 比如放到首页控制器的 viewDidAppear 方法里.
梳理业务逻辑, 把可以延迟执行的逻辑, 做延迟执行处理. 比如检查新版本, 注册推送通知等逻辑.
避免复杂 / 多余的计算.
避免在首页控制器的 viewDidLoad 和 viewWillAppear 做太多事情, 这 2 个方法执行完, 首页控制器才能显示, 部分可以延迟创建的视图应做延迟创建 / 懒加载处理.
采用性能更好的 API.
首页控制器用纯代码方式来构建.
阿里数据 iOS 端优化实践
在以上的认知指导下, 阿里数据 iOS 端开始着手优化, 在 pre-main 阶段和 main()阶段分别做了一系列优化, 取得了一定的成果.
1. pre-main 阶段的优化
1.1. 排查无用的 dylib, 移除不再使用的 libicucore.tbd
1.2. 删除无用文件 & 库, 合并重复文件(多个重复的分类). 移除不再使用的库 UMSocial,PSTCollectionView,MCSwipeTableViewCell, 移除功能重复的库 Mantle.
1.3. 梳理各个类的 + load 方法, 将多个类中 + load 方法做的事延迟到 + initiailize 里去做.
优化前 pre-main 阶段耗时:
优化后 pre-main 阶段耗时:
测试环境: Xcode8.3.3 iOS10.2 的模拟器, 热启动.
备注: 测试发现, pre-main 阶段耗时有一定波动, 冷启动时波动更大, 这里截图贴的是一个中位数水平.
可以看到热启动下, pre-main 阶段耗时有一定下降.
2. main()阶段的优化
2.1. 去掉其中 100ms 的 dispatch_after... 检查代码发现之前会故意让启动图多显示 100ms, 不知道是什么逻辑...
2.2. 将多个二方 / 三方库延迟加载. 包括 TBCrashReporter,TBAcCSSDK,UT,TRemoteDebugger,ATSDK 等.
2.3. 将若干系统 UI 配置, 业务逻辑延迟执行. 包括注册推送, 检查新版本, 更新 Orange 配置等.
2.4. 避免多余的计算. 之前会前后两次获取是否要显示广告图, 每次获取都需要反序列化 Orange 中的配置信息, 再比较配置中的开始 / 结束时间, 大约耗时 20ms. 目前的解决方案是第一次计算后, 用一个 BOOL 属性缓存起来, 下次直接取用.
2.5. 延迟加载 & 懒加载部分视图. 快捷密码验证页是启动图消失后用户看到的第一个页面, 这个页面由于涉及到图片的解码, 多个视图的创建 & 布局, viewDidLoad 阶段会耗时 100ms 左右. 目前的解决方案是把其中密码输入框视图延迟到 viewDidAppear 里加载, 对密码错误提示视图做成懒加载, 耗时降低到 30m 左右.
通过 instruments 的 Time Profiler 分析, 优化后启动速度有明显提升,
didFinishLaunchingWithOptions
耗时在 75ms 左右(iPhone6s iOS10.3.3)
其中目前耗时最多的是快捷密码验证页(
PAPasscodeViewController
)的创建 & 布局, 其次是
DTLaunchViewControlle
里对是否要显示广告页的判断代码. 可以看到
PAPasscodeViewController
的 viewDidAppear 耗时了 78ms, 但已经没有太大关系, 此时用户已经看到了页面, 准备去验证指纹 / 密码了.
总结 & 后续规划
1. 总结
总结起来, 好像启动速度优化就一句话: 让系统在启动期间少做一些事. 当然我们得先清楚工程里做的哪些事是在启动期间做的, 对启动速度的影响有多大, 然后 case by case 地分析工程代码, 通过放到子线程, 延迟加载, 懒加载等方式让系统在启动期间更轻松些.
2. 后续规划
2.1. 替代部分庞大的库, 采用更轻量级的解决方案.
2.2. 整理代码, 去除重复的实现, 避免出现功能重复的类 & 分类 & 方法.
2.3. 梳理和移除已经下线的业务涉及的类 & 分类 & 方法.
2.4. 监控好灰度版本启动速度的变化趋势, 尽早发现 & 解决拖慢启动速度的问题.
参考资料
WWDC Optimizing App Startup Time https://developer.apple.com/videos/play/wwdc2016/406/
attribute 总结 http://www.jianshu.com/p/29eb7b5c8b2d
dyld 加载 Mach-O http://blog.tingyun.com/web/article/detail/1346
优化 App 的启动时间 http://yulingtianxia.com/blog/2016/10/30/Optimizing-App-Startup-Time/
今日头条 iOS 客户端启动速度优化 https://techblog.toutiao.com/2017/01/17/iosspeed/
来源: https://yq.aliyun.com/articles/616400