一. 启动时间的定义
Tip: 我们知道iOS中的启动分为冷启动, 热启动, 后台回到前台三种形式, 我们这里只做冷启动的讨论.
启动时间有着不同的理解和定义, 大致可以分为2种
- 用户侧(广义): 点击图标 -> 首页数据加载完毕
- 开发侧(狭义): 进程创建 -> Launch Image完全消失后的第一帧
对于第一帧的概念, 之前理解的是 rootViewController的viewDidApper完成, 其实苹果在MetricKit中给出了官方定义: 第一个CA::Transaction::commit()
Tip: commit()是Render Server中的一个关键流程, 具体可以参考 laoqingcai.com/ios-screen-…
二. 启动过程的各个阶段
2.1 加载dyld
Tip: iOS12及之前是dyld2, 之后是dyld3. dyld3最重要的特性是启动闭包. 启动闭包其实是App的缓存, 可以直接加快启动速度
- 加载dyld 用户点击icon后, 系统会创建相关进程, 然后将二进制文件读取到内存, 然后读取LC_LOAD_DYLINKER, 找到dyld的路径, 将dyld加载到虚拟内存, 找到dyld的入口函数. 然后剩下的App启动就会交给dyld来做
- 创建启动闭包
dyld会在重启/更新/下载App后的第一次创建启动闭包, 闭包实际是一个缓存, 用来提升启动速度. 主要包含了:
- dependends,依赖动态库列表
- fixup:bind & rebase 的地址
- initializer-order:初始化调用顺序
- optimizeObjc: Objective C 的元数据
- 其他:main entry, uuid…
2.2 rebase & bind
- rebase 因为ASLR(Address space layout randomization: 通过对虚拟内存头部添加随机的地址空间偏移, 可以让进程数据更加安全)的存在, 所以的符号地址都需要将原地址进行一次偏移来得到真正的地址.
let str = "123" // 0x1000
// ASLR的偏移量是 0x10
// rebase就是要将 str的地址改为 0x1010
- bind 因为在代码中有很外部函数的调用, bind就是在运行时将正确的指针与符号对应
print("123")
// print函数是一个库函数, 只有在运行时才能知道这个函数的具体地址是多少. bind就是将具体的地址与代码段中的符号进行绑定
-
2.3 Objc Setup 初始化objc runtime, 注册sel, 加载category等. 这里没有加载Objc类方法等信息是因为启动闭包已经缓存了
-
2.4 Load & Static initializer 进行最后的初始化, 主要是+load方法以及Static initializer
-
2.5 main函数执行 dyld此时会将启动流程交给App执行main函数, 主要是初始化UIKit, 包括UIApplication, 启动主线程Runloop
-
2.6 LifeCycle UIKit启动后, 我们就可以拿到UIApplicationDelegate回调
Tip: LifeCycle的方法是基于Runloop的Source0, 所以main()中启动的Runloop非常重要. 我们可以根据Runloop做一些事情
- 2.7 First Frame Render Server将第一帧的数据渲染, 启动结束
三. 启动时间的监控
- 3.1 线程启动的时间 可以使用系统函数sysctrl来获取
+ (CFAbsoluteTime)processStartTime {
if (__t1 == 0) {
struct kinfo_proc procInfo;
int pid = [[NSProcessInfo processInfo] processIdentifier];
int cmd[4] = {CTL_KERN, KERN_PROC, KERN_PROC_PID, pid};
size_t size = sizeof(procInfo);
if (sysctl(cmd, sizeof(cmd)/sizeof(*cmd), &procInfo, &size, NULL, 0) == 0) {
__t1 = procInfo.kp_proc.p_un.__p_starttime.tv_sec * 1000.0 + procInfo.kp_proc.p_un.__p_starttime.tv_usec / 1000.0;
}
}
return __t1;
}
- 3.2 第一个dyld执行load的开始时间 因为静态库是按顺序加载的, 我们可以在pods库中添加一个AAA开头的库, 保证他是第一个加载
@implementation AAALaunchTime
+ (void)load {
double time = CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970;
NSLog(@"----------App启动---------第一个Post时间: %f",time);
}
@end
- 3.3 main函数开始执行的时间 可以使用__attribute__((constructor))来进行获取
void static __attribute__((constructor)) before_main() {
if (__t2 == 0) {
__t2 = CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970;
}
NSLog(@"----------App启动---------Main开始时间: %f", __t2);
}
constructor / destructor / cleanup 相关文档 www.jianshu.com/p/29eb7b5c8…
- 3.4 didFinishLaunch开始的时间 直接在方法开始时计算
double time = CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970;
NSLog(@"----------App启动---------FinishLaunch开始时间: %f",time);
-
3.5 FirstFrame时间
-
- rootViewController的viewDidApper中记录 (有很大的侵入性)
-
- 寻找离CA::Transaction::commit()最近的时间点
参考抖音发布的文章, 发现 commit()和主线程Runloop有下边的关系CFRunLoopPerformBlock -> CA::Transaction::commit() -> kCFRunLoopBeforeTimers
// Timers CFRunLoopRef mainRunloop = [[NSRunLoop mainRunLoop] getCFRunLoop]; CFRunLoopActivity activities = kCFRunLoopAllActivities; CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler(kCFAllocatorDefault, activities, YES, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) { if (activity == kCFRunLoopBeforeTimers) { double time = CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970; NSLog(@"----------App启动---------BeforeTimers时间: %f",time); CFRunLoopRemoveObserver(mainRunloop, observer, kCFRunLoopCommonModes); } }); CFRunLoopAddObserver(mainRunloop, observer, kCFRunLoopCommonModes); // block CFRunLoopPerformBlock(mainRunloop,NSDefaultRunLoopMode,^(){ double time = CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970; NSLog(@"----------App启动---------PerformBlock时间: %f",time); });-
- ai-chan.top/code/launch… 这个大佬是用hook的方法来获取到与Commit比较近的一个时间点
-
-
3.6 测试
线程启动时间: 1645524638.134531
第一个load时间: 1645524641.022360
Main开始时间: 1645524641.136349
FinishLaunch开始时间: 1645524641.336565
BeforeTimers时间: 1645524641.925697
PerformBlock时间: 1645524641.925818
hookSend开始时间: 1645524642.330300
viewDidAppear时间: 1645524642.6753511
线程启动时间: 1646135630.267325
第一个Post时间: 1646135631.050456
Main开始时间: 1646135631.051349
FinishLaunch开始时间: 1646135631.092039
BeforeTimers时间: 1646135631.095622
PerformBlock时间: 1646135631.095717
hookSend开始时间: 1646135631.104491
viewdidAppear时间: 1646135631.105872
四. 其他监控手段
- 4.1 iOS15之前 DYLD_PRINT_STATISTICS 可以获取pre-main的时间
Total pre-main time: 345.41 milliseconds (100.0%)
dylib loading time: 29.70 milliseconds (8.6%)
rebase/binding time: 23.67 milliseconds (6.8%)
ObjC setup time: 23.26 milliseconds (6.7%)
initializer time: 268.77 milliseconds (77.8%)
slowest intializers :
libSystem.B.dylib : 4.65 milliseconds (1.3%)
libBacktraceRecording.dylib : 8.28 milliseconds (2.3%)
libMainThreadChecker.dylib : 49.42 milliseconds (14.3%)
libMTLCapture.dylib : 19.33 milliseconds (5.5%)
CamExam : 296.20 milliseconds (85.7%)
-
4.2 Instruments的App Launch工具
-
4.3 UITest
UITest会启动6次, 并且跳过第一次的值取5次获取平均值
self.measure(metrics: [XCTApplicationLaunchMetric()]) {
XCUIApplication.init().launch()
}
// Test Case '-[CamExamUITests.CamExamUITestsLaunchTests testLaunch]' measured [Duration (AppLaunch), s] average: 2.507, relative standard deviation: 2.177%, values: [2.484232, 2.459668, 2.551949, 2.450120, 2.589970]
- 4.4 Xcode Organizer (数据量大以后会有)
- 4.5 MetricKit 官方的性能监控库, 包含启动、电量、内存等
五. 优化方案
根据启动过程中的各个阶段, 做对应的处理进行优化
- 5.1 main之前
- 动态库: 减少/合并动态库, 官方建议小于6个
- rebase&bind: 减少无用代码
- +load(): 做迁移
- 5.2 main之后
- didFinishLaunch: 根据业务调整
- 5.3 首屏渲染
- rootVC的视图: 根据业务调整
- 5.4 其他
- Page In: 段重命名
- 二进制重排: juejin.cn/post/695528…
六. 参考资料
-
iOS渲染相关 laoqingcai.com/ios-screen-…
-
抖音品质建设 - iOS启动优化《实战篇》: url.cy/Kg1tz3
-
CoderStar - iOS 启动优化 juejin.cn/post/705193…
-
酷酷的哀殿 - 如何监控 iOS 的启动耗时 ai-chan.top/code/launch…