iOS启动分析(一)

442 阅读13分钟

前言

启动优化一役后,超预期将所负责的 App 双端启动的耗时都降低了65%以上,iOS 在iPhone7上速度达到了400毫秒以内。就像产品们用后说的,快到不习惯。由于 App 日活用户过亿,算一下每天为用户省下的时间,还是蛮有成就感的。

启动阶段性能多维度分析

要优化,先要做到的是对启动阶段各个性能纬度做分析,包括主线程耗时、CPU、内存、I/O、网络。这样才能够更加全面的掌握启动阶段的开销,找出不合理的方法调用。启动越快,更多的方法调用就应该做成按需执行,将启动压力分摊,只留下那些启动后方法都会依赖的方法和库的初始化,比如网络库、Crash 库等。而剩下那些需要预加载的功能可以放到启动阶段后再执行。

启动有哪几种类型,启动有哪些阶段呢?

启动类型分为:

  • Cold:App 重启后启动,不在内存里也没有进程存在。
  • Warm:App 最近结束后再启动,有部分在内存但没有进程存在。
  • Resume:App 没结束,只是暂停,全在内存中,进程也存在。

分析阶段一般都是针对 Cold 类型进行分析,目的就是要让测试环境稳定。为了稳定测试环境有时还需要找些稳定的机型,对于 iOS 来说iPhone7性能中等,稳定性也不错就很适合,Android 的 Vivo 系列也相对稳定,华为和小米系列数据波动就比较大。除了机型外控制测试机温度也很重要,一旦温度过高系统还会降频执行影响测试数据。有时候还会置飞行模式采用 Mock 网络请求的方式来减少不稳定的网络影响测试数据。最好时重启后退 iCloud 账号,放置一段时间再测,更加准确些。

了解启动的阶段目的就是聚焦范围,从用户体验上来确定哪个阶段要快,以便能够让用户可视和响应用户操作的时间更快。

简单来说 iOS 启动分为加载 Mach-O 和运行时初始化过程,加载 Mach-O 会先判断加载的文件是不是 Mach-O,通过文件第一个字节,也叫魔数来判断,当是下面四种时可以判定是 Mach-O 文件:

  • 0xfeedface 对应的 loader.h 里的宏是 MH_MAGIC
  • 0xfeedfact 宏是 MH_MAGIC_64
  • NXSwapInt(MH_MAGIC) 宏 MH_GIGAM
  • NXSwapInt(MH_MAGIC_64) 宏 MH_GIGAM_64

Mach-O 分为主要分为 中间对象文件(MH_OBJECT)、可执行二进制(MH_EXECUTE)、VM 共享库文件(MH_FVMLIB)、Crash 产生的 Core 文件(MH_CORE)、preload(MH_PRELOAD)、动态共享库(MH_DYLIB)、动态链接器(MH_DYLINKER)、静态链接文件(MH_DYLIB_STUB)、符号文件和调试信息(MH_DSYM)这几种。确定是 Mach-O 后,内核会 fork 一个进程,execve 开始加载。检查 Mach-O Header。随后加载 dyld 和程序到 Load Command 地址空间。通过 dyld_stub_binder 开始执行 dyld,dyld 会进行 rebase、binding、lazy binding、导出符号,也可以通过 DYLD_INSERT_LIBRARIES 进行 hook。dyld_stub_binder 给偏移量到 dyld 解释特殊字节码 Segment 中,也就是真实地址,把真实地址写入到 la_symbol_ptr 里,跳转时通过 stub 的 jump 指令跳转到真实地址。 dyld 加载所有依赖库,将动态库导出的 trie 结构符号执行符号绑定,也就是 non lazybinding,绑定解析其他模块功能和数据引用过程,就是导入符号。

作为一个开发者,有一个学习的氛围跟一个交流圈子特别重要,这有个iOS交流群:642363427,不管你是小白还是大牛欢迎入驻 ,分享BAT,阿里面试题、面试经验,讨论技术,iOS开发者一起交流学习成长!

Trie 也叫数字树或前缀树,是一种搜索树。查找复杂度 O(m),m 是字符串的长度。和散列表相比,散列最差复杂度是 O(N),一般都是 O(1),用 O(m)时间评估 hash。散列缺点是会分配一大块内存,内容越多所占内存越大。Trie 不仅查找快,插入和删除都很快,适合存储预测性文本或自动完成词典。为了进一步优化所占空间,可以将 Trie 这种树形的确定性有限自动机压缩成确定性非循环有限状态自动体(DAFSA),其空间小,做法是会压缩相同分支。对于更大内容,还可以做更进一步的优化,比如使用字母缩减的实现技术,把原来的字符串重新解释为较长的字符串;使用单链式列表,节点设计为由符号、子节点、下一个节点来表示;将字母表数组存储为代表 ASCII 字母表的256位的位图。

