IOS优化篇之启动速度优化(一)

启动速度

用户从点击APP图标到完全看到APP内容的过程称为启动,如果启动耗时较长可能会影响用户的体验,所以启动速度优化就显得很有必要。

  • 最佳速度:400ms,这是刚好是启动动画的时间,这是app启动时间的最佳时间。业界建议启动时间保持在1.5s内比较合适。

  • 最慢速度:超过20s,则会被系统杀掉。

启动的分类

1、冷启动:系统里没有APP的进程缓存信息,例如重启手机或者更新APP后的首次启动APP,APP长时间不用系统清掉已有的进程缓存

2、热启动:系统里有APP的进程缓存信息,例如杀死APP后短时间内重启APP

3、回前台:APP退入后台再进入前台,APP进程从挂起到激活状态

一般只讨论1、2两种情况的启动优化。如何从代码层面计算启动速度?根据苹果官方文档的计算方式:进程创建时间到第一个CA::Transaction::commit()

启动流程

如下引用自:抖音品质建设 - iOS启动优化《原理篇》

Snip20211224_2.png

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,启动结束

启动速度优化思路:

1、控制APP的可执行文件大小

2、控制动态库数量

3、控制Page In 次数

4、控制首帧渲染前业务逻辑相关耗时

5、控制首帧视图渲染耗时,即上面流程中的步骤10-12

Tips:iOS13之后系统采用dyld3加载器,才有启动闭包的机制。之前使用的是dyld2,无此机制

优化启动速度

1、动态库数量

解决方案:将项目中的动态库全部替换成静态库。

目前项目中有32个三方库采用动态库方式引入,苹果推荐的是最多不要超过6个动态库。先看如下一组试验数据:

APP进程创建到首次+load调用时间

测试设备ipad pro 2,iOS13.4,冷启动5次;

动态库方式耗时:721.7ms、619.6ms、782.8ms、761.0ms、724.8ms

静态库方式耗时:143.9ms、133.1ms、139.7ms、145.8ms、151.4ms

测试设备ipad pro 2,iOS13.4,热启动5次;

动态库方式耗时:122.6ms、117.4ms、121.3ms、120.7ms、120.5ms

静态库方式耗时:36.1ms、37.0ms、36.6ms、36.1ms、36.2ms

测试设备ipad air,iOS12.1.1,热启动5次;

动态库方式耗时:524.3ms、3435.9ms、3861.4ms、1927.8ms、474.9ms

静态库方式耗时:3145.2ms、4393.3ms、3095.3ms、3126.5ms、2567.0ms

结论:如果项目中有很多动态库,以本项目32个动态库为例,iPad Pro2 测试结果,冷启动耗时减少600ms+,热启动耗时减少80ms+

疑问:

1、静态库增加了APP可执行文件的体积,必然导致dyld加载可执行文件耗时增加,TEXT解密耗时增加。是否静态库被链接的文件数量或者代码量大于某个值后采用动态库更合适?

答案:暂未研究

2、采用静态库的方案是否会导致APP的体积大幅增加?

答案:本项目中并未增加APP体积,反而减小,如下为对比

Snip20211224_5.png

3、采用静态库方式可能导致的问题?(之所以有这样的思考,看到文章中提到了如下的观点:)

  1. 三方库中使用了[NSBundle bundleForClass:[self class]]的行为会和[NSBundle mainBundle]一致。
  2. 由于上一个问题可能导致Bundle找不到的问题(目前正在尝试能否处理)。

本文采用的静态方式use_modular_headers!和上面文章中方式不一样(很明显更加简单,自动实现了上文中资源拷贝的功能)

利用cocopods管理三方库的资源还是比较智能的,以下两个选项用于指定需要拷贝到主工程的资源文件和文件夹语法,默认将这些资源拷贝到主工程的MainBundle中(但如果三方库资源和主工程资源文件重名则会报错),不指定则不拷贝

  spec.resource  = "ic_loading_004.png"
  spec.resources = "Resources/*.png"

如下则将资源拷贝到指定的Bundle中,尽量避免和主工程冲突

  spec.resource_bundles = {
    'resources' => ['Resources']
  }

2、无用代码

思考:无用代码会增加APP可执行文件的大小吗?

无用代码包括第三方库中的无用代码和自身工程中未使用到的无用代码。现在分别做试验(设备iPad Pro2 iOS13.4),随便创建一个工程,打release包,可执行文件最终大小109k,APP最终大小为133k

1、自身工程中未使用到的无用代码

随便拖入一堆无用代码后(工程中不使用)打出来的APP包大小201k,可执行文件大小变为176k。

2、未使用到的无用第三方静态库

拖入ffmpeg静态库,打出来的APP包仍然为133k,可执行文件大小仍然为109k

