1 前言
启动是App给用户的第一印象,一款App的启动速度,不单单是用户体验的事情,往往还决定了它能否获取更多的用户。所以到了一定阶段App的启动优化是必须要做的事情。App启动基本分为以下两种
1.1 冷启动
App 点击启动前,它的进程不在系统里,需要系统新创建一个进程分配给它启动的情况。这是一次完整的启动过程。
表现:App第一次启动,重启,更新等
1.2 热启动
App 在冷启动后用户将 App 退后台,在 App 的进程还在系统里的情况下,用户重新启动进入 App 的过程,这个过程做的事情非常少。
所以我们主要说道说道冷启动的优化
2 启动流程
2.1 APP启动都干了什么
要对启动速度进行优化,我们需要知道启动过程中的大致流程是什么,做了什么事情,是否能针对性优化。
下图是启动流程的详细分解
- 点击图标,创建进程
- mmap 主二进制,找到 dyld 的路径
- mmap dyld,把入口地址设为_dyld_start
dyld 是启动的辅助程序,是 in-process 的,即启动的时候会把 dyld 加载到进程的地址空间里,然后把后续的启动过程交给 dyld。dyld 主要有两个版本:dyld2 和 dyld3。
iOS 12之前主要是dyld2,iOS 13 开始 Apple 对三方 App 启用了 dyld3,dyld3 的最重要的特性就是启动闭包,闭包存储在沙盒的 tmp/com.apple.dyld 目录,清理缓存的时候切记不要清理这个目录。
闭包里主要有以下内容:
- dependends,依赖动态库列表
- fixup:bind & rebase 的地址
- initializer-order:初始化调用顺序
- optimizeObjc: Objective C 的元数据
- 其他:main entry, uuid等等
上图虚线之上的部分是out-of-process的,在App下载安装和版本更新的时候会去执行,直接从缓存中读取数据,加快加载速度
这些信息是每次启动都需要的,把信息存储到一个缓存文件就能避免每次都解析,尤其是 Objective-C 的运行时数据(Class/Method…)解析耗时, 所以对启动速度是一个优化提升
4.把没有加载的动态库 mmap 进来,动态库的数量会影响这个阶段
dyld从主执行文件的header获取到需要加载的所依赖动态库列表,然后它需要找到每个 dylib,而应用所依赖的 dylib 文件可能会再依赖其他 dylib,所以所需要加载的是动态库列表一个递归依赖的集合
5.对动态库集合循环load, mmap 加载到虚拟内存里,对每个 Mach-O 做 fixup,包括 Rebase 和 Bind。
对每个二进制做 bind 和 rebase,主要耗时在 Page In,影响 Page In 数量的是 objc 的元数据
- Rebase 在Image内部调整指针的指向。在过去,会把动态库加载到指定地址,所有指针和数据对于代码都是对的,而现在地址空间布局是随机化(ASLR),所以需要在原来的地址根据随机的偏移量做一下修正, 也就是说Mach-O 在 mmap 到虚拟内存的时候,起始地址会有一个随机的偏移量 slide,需要把内部的指针指向加上这个 slide.
- Bind 是把指针正确地指向Image外部的内容。这些指向外部的指针被符号(symbol)名称绑定,dyld需要去符号表里查找,找到symbol对应的实现, 像 printf 等外部函数,只有运行时才知道它的地址是什么,bind 就是把指针指向这个地址,这也是后面我们能用fishhook来hook一些动态符号的核心
如下图,编译的时候,字符串 1234 在__cstring的 0x10 处,所以 DATA 段的指针指向 0x10。但是 mmap 之后有一个偏移量 slide=0x1000,这时候字符串在运行时的地址就是 0x1010,那么 DATA 段的指针指向就不对了。Rebase 的过程就是把指针从 0x10,加上 slide 变成 0x1010。运行时类对象的地址已经知道了,bind 就是把 isa 指向实际的内存地址。
6.初始化 objc 的 runtime,由于闭包已经初始化了大部分,这里只会注册 sel 和装载 category
7.+load 和静态初始化被调用,除了方法本身耗时,这里可能还会引起大量 Page In,如果调用了dispatch_async则会延迟启动后的runloop开启后执行,如果触发静态初始化,则会延迟到运行时执行
8.初始化 UIApplication,启动 Main Runloop,可以在之前章节利用runloop统计首屏耗时,也可以在启动结束做一些预热任务
9.执行 will/didFinishLaunch,这里主要是业务代码耗时。首页的业务代码都是要在这个阶段,也就是首屏渲染前执行的,主要包括了:首屏初始化所需配置文件的读写操作;首屏列表大数据的读取;首屏渲染的大量计算等;sdk的初始化;对于大型组件化工程,也包含了很多moudle的启动加载项
10.Layout,viewDidLoad 和Layoutsubviews 会在这里调用,Autolayout 太多会影响这部分时间
11.Display,drawRect 会调用
12.Prepare,图片解码发生在这一步
13.Commit,首帧渲染数据打包发给 RenderServer,走GPU渲染流水线流程,启动结束
(tips: 2.2.10-2.2.13这里主要是图形渲染流水线的部分流程,Application产生图元阶段(CPU阶段))。后续会交由单独的RenderServer进程,再调用渲染框架(Metal/OpenGL ES)来生成 bitmap,放到帧缓冲区里,硬件根据时钟信号读取帧缓冲区内容,完成屏幕刷新
2.2 启动各阶段时长统计
上一小节对启动各个阶段过程的详细阐述,归纳起来大致分为6个阶段(WWDC2019):
通过对各个阶段进行时长统计分析,进行优化然后对比。
可以在Xcode中设置环境变量DYLD_PRINT_STATISTICS和DYLD_PRINT_STATISTICS_DETAILS看下启动阶段和对应的耗时(iOS15后环境变量失效)
也可以通过Xcode MetricKit 本身也可以看到启动耗时:打开 Xcode -> Window -> Origanizer -> Launch Time
如果公司有对应的成熟监控体系最好,这里我们主要通过手动无侵入埋点去统计启动时长,对启动流程pre main-> after main进行统计分析
2.1.1 进程创建时间打点
通过 sysctl 系统调用拿到进程创建的时间戳
#import <sys/sysctl.h>
#import <mach/mach.h>
+ (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;
}
+ (NSTimeInte