尽管 Trie 对于性能会做很多优化,但是符号过多依然会增加性能消耗,对于动态库导出的符号不宜太多,尽量保持公共符号少,私有符号集丰富。这样维护起来也方便,版本兼容性也好,还能优化动态加载程序到进程的时间。

然后执行 attribute 的 constructor 函数。举个例子:

#include <stdio.h>

__attribute__((constructor))
static void prepare() {
    printf("%s\n", "prepare");
}

__attribute__((destructor))
static void end() {
    printf("%s\n", "end");
}

void showHeader() { 
    printf("%s\n", "header");
}

运行结果:

ming@mingdeMacBook-Pro macho_demo % ./main "hi"
prepare
hi
end

运行时初始化过程 分为:

  • 加载类扩展
  • 加载 C++静态对象
  • 调用+load 函数
  • 执行 main 函数
  • Application 初始化,到 applicationDidFinishLaunchingWithOptions 执行完
  • 初始化帧渲染,到 viewDidAppear 执行完,用户可见可操作。

过程概括起来如下图:

也就是说对启动阶段的分析以 viewDidAppear 为截止。这次优化之前已经对 Application 初始化之前做过优化,效果并不明显,没有本质的提高,所以这次主要针对 Application 初始化到 viewDidAppear 这个阶段各个性能多纬度进行分析。多维度具体包含内容如下图:

工具的选择其实目前看来是很多的,Apple 提供的 System Trace 会提供全面系统的行为,可以显示底层系统线程和内存调度情况,分析锁、线程、内存、系统调用等问题。总的来说,通过 System Trace 你能清楚知道每时每刻 App 对系统资源使用情况。

System Trace 能查看线程的状态,可以了解高优线程使用相对于 CPU 数量是否合理,可以看到线程在执行、挂起、上下文切换、被打断还是被抢占的情况。虚拟内存使用产生的耗时也能看到,比如分配物理内存,内存解压缩,无缓存时进行缓存的耗时等。甚至是发热情况也能看到。

System Trace 还提供手动打点进行信息显式,在你的代码中 导入 sys/kdebug_signpost.h 后,配对 kdebug_signpost_start 和 kdebug_signpost_end 就可以了。这两个方法有五个参数,第一个是 id,最后一个是颜色,中间都是预留字段。

Xcode11开始 XCTest 还提供了测量性能的 Api。苹果在2019年 WWDC 启动优化专题 Optimizing App Launch - WWDC 2019 - Videos - Apple Developer 上也介绍了 Instruments 里的最新模板 App launch 如何分析启动性能。但是要想达到对启动数据进行留存取均值、Diff、过滤、关联分析等自动化操作,App launch 目前还没法做到。

主线程耗时

多个维度性能纬度分析中最重要,最终用户体感到的是主线程耗时分析。对主线程方法耗时可以直接使用Messier - 简单易用的Objective-C方法跟踪工具 - everettjf - 首先很有趣 生成 trace json 进行分析,或者参看这个代码GCDFetchFeed/SMCallTraceCore.c at master · ming1016/GCDFetchFeed · GitHub,自己手动 hook objc_msgSend 生成一份Objective-C 方法耗时数据进行分析。还有种插桩方式,可以解析 IR(加快编译速度),然后在每个方法前后插入耗时统计函数。文章后面我会着重介绍如何开发工具进一步分析这份数据,以达到监控启动阶段方法耗时的目的。

hook 所有的方法调用,对详细分析时很有用,不过对于整个启动时间影响很大,要想获取启动每个阶段更准确的时间消耗还需要依赖手动埋点。为了更好的分析启动耗时问题,手动埋点也会埋的越来越多,也会影响启动时间精确度,特别是当团队很多,模块很多时,问题会突出。但,每个团队在排查启动耗时往往只会关注自己或相关某几个模块的分析,基于此,可以把不同模块埋点分组,灵活组合,这样就可以照顾到多种需求了。

CPU

为什么分析启动慢除了分析主线程方法耗时外,还要分析其它纬度的性能呢?

我们先看看启动慢的表现,启动慢意味着界面响应慢、网络慢(数据量大、请求数多)、CPU 超负荷降频(并行任务多、运算多),可以看出影响启动的因素很多,还需要全面考虑。

对于 CPU 来说,WWDC 的 What’s New in Energy Debugging - WWDC 2018 - Videos - Apple Developer 里介绍了用 Energy Log 来查 CPU 耗电,当前台三分钟或后台一分钟 CPU 线程连续占用80%以上就判定为耗电,同时记录耗电线程堆栈供分析。还有一个 MetrickKit 专门用来收集电源和性能统计数据,每24小时就会对收集的数据进行汇总上报,Mattt 在 NShipster 网站上也发了篇文章MetricKit - NSHipster专门进行了介绍。那么 CPU 的详细使用情况如何获取呢?也就是说哪个方法用了多少 CPU。

