本文字数:7485字
预计阅读时间:19分钟
2021年初,搜狐视频iOS技术团队开始实施启动优化项目,经过10个月优化后,搜狐视频iOS端启动时间从2秒级,降低到1秒级,优化幅度为46%。我们的技术团队通过多项技术优化和创新,呈现了搜狐视频app自己的启动优化解决方案。
启动优化成果如图:
(注:启动耗时中包含开屏广告接口时延)
像搜狐视频APP这样涉及音视频业务、迭代时间超过10年的项目,包含了众多业务代码和第三方SDK;iOS系统本身也经历了多次更迭,所以,启动优化不是单一项目优化,它是一个系统的、多个技术组成的优化项目。在启动耗时下降46%数据的背后,是我们使用了十项技术优化。
Part 1 启动耗时检测
一、 HHTimeProfile火焰图
Part 2、preMain阶段
二、 减少动态库数量
三、 删除冗余代码
四、 优化+load方法和C静态方法初始化 、C++构造方法
五、 系统动态库预热
Part 3、didFinishLaunching阶段
六、 二进制重排
七、 启动项优先级和自注册
八、 时间片机制
九、 异步获取系统设置
十、 双根视图架构
本篇文章旨在详细、尽可能全面介绍,启动优化涉及到的知识和技术。希望读者通过该指南的介绍,能够大幅降低大型APP的启动时间。本文会用【小提示】【优化收益】等标识,来体现重要的知识点和该项优化带来的收益数据。
下面详细介绍一下搜狐视频启动优化方案。
WWDC的对启动描述:启动涵盖了我们代码库的很大一部分,从底层加工到初始化,再到视图创建等等,因此,如果发现启动并不像期望的那样快速。这也可能表明代码库效率并不高。最后,启动是一个对手机非常紧张的时间会涉及大量CPU工作和大量内存工作。因此,你们应该尝试减缓它,因为它会影响系统性能。
(该图构思源自instrument)
如上图所示:借助instrument工具app launch项目,可以看到紫色部分即preMain阶段。该阶段中initilaizing主要执行系统初始化,这是系统开销,算是固定成本。接下来是system interface initialization,该阶段包含了加载动态库以及系统动态库,static runtime initialization则是+load方法、C静态初始化方法、C++构造方法等初始化。绿色部分是UIKit、didfinishlaunching和首帧。
我们可以在system interface initialization、static runtime initialization、didfinishlaunching几个阶段做优化,而且在didfinishlaunching做优化要比前面几个阶段容易。
【小提示】-(void) applicationDidBecomeActive(:)方法,在启动时也会立即调用一次,如果在这里做了过多的业务影响启动时间。
在做启动优化时首先入手的是:检测启动耗时。搜狐视频项目大概有一万个类,十几万个方法,如何检测庞大的项目的耗时。XCode提供了用来检测启动耗时的工具——instrument
instrument的可以监测APP启动过程中各个阶段的耗时。比如我们关心的preMain和didFinishLaunching两个阶段。查看preMain阶段的具体操作是这样:
\1. 选择紫色区间,再选择“profile”,如图:
\2.点击call tree按钮,隐藏系统库(如图),剩下只有app和SDK的方法了(如果动态库的SDK符号无法解析,可以通过右键,local dsym选项,找到本地的dsym,进而解析出SDK的符号)
\3. 可以看到有一些+load方法,WCDB的绑定方法(C++)等。
同样的步骤,我们还可以选中绿色的didFinishLauching部分的耗时。但是instrument的界面查看起来没有火焰图直观。下面看一下火焰图的方案。
HHTimeProfile是搜狐视频自研的性能检测工具。该工具用于统计OC函数耗时,精确到0.1毫秒。将每一个函数耗时、调用栈绘制成火焰图。(下载地址 https://github.com/hherima/HHTimeProfiler)
以下图为例,横向是时间轴,横向比较宽的代表该方法比较耗时;可以明确看到[STADOpen valueWithError]方法耗时非常严重。
HHTimeProfile也可以检测动态库和第三方SDK的方法耗时。
HHTimeProfile 是基于finshhook,hook所有OC方法的原理。HHTimeProfile已支持pod。
集成后,启动APP会在沙盒doc目录下,生成一个HHTimeProfile/xxx.json文件。打开chrome浏览器中输入chrome://tracing/ 将json拖进去,就能看到火焰图了。
统计preMain的耗时:
通常使用App进程创建的时间戳作为preMain的开始,main函数调用作为preMain的结束。
可以通过sysctl
系统调用拿到进程创建的时间戳。代码如下:
PreMain统计误差
由于iOS15系统增加了对app预热功能,系统会提前创建App进程,时机可能是20ms也可能是2分钟。此时,App再获取进程创建时间,很容易获取一个超前的时间,比如2分钟以上,这部分数据被认为过大(超过阈值50秒),就被统计代码过滤了。
使用NSProcessInfo来检测该次启动是否是预热的场景:
NSString *preWarmKey = [NSProcessInfo.processInfo.environment objectForKey:@"ActivePrewarm"] ;
如图所示:从iOS15.1开始,线上统计数据和实际启动时间开始出现偏差,并且偏差越来越大。
从iOS15发布以后,就开始存在一定误差,iOS15.1开始误差开始增大,到12月底误差已经达到50ms级别。
针对这个问题,我们暂时解决方案是:
\1. 降低统计耗时阈值(从50秒调整到20秒)
\2. 当遇到preWarm的情况且时间超过阈值,则使用该设备正常启动平均耗时来填充。
Part 2 PreMain阶段优化方案
preMain阶段,我们使用了常规优化方案:动态库转静态库、减少c和C++构造方法、减少+load方法和分类。除此之外,也使用了一些新的技术优化方案。
动态库和静态库的优势劣势这里不再赘述,在每个动态库的加载过程中, dyld需要以下步骤,这些操作占用了启动时间
分析所依赖的动态库
找到动态库的mach-o文件
打开文件
验证文件
在系统核心注册文件签名
对动态库的每一个segment调用mmap()
静态库优势是可减低启动时间,我们开始将动态库转为静态库。
首先:工程中依赖的开源库全部转为静态库。如果项目使用了pod,在podfile中使用命令:
use_frameworks! :linkage => :static
搜狐视频引用了19个开源库,全部转为静态库后,线上数据显示preMain阶段降低了200ms左右
其次:在做启动优化专项的时候,我们立了一个flag:公司内部提供的库全部转为静态库,第三方提供的库尽量使用静态库,以降低启动时间。
对于公司内部提供的库,比如:直播SDK、播放器SDK、拍摄SDK、广告SDK、cronet网络库全部改为静态库;这个工作实施起来过程比较曲折,公司内部提供的SDK 业务往往比较多,不同业务线提供的SDK可能引用了相同的基础库,就会造成符号冲突,实际上动态库转静态库最大的挑战之一就是符号冲突。
比如:A、B两个SDK都有相同的方法,编译会提示符号冲突 duplicate Symbol。就需要某一SDK改方法名,或者将自己的符号隐藏。每当我们接入一个静态库就要做一遍符号去重的工作。以搜狐视频启动优化为例:直播SDK转为静态库耗时两个月,开发产品开了四次会。符号冲突涉及到直播SDK、客户端、播放器SDK、连麦SDK、甚至第三方搜狗SDK。最终:直播SDK隐藏了部分符号,并且重命名了其他冲突的符号,解决了编译问题。
动态库转静态库,另外一个挑战是:不同静态库的分类方法重名问题。如果系统类的分类重名但是逻辑实现又不一样,就存在潜在风险。比如:我们遇到了和某一SDK 重名的[NSString stringWithJSONObject]。SDK内部实现对json添加换行符,客户端同名方法实现没有换行符。结果App在链接时候,链到SDK的方法。App在使用该方法格式化接口参数时候,后端无法识别。
如何避免这个问题,我们做了一个工具,可以检测系统类的分类是否相同,以避免出现上述重名方法问题。
最后:向第三方SDK提出,提供静态库的需求。。
以iPhone7 Plus为例,以10次启动的平均值,动态库转静态库前后耗时对比。
从图上可以看出,dylib耗时降低的同时,rebase/binding, objC setup, initializer耗时并没有提升,preMain阶段降低了376ms。当我们将所有动态库转为静态库后,总体收益如下:
【动态库优化总收益】preMain阶段降低 400ms左右
【小提示1】搜狐视频使用了cronet库,该库使用了boringSSL库。这个和openSSL处于不同的分支。方法不尽相同。cronet不能直接使用app的openSSL库,我们采取了重命名方案。**【重命名方案】**
【小提示2】动态库的加载时机,以及为什么动态库不能动态加载:
在 iOS App 启动时系统会查找我们所依赖的所有动态库并加载, 这降低了我们 App 的启动速度, 那么可不可以将动态库的调用时间延迟到 app 运行时? 答案是不能! 不能动态加载动态库的原因是系统的限制. 查看苹果的 API 文档, 会发现有一个方法提供了加载可执行文件的功能, 那就是NSBundle
的load
方法 (底层实现为dlopen
函数),然而, 这个方法的使用是有前提的. 那就是库和 app 的签名必需一致. iOS 可能是出于安全考虑, 在加载可执行代码前, 需要校验签名.load
方法的内部实现是调用了dlopen
, 而真机的dlopen
内部还会调用dlopen_preflight
先校验签名. 如果库不是事先打包进 app(打包进 app 的话会与 app 有相同签名), 就会报签名错误, 从而加载不成功。因此动态加载动态库在模拟器上可以实现, 但是真机上不能运行。
对于项目中的冗余代码,我们使用CATClearProjectTool 检查,发现了从2011年到2021年多个无用的类,手动删除450个类和分类。并淘汰ASI库,AddressBook、TTTAttributedLabel、SXAddressbook
【小提示】对自定义类进行分类扩展,不影响启动时间。对动态库(私有动态库和系统库动态库)的类进行扩展会影响启动时间
原因:对于Category
,dyld源码的_read_images
通过_getObjc2CategoryList
获取Category
list,并将分类信息添加到类和元类中。*_getObjc2CategoryList中从*__DATA __objc_catlist section中取分类信息。自定义类的分类编译之后,分类信息并不会存储在__objc_catlist中。因为静态链接期间,Category
中的方法已经和主类中的方法合并到方法列表中了并且内存地址连续,同时Category
中的方法还放在了主类方法的前面。启动阶段dyld对自定义类的分类没有做工作。
【优化收益】从线上数据上看,优化不明显
通过instrument工具可以看到load方法的耗时(下图),同时也能从mach-o中查找所有的load方法(可在公众号留言获取脚本文件)
做好 load 和 Static Initializers 的耗时监控,随着业务的增长很容易出现耗时增加。
我们将项目源码中的load方法initialize化。C的静态初始化方法attribute((constructor))放到initialize中。
【优化收益】:在50ms~100ms区间
在premain阶段,我们使用了新的优化方案,借助push扩展,对系统动态库预热
在iOS10 以上的系统,APP收到push消息时,push扩展会收到回调。我们在push扩展回调方法中,将主APP使用的系统动态库提前加载。如果用户短时间内点击了push消息拉起主APP。那么主APP的preMain耗时将会减少。我们对这个场景进行测试,数据如下:
冷启动iPhone 6Plus | |
---|---|
正常拉起 | push拉起 |
2797 | 1591 |
3412 | 1579 |
2578 | 1615 |
2164 | 1587 |
2130 | 1576 |
2097 | 1564 |
2204 | 2384 |
2099 | 2138 |
1769 | 1881 |
1820 | 2005 |
均值2307 | 均值1792 |
在比较旧的设备上提升比较明显,大概500ms的提升,性能好一点的设备优化不明显。同时也测试了iPhone 7Plus的耗时,有56ms的提升。
iPhone 7Plus | |
---|---|
正常启动 | push拉起 |
572 | 519 |
575 | 558 |
653 | 668 |
724 | 553 |
均值631 | 均值574.5 |
以上测试环境是debug,且重启手机的冷启动。
【优化收益】20ms
Part 3、didFinishLaunching阶段
二进制重排的核心,就是尽可能的将启动期间调用到的函数集中起来,进而减少Page Fault的次数。
默认情况下,App启动期间调用的函数分布在多个内存页上(iOS端每一页大小限定为16KB)。系统加载该进程需要访问很多页,但只有部分Symbol是启动时用到的。
当CPU访问一个虚拟内存Page而对应的物理内存却不存在时,会触发一次缺页中断(Page Fault),分配物理内存,有需要的话会从磁盘mmap读人数据。通过App Store渠道分发的App,Page Fault还会进行签名验证,所以一次Page Fault的耗时比较大。
【优化收益】理论值100ms
启动项优先级比较好理解,首先定义五个优先级:
\1. willFinishLaunch
\2. didFinishLaunching
\3. 开屏广告后
\4. 首页展示后
\5. 懒加载
根据业务需求,可以将业务按照这五个优先级排。比如,有一些需要提前异步获取数据的方法可以放在willFinishLaunch阶段,一些必要的业务放在didFinishLaunching阶段,上传、下载SDK,可以在开屏广告展示或者首页出现后,再初始化这两个SDK。登录SDK,不是每次启动都要初始化,可以当使用过到的时候再初始化。
启动项自注册
目的:为了将启动项放在各自的业务代码中启动,减少头文件的引用。充分解耦启动项。
原理:
\1. 利用clang 的section方法,在编译阶段,对mach-o文件进行存储函数的操作。mach-o文件的__DATA section是可读写的。
\2. 在相应时机通过getsectbynamefromheader_64找到创建的__DATA段,遍历执行section中的存储的函数指针的指针。
实现步骤:
实现如下:
最后写入Mach-O的有__DATA段如图,
【优化收益】启动耗时降低200ms
从开屏时序上来看,开屏广告展示期间可以初始化首页。但这样会引入新问题:当开屏广告为视频广告时候,如果同时加载首页和开屏视频广告,视频广告会出现卡顿现象。
所以,我们采用时间片处理机制——将大任务分割成小任务。这样不会对主线程执行造成明显的卡顿。如图所示
代码示例,我们使用基本的time和 dispatch_once_t来实现
【优化收益】启动耗时降低200ms
项目中会使用到有一些系统设置开关,但不是启动后立即使用;所以,可以提前异步获取这些属性。
举一个例子:获取系统push开关。我们采取异步+同步的方式获取,代码如下:
优化后,线上数据对比:
获取push设置次数 | 同步获取占比 | |
---|---|---|
优化前 | 163984 | 99.7% |
优化后 | 7402 | 4.1% |
【优化收益】:9ms
搜狐视频一直以来使用单一根视图架构,即TabbarViewController,首页和我的tab两个个vc也在TabbarViewController之上。在这个架构下,push拉起app时候,必须经过TabbarViewController和首页。这两个页面不是播放必须的。所以我们想是不是可以跳过首页,直接拉起详情页。经过技术调研,我们使用双根视图架构,即将详情页作为根视图,启动后直接加载详情页。
双根视图架构通过修改push拉起时的页面架构,从而规避掉一部分启动时业务逻辑的耗时,有效的降低了冷启动push拉起的时间。
如上图,双根视图的层级结构由原本单一根视图更改为两个并列的根视图。push拉起时设置window的根视图为包含push拉起落地页的容器视图,规避掉push冷启动时对tabVC初始化的依赖,有效降低其他业务模块初始化对启动耗时的影响。在后续的用户使用过程中,如果业务需要,再将window的根视图切换回tabVC根视图即可无缝进行后续操作
【优化收益】启动耗时降低100ms 左右
不同场景的启动,时间也不相同。针对不同启动场景进行着重优化。如下图:
比如guide(首次安装)的情况,耗时比较突出。
我们分析首次安装的场景后,将广告SDK等延后初始化,尽早展示隐私弹框和隐私引导。优化后guide场景的启动降低了50%。
对于大型App而言,启动速度尤其重要。有统计显示启动速度每慢1秒就损失7%的用户。冷启动流程也是一个比较复杂的过程,启动优化不是一次优化就能做到位,就像优化前的较高耗时也不是一两个版本形成的。我们技术同学通过逐步迭代、及时调整优化策略,避免重大bug的产生。
保持优化成果:我们线上有数据看板、每日邮件等方式。数据看板对preMain、didfinishlaunching、广告初始化、系统服务初始化等阶段的耗时都有统计,可直观查看线上启动健康程度。
开发者在做优化的同时,iOS系统本身也在持续改善APP启动耗时,我们也要保持技术的最新。