启动速度
用户从点击APP图标到完全看到APP内容的过程称为启动,如果启动耗时较长可能会影响用户的体验,所以启动速度优化就显得很有必要。
最佳速度:400ms,这是刚好是启动动画的时间,这是app启动时间的最佳时间。业界建议启动时间保持在1.5s内比较合适。
最慢速度:超过20s,则会被系统杀掉。
启动的分类
1、冷启动:系统里没有APP的进程缓存信息,例如重启手机或者更新APP后的首次启动APP,APP长时间不用系统清掉已有的进程缓存
2、热启动:系统里有APP的进程缓存信息,例如杀死APP后短时间内重启APP
3、回前台:APP退入后台再进入前台,APP进程从挂起到激活状态
一般只讨论1、2两种情况的启动优化。如何从代码层面计算启动速度?根据苹果官方文档的计算方式:进程创建时间到第一个CA::Transaction::commit()
启动流程
1、点击APP图标后,内核创建APP进程
2、将APP的Mach-O可执行文件mmap进虚拟内存,加载dyld程序,接下来调用_dyld_start函数开始程序的初始化
3、重启手机/更新APP会先创建启动闭包,然后根据启动闭包进行相关的初始化
4、将动态库mmap进虚拟内存,动态库数量太多则这里耗时会增加
5、对动态库和APP的Mach-O可执行文件做bind&rebase,主要耗时在 Page In,影响 Page In 数量的是 objc 的元数据
6、初始化 objc 的 runtime,如果有了闭包,由于闭包已经初始化了大部分,这里只会注册 sel 和装载 category
7、+load 和静态初始化被调用,除了方法本身耗时,这里还会引起大量 Page In
8、初始化 UIApplication,启动 Main Runloop
9、执行 will/didFinishLaunch,这里主要是业务代码耗时
10、Layout,viewDidLoad 和 Layoutsubviews 会在这里调用,Autolayout 太多会影响这部分时间
11、Display,drawRect 会调用
12、Prepare,图片解码发生在这一步
13、Commit,首帧渲染数据打包发给 RenderServer,启动结束
什么是PageIn?
启动的路径上会触发很多次 Page In,其实也比较容易理解,因为启动的会读写二进制中的很多内容。Page In 会占去启动耗时的很大一部分,我们来看看单个 Page In 的过程:
- MMU 找到空闲的物理内存页面
- 触发磁盘 IO,把数据读入物理内存
- 如果是 TEXT 段的页,要进行解密
- 对解密后的页,进行签名验证 (iOS 13 对这个过程进行了优化,Page In 的时候不需要解密了。)
启动优化
启动速度优化思路:
1、控制APP的可执行文件大小
2、控制动态库数量
3、控制Page In 次数
4、控制首帧渲染前业务逻辑相关耗时
5、控制首帧视图渲染耗时,即上面流程中的步骤10-12
1、尽可能减少动态库的引用
至于什么是动态库,什么叫静态库?
静态库:编译时链接,链接时完整地拷贝至可执行文件中, 被多个依赖多次使用就会有多份冗余拷贝. 动态库: 链接时不复制, 程序运行时由系统动态加载到内存, 供程序调用, 系统只加载一次, 多个程序共用, 节省内存. iOS 中的静态库与动态库
2、删除无用代码
Q: 无用代码会增加APP可执行文件的大小吗?
A: 项目工程中未使用到的无用代码最终会编译到APP的可执行文件中区,所以会导致APP可执行文件体积增加。当APP可执行文件变大时,会导致dyld加载可执行文件的耗时增加,即增加了启动时间。
如何做?
基于Mach-O可执行文件格式中的字段来确定类或者函数是否被使用,优点是实现简单,缺点是不够准确,只能做静态分析,动态调用代码无法查出来,所以删代码前需要二次确认,目前也有很多不错的实现方案。
采用业界开源方案:WBBlades 分析项目中的无用类,该工具支持OC和Swift的检测,且使用简单。
3、二进制重排
Q: 二进制重排为什么会加快启动速度?
A: 当APP进程访问一页虚拟内存page,而对应的物理内存不存在时,先触发缺页中断(Page Fault)阻塞当前进程,然后加载数据到对应物理内存(Release版本还要对加载的数据进行签名),所以缺页中断还是比较耗时的。假设APP启动时调用100个函数,这100个函数如果分布在100个不同的内存页,那会产生100次缺页中断。如通过二进制重排将这100函数分布到50个或者更少的内存页中,缺页中断的次数减半,启动速度就提升了 。
获取启动阶段Page Fault的次数
打开Instruments,选择System Trace
工具
重启手机(热启动情况下系统已经做了加载缓存,产生缺页中断大幅减少,所以最好重启手机),然后点击启动,待首屏出现后停止,如下图:
解决方案
获取启动阶段调用的函数符号然后编写order_file编译顺序文件然后在Build Settings -> Order File中配置一个后缀为order的文件路径是实现二进制重排的核心思路。
使用clang编译器静态插桩
静态插桩
:在build settings->"Other C Flags"
中添加"-fsanitize-coverage=func,trace-pc-guard"
。如过项目中有 Swift 代码,还需要在 "Other Swift Flags"
中加入"-sanitize-coverage=fun"
和"-sanitize=undefined"
,如下:
静态插桩脚本 获取启动阶段符号表的使用步骤:
1、在Podfile中添加如下代码:
pod 'YCSymbolTracker'
post_install do |installer|
require './Pods/YCSymbolTracker/YCSymbolTracker/symbol_tracker.rb'
symbol_tracker(installer)
end
然后执行pod install
2、再首帧完成渲染前调用如下代码:
// 首帧渲染完成后调用此方法,一般在跟控制器的viewDidAppear方法中调用即可
static func runAfterFirstFrameRendered(){
....省略的业务代码
// 首帧渲染前调用监控代码
let filePath = NSTemporaryDirectory().appending("/demo.order")
YCSymbolTracker.exportSymbols(filePath: filePath)
}
3、关机然后打开APP,获取启动阶段的符号表
4、首帧渲染前的业务逻辑优化
这部分代表了main()函数之后的时间,即从didFinishLaunchingWithOptions()开始到根控制器viewDidAppear函数结束主线程耗时的优化
解决方案:
利用Xcode自带的Instruments工具APP Launch分析启动耗时,找出耗时严重的函数调用然后进行优化。该工具会追踪应用启动后5秒内的所有线程的耗时,自带Time Profiler和应用进程的System Trace两个看板,如下:
接下来利用System Trace功能对APP启动后的业务逻辑进行耗时分析,点击System Trace看板(也就是上面FilmoraGo)左边的右箭头,选择Main Thread,接下来就可以看到启动阶段各个函数的耗时了。
这里分析比如我们可以把一些类似加载字体,或者一些不影响业务的操作延后或者放到子线程中执行。