有好几种获取详细 CPU 使用情况的方法。线程是计算机资源调度和分配的基本单位。CPU 使用情况会提现到线程这样的基本单位上。task_theads 的 act_list 数组包含所有线程,使用 thread_info 的接口可以返回线程的基本信息,这些信息定义在 thread_basic_info_t 结构体中。这个结构体内的信息包含了线程运行时间、运行状态以及调度优先级,其中也包含了 CPU 使用信息 cpu_usage。获取方式参看 objective c - Get detailed iOS CPU usage with different states - Stack Overflow。GT GitHub - Tencent/GT: GT (Great Tit) is a portable debugging tool for bug hunting and performance tuning on smartphones anytime and anywhere just as listening music with Walkman. GT can act as the Integrated Debug Environment by directly running on smartphones. 里也有获取 CPU 的代码。

整体 CPU 占用率可以通过 host_statistics 函数可以取到 host_cpu_load_info,其中 cpu_ticks 数组是 CPU 运行的时钟脉冲数量。通过 cpu_ticks 数组里的状态,可以分别获取 CPU_STATE_USER、CPU_STATE_NICE、CPU_STATE_SYSTEM 这三个表示使用中的状态,除以整体 CPU 就可以取到 CPU 的占比。通过 NSProcessInfo 的 activeProcessorCount 还可以得到 CPU 的核数。线上数据分析时会发现相同机型和系统的手机,性能表现却截然不同,这是由于手机过热或者电池损耗过大后系统降低了 CPU 频率所致。所以如果取得 CPU 频率后也可以针对那些降频的手机来进行针对性的优化,以保证流畅体验。获取方式可以参考 GitHub - zenny-chen/CPU-Dasher-for-iOS: CPU Dasher for iOS source code. It only supports ARMv7 and ARMv7s architectures.

内存

要想获取 App 真实的内存使用情况可以参看 WebKit 的源码,webkit/MemoryFootprintCocoa.cpp at 52bc6f0a96a062cb0eb76e9a81497183dc87c268 · WebKit/webkit · GitHub 。JetSam会判断 App 使用内存情况,超出阈值就会杀死 App,JetSam 获取阈值的代码在 darwin-xnu/kern_memorystatus.c at 0a798f6738bc1db01281fc08ae024145e84df927 · apple/darwin-xnu · GitHub。整个设备物理内存大小可以通过 NSProcessInfo 的 physicalMemory 来获取。

网络

对于网络监控可以使用 Fishhook 这样的工具 Hook 网络底层库 CFNetwork。网络的情况比较复杂,所以需要定些和时间相关的关键的指标,指标如下:

  • DNS 时间
  • SSL 时间
  • 首包时间
  • 响应时间

有了这些指标才能够有助于更好的分析网络问题。启动阶段的网络请求是非常多的,所以 HTTP 的性能是非常要注意的。以下是 WWDC 网络相关的 Session:

I/O

对于 I/O 可以使用 Frida • A world-class dynamic instrumentation framework | Inject JavaScript to explore native apps on Windows, macOS, GNU/Linux, iOS, Android, and QNX 这种动态二进制插桩技术,在程序运行时去插入自定义代码获取 I/O 的耗时和处理的数据大小等数据。Frida 还能够在其它平台使用。

关于多维度分析更多的资料可以看看历届 WWDC 的介绍。下面我列下16年来 WWDC 关于启动优化的 Session,每场都很精彩。

延后任务管理

经过前面所说的对主线程耗时方法和各个纬度性能分析后,对于那些分析出来没必要在启动阶段执行的方法,可以做成按需或延后执行。 任务延后的处理不能粗犷的一口气在启动完成后在主线程一起执行,那样用户仅仅只是看到了页面,依然没法响应操作。那该怎么做呢?套路一般是这样,创建四个队列,分别是:

  • 异步串行队列
  • 异步并行队列
  • 闲时主线程串行队列
  • 闲时异步串行队列

有依赖关系的任务可以放到异步串行队列中执行。异步并行队列可以分组执行,比如使用 dispatch_group,然后对每组任务数量进行限制,避免 CPU、线程和内存瞬时激增影响主线程用户操作,定义有限数量的串行队列,每个串行队列做特定的事情,这样也能够避免性能消耗短时间突然暴涨引起无法响应用户操作。使用 dispatch_semaphore_t 在信号量阻塞主队列时容易出现优先级反转,需要减少使用,确保QoS传播。可以用dispatch group 替代,性能一样,功能不差。异步编程可以直接 GCD 接口来写,也可以使用阿里的协程框架 coobjc coobjc

闲时队列实现方式是监听主线程 runloop 状态,在 kCFRunLoopBeforeWaiting 时开始执行闲时队列里的任务,在 kCFRunLoopAfterWaiting 时停止。