iOS 启动速度优化
数据统计
在做优化前应将应用当前的耗时情况进行完善的统计,以便后续通过数据发现问题,以及对比优化效果。
我们的统计工作分位两个维度,一个是启动耗时的分段耗时及总耗时的统计,另一个是针对具体的启动项或函数的耗时进行统计及上报。
启动耗时统计
我们将启动耗时分成两个阶段,第一阶段从系统创建 APP 进程(用户点击图标)到 main
函数开始执行,即 pre-main 阶段。第二阶段即从 didFinishLaunching
开始执行到首页渲染完毕。
至于 main
函数到 didFinishLaunching
开始这段耗时根据我们的统计占用耗时较少(约为几十毫秒)且浮动不大,因此将第二阶段的起点放在 didFinishLaunching
的开始,但在统计总体耗时的时候还是会将其包括在内。
Pre-main 耗时统计
在调试时可以通过增加 DYLD_PRINT_STATISTICS
和 DYLD_PRINT_STATISTICS_DETAILS
来打印出 dyld 的启动耗时,包括了各阶段的耗时及链接的动态库列表,但该耗时不包括加载 dyld 及之前的耗时。可以作为优化时的参考。
对于数据的上报,我们通过在 main
函数执行时通过 sysctl
函数去拿到进程创建的时间,具体如下:1
2
3
4
5
6
7
8
9
10
11
12
13+ (BOOL)processInfoForPID:(int)pid procInfo:(struct kinfo_proc*)procInfo {
int cmd[4] = {CTL_KERN, KERN_PROC, KERN_PROC_PID, pid};
size_t size = sizeof(*procInfo);
return sysctl(cmd, sizeof(cmd)/sizeof(*cmd), procInfo, &size, NULL, 0) == 0;
}
+ (NSTimeInterval)processStartTime {
struct kinfo_proc kProcInfo;
if ([self processInfoForPID:[[NSProcessInfo processInfo] processIdentifier] procInfo:&kProcInfo]) {
return kProcInfo.kp_proc.p_un.__p_starttime.tv_sec * 1000.0 + kProcInfo.kp_proc.p_un.__p_starttime.tv_usec / 1000.0;
}
return 0;
}
然后以 main
函数开始执行作为 Pre-main 阶段的终点进行数据上报。
After-main 耗时统计
以 didFinishLaunching
开始执行为起点,到首页渲染完成为终点。这里的终点会有几种打点方式,之前我们以首页的 viewDidAppear
首次调用作为首页渲染完成的标志,并在优化过程中将非必要启动项置于该方法的回调之后。但经过几个版本的数据分析(项目接入的 APM 监控)发现这块耗时会有较多的极端数据出现,且难以排查到具体原因和复现,因此我们决定修改该阶段终点的打点时机。
Apple 的 APM 统计 Metrics 框架将启动耗时的终点定在第一次 CA::Transaction::commit
。而抖音的方案是根据 iOS 的版本,iOS 12 及以下使用 viewDidAppear
,否则以 applicationDidBecomeActive
作为终点。
最终我们决定使用 Metrics 的做法,去监听 first CA::Transaction::commit
,方式是通过 Runloop 来监听:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15CFRunLoopRef mainRunloop = [[NSRunLoop mainRunLoop]getCFRunLoop];
if (@available(iOS 13.0, *)) {
CFRunLoopActivity activities = kCFRunLoopAllActivities;
CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler(kCFAllocatorDefault, activities, YES, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) {
if (activity == kCFRunLoopBeforeTimers) {
// 打点记录
CFRunLoopRemoveObserver(mainRunloop, observer, kCFRunLoopCommonModes);
}
});
CFRunLoopAddObserver(mainRunloop, observer, kCFRunLoopCommonModes);
} else {
CFRunLoopPerformBlock(mainRunloop, NSDefaultRunLoopMode, ^{
// 打点记录
});
}
系统对于界面更新的处理方式是当 UIView/CALayer
发生属性变动、层级变动或设置了 setNeedsLayout/setNeedsDisplay
时,该 UIView/CALayer
会被标记为待处理并被提交到一个全局容器中。然后在通过 Runloop 中监听 kCFRunLoopBeforeWaiting
和 kCFRunLoopExit
两个事件,来处理被标记的视图变化,具体是调用了 _ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv()
这个方法,其内部调用栈大致如下:1
2
3
4
5
6
7
8
9
10
11_ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv()
QuartzCore:CA::Transaction::observer_callback:
CA::Transaction::commit();
CA::Context::commit_transaction();
CA::Layer::layout_and_display_if_needed();
CA::Layer::layout_if_needed();
[CALayer layoutSublayers];
[UIView layoutSubviews];
CA::Layer::display_if_needed();
[CALayer display];
[UIView drawRect];
具体可参考 YY 关于 Runloop 的这篇文章。
因此通过监听 Runloop 的事件可以拿到与第一次 CA::Transaction::commit()
非常接近的时间点。但经过实际测试发现,不同系统的回调时机会有较大的差异,不能简单的监听 Runloop 的第一次 beforeWaiting
。例如:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23// iOS 13.1.3
kCFRunLoopBeforeTimers: 1088171.925382
kCFRunLoopBeforeTimers: 1088172.049286
kCFRunLoopBeforeTimers: 1088172.062607
kCFRunLoopBeforeTimers: 1088172.081497
kCFRunLoopBeforeTimers: 1088172.094928
kCFRunLoopBeforeTimers: 1088172.095982
kCFRunLoopBeforeTimers: 1088172.096286
kCFRunLoopBeforeTimers: 1088172.096508
kCFRunLoopBeforeWaiting: 1088172.096728
First View Did Appear: 1088172.154152
// iOS 14.3
First View Did Appear: 13255.515243
kCFRunLoopBeforeTimers: 13255.545548
kCFRunLoopBeforeTimers: 13257.364940
kCFRunLoopBeforeTimers: 13257.365692
kCFRunLoopBeforeTimers: 13257.380215
kCFRunLoopBeforeTimers: 13257.381132
kCFRunLoopBeforeTimers: 13257.381725
kCFRunLoopBeforeTimers: 13257.381765
kCFRunLoopBeforeTimers: 13257.381807
kCFRunLoopBeforeWaiting: 13257.381919
可以看到在 iOS 13 上的表现基本符合预期,在提交了 Transaction 后的某个时间点首页渲染完成并调起了 viewDidAppear
,但在 iOS 14 上,Runloop 的回调会晚于 viewDidAppear
。
因此在该阶段终点即整个启动阶段的终点的打点时机上,如果想要监听 first CA::Transaction::commit()
,需要对系统进行区分以获取最合理的时机,或者采用 first viewDidAppear
加 first applicationDidBecomeActive
的方式也是可以的。
分阶段优化
Pre-main 阶段
通过 DYLD_PRINT_STATISTICS
可以比较清楚的看到该阶段内做了哪些事情以及它们的耗时情况:1
2
3
4
5
6
7
8
9
10Total pre-main time: 948.13 milliseconds (100.0%)
dylib loading time: 145.53 milliseconds (15.3%)
rebase/binding time: 113.82 milliseconds (12.0%)
ObjC setup time: 165.37 milliseconds (17.4%)
initializer time: 523.40 milliseconds (55.2%)
slowest intializers :
libSystem.B.dylib : 5.35 milliseconds (0.5%)
libMainThreadChecker.dylib : 25.31 milliseconds (2.6%)
libglInterpose.dylib : 213.90 milliseconds (22.5%)
MyApp : 465.52 milliseconds (49.0%)
其实在 dylib loading 开始前,系统会创建进程并为其开辟内存空间,然后加载可执行文件。之后加载 dyld(本身也是动态库),创建启动闭包(dyld3 ,更新 App 或重启手机后),完成后 dyld 开始分析动态库依赖,并逐一加载,即 dylib loading time
开始。加载完成后需对动态库进行符号绑定,即 rebase/binding time
。因此动态库的数量是影响这段耗时的主要因素,包括系统库,而我们的项目已经全部采用静态库的方式。不但减少启动耗时,也可减少包体积。
之后的 ObjC setup time
的主要工作是 Class
注册、Category
注册(向类中插入方法)及方法唯一性检查等。这一段的优化工作主要在无用类及 Category
的检查上,之前常规的方案是通过 Mach-O 文件分析,取 classlist
与 classref
的差集,但这里推荐一个准确性更高的工具:WBBlades,通过分析 Mach-O 文件,解决了类的动态调用、类的继承关系和类内调用等问题。
initializer time
中主要是 +load()
方法调用、c++ 构造函数和静态全局变量的创建。而 +load()
方法除了本身的耗时,还会触发 Page in 读入物理内存以执行该函数。从上面的 log 可以看到这段耗时占到整个 Pre-main 耗时的一半以上,因此针对 +load()
方法的优化就是尽可能不用该方法,改为 +initialize()
等懒加载的方式。对于 c++ 的静态初始化,可将 static
变量迁移至方法内部以改为懒加载的方式,借鉴这篇的代码:1
2
3
4
5
6
7
8
9
10
11
12//Bad
namespace {
static const std::string bucket[] = {"apples", "pears", "meerkats"};
}
const std::string GetBucketThing(int i) {
return bucket[i];
}
//Good
std::string GetBucketThing(int i) {
static const std::string bucket[] = {"apples", "pears", "meerkats"};
return bucket[i];
}
After-main 阶段
该阶段的优化重点是启动项、首页的初始化及生命周期方法。
启动项
启动项的治理思路比较简单,将非必要的启动项通过置后、异步或懒加载等方式来减少耗时。
另外,可以针对启动项进行统一管理,如耗时统计,包括线下 log 及线上监控,在我们的优化过程中发现仅以本地 log 作为依据是无法完全覆盖所有的启动场景的,在上线了线上监控后发现某些启动项耗时会有很大的波动,出现较高的耗时。在了解启动项耗时的基本情况后,再对高耗时的启动项进行摸排,找到具体耗时原因进行优化。
启动项统一管理的作用还包括解决启动依赖、控制并发、更好的管控新增的启动项等。
首页耗时
这一阶段的主要工作包括首页及相关页面对子视图的加载渲染、网络请求、数据同步、各配置项的同步及组件的初始化等等。对于这段耗时的监控我们借助了 Instrument 的 Time Profiler 以及本地打点的方式。其中 Time Profiler 使用的时定期抓取主线程堆栈的方式,间隔为 1ms ,通过对线程快照的对比来进行耗时统计。如果觉得 Time Profiler 粒度太粗也可以自行设置间隔来抓取主线程的堆栈,如通过 Thread.callStackSymbols
或 __builtin_frame_address
取到当前栈帧的地址,通过 dladdr(UnsafeRawPointer(bitPattern: address), &i)
得到该栈帧的 dl_info
,该结构体如下:1
2
3
4
5
6
7
8public struct dl_info {
public var dli_fname: UnsafePointer<Int8>! //* Pathname of shared object *//
public var dli_fbase: UnsafeMutableRawPointer! //* Base address of shared object *//
public var dli_sname: UnsafePointer<Int8>! //* Name of nearest symbol *//
public var dli_saddr: UnsafeMutableRawPointer! //* Address of nearest symbol *//
public init()
public init(dli_fname: UnsafePointer<Int8>!, dli_fbase: UnsafeMutableRawPointer!, dli_sname: UnsafePointer<Int8>!, dli_saddr: UnsafeMutableRawPointer!)
}
其中dli_sname
即为符号名。最后通过 Frame Pointer
的偏移来遍历整个调用栈的符号。
另一种思路是直接对方法进行打点,比较容易想到的是 hook objc_msgsend
,但弊端是静态函数记录不到,如纯 Swift 方法。也可在编译期通过对代码进行插桩实现打点,如利用 Clang 提供的代码覆盖工具 SanitizerCoverage,可覆盖 OC/Swift/C/C++ 等语言。
具体的耗时情况及解决办法也是因项目而异的,比较通用的耗时点比如子视图的加载,在我们的项目中使用了 MJRefreshGifHeader
, 并在其初始化时从 bundle 中分别取了 15 至 20 张图片传至 GifHeader 中不同的状态,造成了大量的耗时。
其他
二进制重排
页缺失(英语:Page fault,又名硬错误、硬中断、分页错误、寻页缺失、缺页中断、页故障等)指的是当软件试图访问已映射在虚拟地址空间中,但是目前并未被加载在物理内存中的一个分页时,由中央处理器的内存管理单元所发出的中断。通常情况下,用于处理此中断的程序是操作系统的一部分。如果操作系统判断此次访问是有效的,那么操作系统会尝试将相关的分页从硬盘上的虚拟内存文件中调入内存。
每触发 Page fault 都会有一定的耗时(一次约 0.6~0.8 ms),当触发量足够多时其耗时也是比较可观的。具体消耗可通过 Instruments 的 System Trace 查看,注意这个消耗不是恒定的,受系统内存使用情况的影响,因此在测试前可重启手机,或打开多个 App 来尽量占用系统内存。
Xcode 在 Build Settings - Linking - Order File 中提供了一个设置 Order file 的路径。该文件是一个.order
后缀的文本文件,链接器会根据该文件改变其中符号在内存中的布局,且会置于对应 section 的开始,对于不存在的符号会选择忽略。
通过将启动过程中所使用的函数符号通过 order file 告知链接器进行重排,将它们尽量排在相邻靠前的内存页中,从而减少 Page fault 触发次数,进而减少启动耗时。
因此重排的终点就是生成覆盖面尽量全的 order file。
我们采用的方式是 Clang 提供的 SanitizerCoverage,也就是编译期插桩的方式。该方案的优势在于覆盖面广(OC/Swift/C++,method/function/block),侵入性小,开发成本少(有很多现成的方案可借鉴,实现都大同小异)。将启动到首页显示期间运行的函数符号记录在 order file 中,然后放入项目路径下,可通过 LinkMap 来对比使用前后的符号排列是否变化。
至于具体的优化效果,本地测试下来成效不是很明显。而且本身对二进制重排并没有抱多少期望,只是在做完基础优化工作后的额外尝试,效果有点算点吧~
__TEXT 段重命名
因为 App Store Connect 会对我们上传 Mach-O 的 Text 段进行加密,以防止代码的泄露。而在启动过程会触发大量的 Page in 动作,其中包括将 Text 段的页读入物理内存,这就需要先对其解密,从而产生耗时以及 Text 段的体积变大。(此过程已在 iOS 13 被优化,Page in 不需要再解密了)
如果你的应用仍支持 iOS 13 以下的版本,比如我们还在 iOS 9 😓 。可以尝试一下段重命名的方案。链接器 ld 的 -rename-section
命令可以将 Text 段的内容移至其它的段,从而规避解密的耗时。具体可看这篇,跟重排一样,不用抱太大希望,不过对包大小的优化效果还是有的。