iOS 应用启动耗时分析及优化
App启动流程
-
main函数之前
-
1.iOS系统首先会加载解析该APP的Info.plist文件,因为Info.plist文件中包含了支持APP加载运行所需要的众多Key,value配置信息,例如APP的运行条件(Required device capabilities),是否全屏,APP启动图信息等。
-
2.创建沙盒(iOS8后,每次启动APP都会生成一个新的沙盒路径)
-
3.根据Info.plist的配置检查相应权限状态
-
4.加载Mach-O文件读取dyld路径并运行dyld动态连接器(内核加载了主程序,dyld只会负责动态库的加载)
- 4.1 首先dyld会寻找合适的CPU运行环境
- 4.2 然后加载程序运行所需的依赖库和我们自己写的.h.m文件编译成的.o可执行文件,并对这些库进行链接。
- 4.3 加载所有方法(runtime就是在这个时候被初始化的)
- 4.4 加载C函数
- 4.5 加载category的扩展(此时runtime会对所有类结构进行初始化)
- 4.6 加载C++静态函数,加载OC+load
- 4.7 最后dyld返回main函数地址,main函数被调用
-
-
main函数执行
-
首屏渲染完成
-
执行UIApplicationMain
-
创建UIApplication对象
-
创建UIApplication的delegate对象
-
创建MainRunloop
-
delegate对象开始处理(监听)系统事件(没有storyboard)
-
-
根据Info.plist获得最主要storyboard的文件名,加载最主要的storyboard(有storyboard)
-
程序启动完毕的时候, 就会调用代理的application:didFinishLaunchingWithOptions:方法 在application:didFinishLaunchingWithOptions:中创建UIWindow 创建和设置UIWindow的rootViewController
-
显示第一个窗口
-
各阶段耗时原因
Main函数之前阶段
-
动态库加载越多,启动越慢。
减少非系统库的依赖
合并非系统库
使用静态资源,比如把代码加入主程序
-
ObjC类越多,启动越慢
减少Objc类数量, 减少selector数量
-
C的
constructor
函数越多,启动越慢减少C++虚函数数量
-
C++静态对象越多,启动越慢
使用 struct(其实本质上就是为了减少符号的数量)
-
ObjC的
+load
越多,启动越慢减少load方法内的逻辑,在swift中已经拒绝开发者使用+load方法,推荐initializer
-
冷启动时cache hit越少,启动越慢
二进制重排
Main函数之后
-
执行
applicationWillFinishLaunching
的耗时,内容越多越慢使用
延后任务管理
解决 -
第一屏渲染速度
延后和第一屏显示无关的业务逻辑
延后任务管理:监听主线程 runloop,在kCFRunloopBeforeWaiting 时执行, KCFRunloopAfterWaiting时停止(闲时主线程队列),或者异步线程执行。
分析app耗时分析方法
app启动过程中的耗时分析
方法1:
在Xcode
的菜单中选择Project
→Scheme
→Edit Scheme...
,然后找到 Run
→ Environment Variables
→+
,添加name
为DYLD_PRINT_STATISTICS
value
为1
的环境变量。
了解main函数之前各个阶段的方法耗时,就能针对耗时大的阶段使用相应前面提到的解决方案。
main()
函数之前总共使用了165.85ms
中,加载动态库用了120.63ms
,指针重定位
使用了4.74ms
,ObjC类
初始化使用了8.00ms
,各种初始化可执行文件使用了32.74ms
,用时最多的几个初始化是libSystem.B.dylib
、libBacktraceRecording.dylib
、libMainThreadChecker.dylib
以及Module
。
可以看到在加载动态库中用时最多。
方法2:
上图中Module
模块是主程序的可执行文件,如果这个阶段用时很多,可以查看缺页中断(Page Fault)
发生的次数,因为缺页中断(Page Fault)
相对来说会耗费大量时间,这需要了解虚拟内存的工作原理。
如果想查看真实 Page Fault 次数 , 应该将应用卸载 , 查看第一次应用安装后的效果 , 或者先打开很多个其他应用 .
因为之前运行过 app
, 应用其中一部分已经被加载到物理内存并做好映射表映射 , 这时再启动就会少触发一部分缺页中断 , 并且杀掉应用再打开也是如此 .
其实就是希望将物理内存中之前加载的覆盖/清理掉 , 减少误差 .
- 打开Instruments 工具中是 System Trace.
- 选择真机和工程,点击左侧启动,当工程首页加载完成后点击停止。最好是将应用杀掉重新安装 , 因为冷热启动的界定其实由于进程的原因并不一定后台杀掉应用重新打开就是冷启动 .
- 等分析完成,查看缺页次数
这次缺页发生了1509次,耗时200ms,占总耗时的93%。缓存命中1955次只耗时6.42ms,可见缺页造成的耗时很大。
这里需要用到二进制重排去优化。怎样查看main函数之前到底调用了哪些OC类和方法?可以通过clang插桩方式,详见iOS 优化篇 - 启动优化之Clang插桩实现二进制重排
clang静态插桩实际上是在编译期就在每一个函数内部二进制源数据添加 hook 代码 ( 我们添加的 __sanitizer_cov_trace_pc_guard 函数 ) 来实现全局的方法 hook 的效果 .
在build settings的 Other C Flags中, 添加
-fsanitize-coverage=func,trace-pc-guard
添加hook代码
void __sanitizer_cov_trace_pc_guard_init(uint32_t *start,
uint32_t *stop) {
static uint32_t N; // Counter for the guards.
if (start == stop || *start)return; // Initialize only once.
printf("INIT: %p %p\n", start, stop);
for (uint32_t *x = start; x < stop; x++)
*x = ++N; // Guards should start from 1.
printf("totasl count %i\n", N);
}
// 所以在每个函数调用时都会先跳转执行该函数
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
// +load方法先于guard_init调用,此时guard为0
// if(!*guard) { return }
if (stopCollecting) {
return;
}
// __builtin_return_address 获取当前调用栈信息,取第一帧地址(即下条要执行的指令地址,被插桩的函数地址)
void *PC = __builtin_return_address(0);
PointerNode *node = malloc(sizeof(PointerNode));
*node = (PointerNode){PC, NULL};
// 使用原子队列要存储帧地址
OSAtomicEnqueue(&qHead, node, offsetof(PointerNode, next));
}
就能拿到调用的所有方法,包括main函数之前的方法。将函数保存成.order ,并在settings的 Order File
中设置文件路径。再次编译查看link map就能看出,.order中的方法全部被编译在一起,减少缺页的发生。
app启动后各个方法耗时
OC中每个方法的调用最终都是会走到objc_msgSend
中,所以如果我们Hook了objc_msgSend
则能以最小改动计算每个方法的耗时。
详细可见这篇介绍iOS底层探索 - 通过objc_msgSend实现iOS方法耗时监控以及用到的fishhook的原理iOS源码解析: 聊一聊iOS中的hook方案