3、未使用到的无用第三方动态库

拖入Alamofire.framework动态库,打出来的APP包大小2.1M,可执行文件大小仍然为109k。APP包增加的大小为Alamofire.framework库本身的大小

答案:项目工程中未使用到的无用代码最终会编译到APP的可执行文件中区,所以会导致APP可执行文件体积增加。当APP可执行文件变大时,会导致dyld加载可执行文件的耗时增加,即增加了启动时间

Tips:统计项目中代码行数(包括注释和空格)

cd 项目目录
find . ! -path "./*.framework/*" ! -path "./tttt/*" "(" -name "*.m" -or -name "*.mm" -or -name "*.cpp" -or -name "*.h" -or -name "*.swift" ")" -print | xargs wc -l

解决方案:

目前业界给出了两种方案:1、自定义Pass、在函数头部插桩,在编译期间确定代码是否被使用,优点是准确,缺点需要查看llvm源码,实现难度大,目前也只是停留在理论阶段,并无具体的解决方案;2、基于Mach-O可执行文件格式中的字段来确定类或者函数是否被使用,优点是实现简单,缺点是不够准确,只能做静态分析,动态调用代码无法查出来,所以删代码前需要二次确认,目前也有很多不错的实现方案。

采用业界开源方案:WBBlades 分析项目中的无用类,该工具支持OC和Swift的检测,且使用简单。安装该工具后执行如下命令:

blades -unused /Users/ws/Library/Developer/Xcode/DerivedData/FilmoraGo-blntnxezkthnqjdamaarjzspcgmz/Build/Products/Debug-iphoneos/FilmoraGo.app

最终输出项目中的无用类到桌面UnusedClass.plist文件中

Snip20211228_2.png

结论:

经过对比,最终减少安装包0.3M左右,对启动时间基本没有帮助,此外再删除无用类的过程中发现一下现象,予以记录

如下情况类仍然被该工具标记为未使用:

1、Swift中一个类声明为Private

2、collectionView.register()

疑问:

1、目前项目中无用类比较少,删除无用类文件后,对安装包和启动时间影响不大,故减少无用类对启动时间的帮助量化目前还未得知

2、通过Pod方式引入的库也检查出来了很多无用类,有没有办法过滤掉Pods库中的无用类?

参考文章:

Mach-O 文件结构

从Mach-O角度谈谈Swift和OC的存储差异

MachOView

58同城iOS混编项目无用代码检测方案介绍

3、二进制重排

思考:

二进制重排为什么会加快启动速度?

当APP进程访问一页虚拟内存page,而对应的物理内存不存在时,先触发缺页中断(Page Fault)阻塞当前进程,然后加载数据到对应物理内存(Release版本还要对加载的数据进行签名),所以缺页中断还是比较耗时的。假设APP启动时调用100个函数,这100个函数如果分布在100个不同的内存页,那会产生100次缺页中断。如通过二进制重排将这100函数分布到50个或者更少的内存页中,缺页中断的次数减半,启动速度就提升了 。

获取启动阶段Page Fault的次数

打开Instruments,选择System Trace工具

Snip20220113_3.png

重启手机(热启动情况下系统已经做了加载缓存,产生缺页中断大幅减少,所以最好重启手机),然后点击启动,待首屏出现后停止,如下图:

Snip20220113_6.png

以iPad Pro2代为测试设备,优化前启动APP时产生缺页中断3969次,耗时303.82ms

解决方案

获取启动阶段调用的函数符号然后编写order_file编译顺序文件然后在Build Settings -> Order File中配置一个后缀为order的文件路径是实现二进制重排的核心思路。目前业界获取启动阶段调用的函数符号主要有三种:

1、抖音通过静态扫描和运行时 Trace 等方法确定 order_file。该方案实现难度大(需要汇编、反汇编等知识),且只能覆盖部分符号(无法覆盖纯Swift、initialize、部分block 和 C++ 通过寄存器的间接函数调用)

2、手淘通过修改 .o 目标文件实现静态插桩,需要对目标代码较为熟悉,通用性不高

3、clang编译器静态插桩,目前业界已有成熟的库和方案

静态插桩:在 build settings->"Other C Flags"中添加"-fsanitize-coverage=func,trace-pc-guard"。如过项目中有 Swift 代码,还需要在 "Other Swift Flags" 中加入"-sanitize-coverage=fun"和"-sanitize=undefined",如下:

Snip20220118_2.png

Snip20220118_1.png

静态插桩脚本 获取启动阶段符号表的使用步骤:

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,获取启动阶段的符号表

Snip20220208_1.png

结论:
最后获得150个page in fault的提升,大概50ms左右的速度提升

参考文章

[抖音研发实践:基于二进制文件重排的解决方案,APP 启动速度提升超 15%](抖音研发实践:基于二进制文件重排的解决方案 APP启动速度提升超15%)

