一、启动时长监控
1、阶段划分
一般而言,对于冷启动时长,一般是统计从用户点击图标到首屏展示出来这段时间的时长。这个过程可以粗粒度分为下面两个阶段进行统计:
这里一般是使用 root viewController 的 viewDidAppear 时间作为首屏渲染完成时间,也就是 APP 首屏加载完成用户可以交互的时间。
Pre Main 阶段,进程创建到执行 main() 的时间:
- 加载动态库:加载系统动态库及三方动态库
Rebase
&Bind
:修复 Mach-O 内外指针偏移- Objc setup:初始化 objc runtime、注册 sel、加载 category 等
- Initializers:调用
+load
方法,调用 static initializer 相关方法等
After Main 阶段,执行 main()
到首屏渲染完成的时间:
- SDK 注册
- 业务初始化
- 首屏数据加载 & UI 绘制
- …
当然这只是较粗粒度的划分,基本大部分 APP 都是划分成类似阶段。部分 APP 的 After Main 阶段定义为“执行 main()
”到 “didFinishLuanching
执行结束”,这不是非常好的划分方式,因为对于用户来说,用户真正等待的时间是点击图标到 APP 首页展示完成能够交互的这段时间,而“didFinishLuanching
执行结束”时首页未必渲染完成。
对于 After Main 阶段,由于可能会有大量 SDK 注册、业务初始化等任务。为了能准确定位各任务耗时情况,一般需要对这些任务进行集中管理,监控任务执行耗时。
部分 APP 为了能够更加细致地分析各阶段耗时情况,可能会把 APP 冷启动划分为更加细粒度的几个阶段,例如抖音将冷启动划分成了下面这些阶段:
2、线上耗时监控
(1) 节点时间上报
只要启动阶段划分完成了,对于监控耗时情况就比较容易了,只需要获取各阶段节点发生的时间进行计算和上报即可。根据前面我们对 Pre Main 和 After Main 两个阶段的划分,对于“执行 mian()
”和“首屏渲染完成”这两个节点,我们只需要在对应时刻插入统计逻辑即可。对于“进程创建时间”,可以通过 sysctl
系统调用拿到进程创建的时间戳:
1 | // 需要先导入头文件 |
iOS 9 之前 sysctl
还能获取到其他进程的信息,后来 Apple 为了隐私安全禁止了 sysctl
获取其他进程的信息,但是仍然允许获取当前进程的信息。
(2) 无侵入监控方案
无侵入监控的好处是降低使用者的学习成本,无需更改 APP 的现有逻辑。无侵入埋点对冷启动的定义仍然可以是“进程创建”到“首屏渲染完成”这段时间,这里可以参考字节的 APM 团队提供的一种无侵入的启动监控方案,方案将启动流程拆分成几个粒度比较粗的与业务无关的阶段:进程创建,最早的 +load
,didFinishLuanching
开始和首屏首次绘制完成。
前三个时间点无侵入获取较为简单
- 进程创建:通过 sysctl 系统调用拿到进程创建的时间戳(实现方式前面提到了)。
- 最早的
+load
:+load
、initializer 的调用顺序和链接顺序有关,链接顺序默认按照 CocoaPod 的 Pod 命名升序排列,所以将组件命名为 AAA 开头既可以让某个+load
、initializer 第一个被执行。 didFinishLaunching
:监控 SDK 初始化一般在启动的很早期,用监控 SDK 的初始化时间作为didFinishLaunching
的时间。
那么问题来了:
如何无侵入获取“首屏渲染完成”时间?
iOS 13 开始,Apple 提供了 MetricKit 用于统计 APP 的性能数据,包括 APP 启动时间、电池使用、磁盘 IO 等性能数据,iOS 系统会自动收集收集这些性能数据进行上报,在 Xcode 11 版本我们可以在 Window - Organizer 中看到 APP 的这些数据:
MetricKit 官方的统计的冷启动结束时间(首屏渲染完成时间)是完成第一个 CA::Transaction::commit 的时间,这点我们可以与之对齐。而我们如何准确获取到这个时间呢?
CATransaction 的 setCompletionBlock: 方法会在 CATransaction commit 结束被调用,所以我们可以利用这个 API 获取完成第一个 CA::Transaction::commit 的时间:
1 | [CATransaction setCompletionBlock:^{ |
经过测试可以发现,该 Block 回调时间在首个 ViewController(例如 TabBarViewController) 的 viewWillAppear 和 viewDidAppear 之间,相对来说还是比较准确的。
关于 MetricKit 工具,除了系统自动收集上报前面提到的那些数据外,MetricKit 还会统计上报 OSSignpost 事件持续的时间,所以我们可以借助 OSSignpost 完成一些自定义指标的收集。MetricKit 还会在一天结束后,将过去 24 小时搜集的性能数据归集在一起,然后在下一次启动 App 后,在 delegate 的回调中提供给我们(开发阶段可以手动触发:Xcode > Debug > Simulate MetricKit Payloads),这些数据已经做了聚合计算,划分为 50 分位数、90 分位数和 95 分位数的统计数据,拿到这些数据后我们可以直接用柱状图展示。所以我们也可以借助 MetricKit 相关 API 完成关键数据的收集,关于 MetricKit 用法网上有较多资料,这里不再补充。
3、调试工具
(1) Xcode 打印 pre-main 时间
Xcode 提供了一个很便捷的方式打印 pre-main 阶段耗时情况,开启方式为:在 Edit scheme -> Run -> Arguments -> Environment Variables 中将环境变量 DYLD_PRINT_STATISTICS 设为 1,就可以看到 main 之前各个阶段的时间消耗。
1 | Total pre-main time: 80.58 milliseconds (100.0%) |
如果将将环境变量 DYLD_PRINT_STATISTICS_DETAILS 设为 1,可以看到更加详细的数据:
1 | total time: 2.9 seconds (100.0%) |
(2) App Launch
App Launch 是 Xcode 11 后新出的模板,同时包含了 Time Profile 以及 System Trace 的功能,可以用于查看 App 的启动过程,从而可以针对性的对启动速度进行优化,使用方式如下:
- 首先在 Xcode 的 build settings 中 Debug Information Format 设置为 DWARF with dsYM File (用于符号化地址)
- Xcode 编译运行项目
- 通过 Xcode –> Open Developer Tool –> Instruments –> APP Launch 启动应用,App Launch 会启动应用 5 秒后自动关闭应用。
之后就可以查看主线程被阻塞的情况,再借助 Time Profile 查看调用堆栈定位耗时任务进行优化即可。
(3) OSSignpost
OSSignpost 是 iOS 12 推出的可以用于在 instruments 里标记时间段的 API,性能非常高,可以认为对启动无影响:
1 |
|
其中 os_log_create 函数有两个参数:
- 第一个参数 subsystem:标识,反向 DNS 格式,例如 com.demo.signpost
- 第二个参数 category:分类
os_signpost_interval_begin 和 os_signpost_interval_end 用于标记开始和结束,第三个参数是给时间段的事件名。并且还可以增加第四、五个参数用于携带元数据,例如:
1 | os_signpost_interval_begin(log, spid, "task", "Start"); |
除此之外,还可以利用 os_signpost_event_emit 函数添加兴趣点,例如:
1 | os_signpost_event_emit(log, spid, "task", "testEmit"); |
除了 MetricKit 会统计使用 OSSignpost 的自定义事件时长数据外,我们还可以借助 Instruments 工具来查看这些数据,先看下 demo 内容:
1 | os_log_t log = os_log_create("com.demo.signpost", "mySignpost"); |
运行 demo,然后打开 Instruments 选择 Blank:
点击右上角加号,选择 os_signpost 双击或拖动到左边:
开始调试就可以看到相关数据了:
所以,可以借助这种方式调试启动过程中代码逻辑耗时情况。
(4) 火焰图
火焰图用来分析时间相关的性能瓶颈非常有用,可以直接把业务代码的耗时绘制出来。本质上是使用 hook objc_msgSend
或者编译期插桩在方法的开始和末尾打两个点,以计算这个方法的耗时,然后转换成 Chrome 的标准的 json 格式就可以分析了,例如:
1 | CFTimeInterval begin = CACurrentMediaTime(); |
可以看到上面是使用 CACurrentMediaTime()
获取的时间,那么这种方式与 NSDate
、CFAbsoluteTimeGetCurrent()
有何区别呢?
区别:
NSDate
属于 Foundation 框架、CFAbsoluteTimeGetCurrent()
属于 CoreFoundation框架、CACurrentMediaTime()
属于 QuartzCore 框架- QuartzCore 框架提供了图形处理和视频图像处理的能力,Core Animation 就属于 QuartzCore 框架
NSDate
或CFAbsoluteTimeGetCurrent()
返回网络时间同步的时钟时间。CACurrentMediaTime()
和mach_absolute_time()
是系统时间,是基于内建时钟的,能够更精确更原子化地测量,并且不会因为外部时间变化而变化(例如时区变化、夏时制、秒突变等),但它和系统的 uptime 有关,系统重启后CACurrentMediaTime()
会被重置。
应用场景:
NSDate
、CFAbsoluteTimeGetCurrent()
常用于日常时间、时间戳的表示,与服务器之间的数据交互,其中CFAbsoluteTimeGetCurrent()
相当于[[NSDate data] timeIntervalSinceReferenceDate]
;CACurrentMediaTime()
常用于测试代码的效率;
知道了如何计算代码的执行时间,那如何以可视化方式展示数据呢?有两种方式,一种是借助 Chrome 内置的 chrome://tracing 工具,我们将统计的数据格式转成该工具所需的 格式即可导入展示。
另一种方式是借助开源工具 AppleTrace,该工具会生成可直观展示数据的 html,实现原理和使用方式可以查看该 github 中贴出的文章《AppleTrace 性能分析工具》。该工具最终生成的火焰图效果如下:
二、优化实践
1、Pre Main 阶段优化
前面已经提到 Pre Main 阶段的任务:
- 加载动态库
- Rebase & Bind
- Objc setup
- Initializers
我们可以针对这些任务进行针对性优化:
(1) 减少动态库数量
减少动态库数量可以加减少 dyld 3 启动闭包创建和加载动态库阶段的耗时,官方建议动态库数量小于 6 个。减少动态库可以合并动态库或者将动态库转为静态库,相较而言,转为静态库在操作上更容易些,是首选方案。
cocoapods 默认使用的就是静态库,但是如果 Podfile 中加入 use_frameworks! 选项将会使用动态库。
(2) 动态库懒加载
实现动态库懒加载主要分为两步:
- 第一步:在 Build Phases 的 Link Binary With Libraries 中去掉需要懒加载的动态库;
- 第二步:运行时使用
dlopen
对动态库按需加载。
苹果系统 API <dlfcn.h>
中提供了几个操作动态库的方法,包括 dlopen
、dlerror
、dlsym
和 dlclose
等,我们可以使用 dlopen
对启动阶段不需要的动态库在运行时手动加载。
需要注意的是,使用 dlopen
懒加载动态库时,dlopen
会调用 dlopen_preflight
先校验动态库签名,如果动态库没有打包进 APP(和 APP 使用相同签名),真机会加载失败并报签名错误。所以动态库懒加载核心思想是使动态库只参与签名不参与链接,在需要动态库的时候我们手动加载动态库。
非系统的动态库路径是固定的,均位于 “xxx.app/Frameworks/“ 目录下,可以通过 [NSBundle mainBundle].bundlePath
获取 “xxx.app” 所在的目录,然后在必要时候手动加载动态库:
1 | - (char *)dlopenFramework:(NSString *)frameworkName { |
动态库懒加载也可以使用 [NSBundle loadAndReturnError:]
或 [NSBundle load]
这两个 API,他们本质上调用的是底层的 dlopen
,例如:
1 | - (BOOL)loadFramework:(NSString *)frameworkName { |
如果将动态库配置成懒加载,就无法直接调用其中的类和方法,因为其符号并不包含在运行内存中。面对这样的情况,我们可以把动态库中提供给外部调用的逻辑收口,使用特定的协议或接口,通过组件化框架在调用接口时尝试加载动态库,再进行实际逻辑的调用:
1 | - (Class<FeatureBridge>)getBridgeWithProtocol:(Protocol *)protocol { |
经验证,手动调用 dlopen
的耗时大于系统 dyld
加载耗时,所以需要避免在主线程直接调用 dlopen
操作。一个有效的解决手段是在启动阶段或者启动完成后,异步预加载所有懒加载的动态库。
对于使用 Cocoapods 管理的项目也可以配置动态库懒加载:在 pod install 之后,会生成 Pods-xxx-frameworks.sh 和 Pods-xxx.release.xcconfig 这两个文件,其中 Pods-xxx-frameworks.sh 文件脚本负责架构剔除和重签名等功能,而 Pods-xxx.xcconfig 文件则负责静态库和动态库的链接配置,我们自定义的动态库想要进行懒加载,只需要修改 xxx.xcconfig 配置文件,将需要懒加载的动态库从配置文件中移除,这样保证懒加载的动态库参与签名和拷贝,但是不参与链接。
需要注意的是使用动态库懒加载是有一定风险的,而且 Apple 官方也不建议懒加载动态库。懒加载动态库的前提是不做频繁升级和变更,但是一旦升级则可能引起灾难性的后果,因为 API 发生变更,编译能正常通过,因为编译是依赖协议进行的,API 发生了变更很可能负责升级的同学并没有对协议进行升级,可以在 debug 环境下利用 Runtime 对相关 API 进行检测。
(3) 移除无用代码
移除无用代码可以减少 Rebase & Bind & Runtime 初始化的耗时,可通过静态扫描方式查找无用代码,最容易的静态扫描是使用 AppCode,但是项目大了之后 AppCode 的索引速度非常慢,另外的一种静态扫描是基于 Mach-O 的:
_objc_selrefs
和_objc_classrefs
存储了引用到的sel
和class
__objc_classlist
存储了所有的sel
和class
二者做个差集就知道那些 class/sel 用不到,实现方式可以参考:objc_cover,由于 OC 支持运行时调用,删除之前还要再二次确认。
(4) +load 优化
过多的 +load
方法会拖慢启动速度,一种优化方式是将 +load
方法中的任务迁移到 +initialize
方法中:
1 | + (void)initialize { |
对于有些任务确实需要在启动的早期(+load
)里注册的,例如各个组件经常在 +load
方法中注册自己提供的 Service
,即 protocol
和 class 对应关系,可以使用 clang attribute 代替。Clang 提供了很多的编译器函数,它们可以完成不同的功能。其中一种就是 section()
函数,section()
函数提供了二进制段的读写能力,它可以将一些编译期就可以确定的常量写入数据段,例如:
1 | __attribute__((used, section ("xxx段,xxx节"))) |
通过使用 __attribute__((section("xxx段,xxx节")))
来指明将数据存储到哪个段哪个节。__attribute__((used))
告诉编译器该静态变量即使没有被使用也要保留,防止链接器在优化时将其删除。
借助 clang attribute 取代 +load
进行 Service
注册的 demo 如下:
1 |
|
使用时:
1 | ServiceRegister(AAAclass, AAAprotocol); |
取数据:
1 | // 1.根据符号找到所在的 mach-o 文件信息 |
打印结果:
1 | class:AAAclass,protocol:AAAprotocol |
我们使用 MachOView 工具查看 Mach-O 可以看到我们存储的数据:
(5) 减少静态初始化方法的使用
例如减少 __attribute__(constructor)
的使用,这里没有太多需要总结的。
(使用 __attribute__((constructor))
修饰的函数可以在 main 函数之前调用)。
2、After Main 阶段优化
After Main 阶段主要耗时的地方在于 didFinishLuanching
中的大量启动项,比如 SDK 注册、业务初始化等。所以这一阶段最佳优化方式是对启动项集中管理,并且根据启动项的重要和紧急程度,提供三种启动时机:
- 同步启动
- 异步启动
- 首屏渲染完成后启动
当然也可以根据自己 APP 需求提供其他启动时机,对于那些不是立刻能用到的 SDK 或者某些任务,比如微信分享 SDK,放到首屏渲染完成后启动,这样能有效减少启动项带来的耗时。
对于启动项注册主要有两种方式:
- .plist 中集中注册启动项;
- 启动项自注册,即由需要设置启动项的组件自行注册启动项;
对于 .plist 中集中注册启动项的方式,启动项、组件之间产生了耦合,当这个组件被另个 APP 依赖的时候,又需要重新注册启动项,启动项注册逻辑无法复用。而启动项自注册的方式,恰好解决了这个问题。那组件如何自行注册启动项呢?一般是在 +load 方法中进行注册,但是 +load 方法会增加 Pre Main 阶段耗时,所以应当使用前面提到的 clang attribute 相关函数进行注册。
为了准确知道各启动项耗时情况,还应当对每个启动项进行监控,记录耗时时长并进行上报,在开发阶段和上线后跟踪启动项的耗时,及时进行优化。对于获取启动项耗时可以使用前面提到的在任务开始、结束分别使用 CACurrentMediaTime()
获取时间计算差值即可。
而对于首页渲染耗时,则需要根据业务逻辑进行优化,例如缓存必要数据、子线程预加载动画文件等。
- 本文章采用 知识共享署名 4.0 国际许可协议 进行许可,完整转载、部分转载、图片转载时均请注明原文链接。