手淘架构组最新实践 | iOS基于静态库插桩的⼆进制重排启动优化

深入探索 iOS 启动速度优化(二进制重排)

Clang 静态插桩代码

懒人版二进制重排

4、首帧渲染前的业务逻辑优化

这部分代表了main()函数之后的时间,即从didFinishLaunchingWithOptions()开始到根控制器viewDidAppear函数结束主线程耗时的优化

解决方案:

利用Xcode自带的Instruments工具APP Launch分析启动耗时,找出耗时严重的函数调用然后进行优化。该工具会追踪应用启动后5秒内的所有线程的耗时,自带Time Profiler和应用进程的System Trace两个看板,如下:

![Snip20211229_7.png](https://upload-images.jianshu.io/upload_images/7776337-9d11b033c4895fec.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

可以看到APP的启动时间为1.7秒(备注:通过这个工具是APP的冷启动时间),和之前通过检测工具测出的APP冷启动时间还是比较吻合的。

接下来利用System Trace功能对APP启动后的业务逻辑进行耗时分析,点击System Trace看板(也就是上面FilmoraGo)左边的右箭头,选择Main Thread,接下来就可以看到启动阶段各个函数的耗时了

Snip20211230_1.png

可以看到这里面didFinishiLanchingWithOptions耗时197ms,加载解析首屏Main文件及创建相关视图耗时20ms,首次Runloop接手了GCD主线程的代码块执行耗时121ms。接着依次点开这些耗时点逐个分析进行优化

1、在didFinishiLanchingWithOptions中将字体文件加载到内存中(方法configure.fontPath = .. 的耗时),该方法耗时144ms,是大头。经过和业务同学沟通,可以将此动作延后加载。可以看到这个方法是通过source0消息触发的,在__CFRunLoopDoSources0代码块中调用

2、加载解析首屏Main文件及创建相关视图耗时20ms,通过分析解析XML耗时3ms左右,基本可以忽略,主要时间消耗在一个视图的创建上(大概10ms)。这段代码也是在__CFRunLoopDoSources0中执行

3、

结论:

具体改进思路为:设计启动器,改启动器统一管理启动任务,包括首屏选日前必须要加载的任务和可以在渲染完成后延后加载的任务;目的是优化启动速度。启动速度减少大概200ms

疑问:

这种方式是否可以全量覆盖APP启动阶段的耗时?如果不能,解决方案?

答案:不能全量检测,例如两个方法A和B,满足某个条件调用了A,满足另外一个条件调用了B,而B非常耗时,通过这种方式只是测试到了A的耗时。一种是线上监控,不过任务过于艰巨。二是仔细排查代码将这种AB逻辑相关代码揪出来单独进行测试可能更加容易实现一些。

5、首帧视图的渲染优化

思考:

首帧视图耗时即首次执行CA::Transaction::commit()到将最终的渲染树提交给渲染进程结束所消耗的时间,同样利用APP Lauch查看它的耗时如下:

Snip20211230_3.png

可以看到视图的渲染是在代码块__CFRunLoopDoBlocks中调用的,整个耗时20ms,基本没有优化的空间。展开这个调用栈,可以非常清楚的看到视图渲染的整个流程。

优化效果

以设备iPad Pro 2代为例

优化前:冷启动5次,时间为:1449.2ms、1426.4ms、1300.1ms、1441.1ms、1351.1ms;热启动5次,时间为:314.2ms、315.2ms、342.1ms、321.4ms、310.3ms

优化后:冷启动5次,时间为:709.3ms、610.3ms、679.3ms、646.7ms、667.4.0ms;热启动5次,时间为:

293.8ms、299.1ms、299.5ms、299.9ms、293.6ms

可以看到冷启动的优化效果还是很明显的,基本上在50%左右。热启动也有一定的提升

思考

此为线下优化启动速度的一次实践,可以看到经过这次优化启动速度提升不少,那如何做到在业务快速迭代中启动速度不会持续增加呢?或者说增加了能立马知道,研发阶段就快速优化,不要等到累积到一定程度再来优化。

1、首先研发阶段针对启动相关代码做code review,主要包括:

新增动态库

新增启动相关业务逻辑

2、将启动速度监控自动化

设计一套启动速度监控自动化系统,当发现启动速度增加了某个阈值给予开发人员警告,并将耗时增加的方法打印出来,相关业务人员针对性的优化。

参考文章

抖音品质建设 - iOS启动优化《原理篇》

抖音品质建设 - iOS启动优化《实战篇》

基于mach-o+反汇编的无用类检测

iOS静态库&动态库依赖探索

Snip20211224_2.png

你可能感兴趣的:(IOS优化篇之启动速度优化(一))