App 的启动速度是影响用户对产品的第一印象,也是性能优化工作中最为重要的一环。启动速度越慢,用户流失的概率越高。所以提高启动速度不仅有利于用户体验指标的提升,也能促进核心业务的增长。
一、基本概念
启动的种类
根据场景的不同,启动可分为:冷启动、热启动、回前台。
-
冷启动
设备经过重启(或无 App 的缓存)后,点击 App 启动时,App 的进程还不在系统里;需要系统创建一个进程分配给 App。后续要讨论的也主要是针对冷启动。 -
热启动
刚启动过 App,然后 kill 掉,此时 App 所需要的数据还在缓存中,再次启动则称为热启动。 -
回前台
点击 App 启动时,此时 App 的进程还在系统里,是启动后退回到后台的,不需要开启新的进程。因为此时 App 任然存活着,只是处于 suspended 状态。
Mach-O
Mach-O 是 iOS 中可执行文件的格式, 是所有.o 文件的集合,Mach-O 可分为三部分,如下图:
-
Header
头部信息包含可以执行的 CPU 架构,比如x86、arm64 等,还有一些其他影响文件解析的 flags; -
Load commands
加载命令,包含文件的组织架构和在虚拟内存中布局的方式,比如 Segment command 和 Data 中的 Segment/Section 是一一对应的。 -
Data
包含了实际的代码和数据,存放 Load commands 中需要的各个段(Segment)的数据,
标准的三个 Segment 是 TEXT,DATA,LINKEDIT,也支持自定义:
TEXT, 代码段,只读可执行,存储函数的二进制代码(__text),常量字符串(__cstring),Objective C 的类/方法名等信息
DATA,数据段,读写,存储 Objective C 的字符串(__cfstring),以及运行时的元数据:class/protocol/method…
LINKEDIT,启动 App 需要的信息,如 bind & rebase 的地址,代码签名,符号表…
dyld
即 dynamic link editor,苹果的动态链接器。是启动的辅助程序,启动的时会把 dyld 加载到进程的地址空间,然后通过 dyld 来加载后续的启动任务。目前有两个版本,如图:
- dyld2(iOS3 ~ iOS12)
- dyld2 是纯粹的 in-process,即程序进程内执行,只有当应用程序被启动后,dyld2 才开始执行任务。
- 主要特性是 shared cache(共享缓存), 将多个系统库(如 UIKit)合并成一个大的缓存文件,来提高加载性能。
- dyld3(iOS13之后)
-
dyld3 是部分 in-process,部分out-of-process,上图虚线以上部分是 out-of-process 的,只有当 App 下载或版本更新的时候会执行,主要做:
- 分析 mach-o headers
- 查找依赖的动态库
- 查找需要 rebase & bind 之类的符号
- 把上述的结果写入缓存
-
主要的特性是启动闭包,是一种缓存机制,就是上述在 out-of-process 过程提前执行的任务。当应用启动的时候,就可以直接从缓存中读取数据,从而加快启动的速度。
-
接下来,主要针对冷启动的过程涉及到的知识点进行介绍。
二、启动过程
App 的 整个启动流程以 main 函数为分界点,可分为三个阶段,如图
1.pre-main 阶段,即从点击App 的icon 开始,到 main 函数执行前;
2.main 函数的执行;
3.post-main 阶段,即 main 函数首页的加载完成;
接下来先看下 pre-main 阶段,这部分主要是系统的一些操作。
pre main 阶段
App 启动后,系统会创建一个进程。然后加载可执行文件,此时能够获取到 dyld 的路径并载入,之后 dyld 会递归的加载所有的 dylib。大概过程如下图所示:
-
load dylibs
首先 dyld 会去加载 App 使用到的动态库,而每一个动态库有它自己的依赖关系,所以会递归的去查找和加载每一个动态库。 -
rebase & binding
加载完动态库之后,会对这些库进行链接,主要是 rebase和 binding;
rebase(偏移修正)的作用:一个 App 二进制文件内的所有方法、函数调用,都有一个地址,这个地址是相对当前二进制文件的偏移地址。当该文件运行到内存中时,系统每次会随机分配一个地址值作为该二进制文件的地址,因此文件内的方法的地址需要根据二进制文件的起始地址进行修正。如,二进制文件内的test 方法,偏移值是 0x0001,而系统分配给该文件的随机地址是 0x1f00,此时 test 方法的真实地址(运行时确定的内存地址)为:0x1f00+0x0001 = 0x1f01;
binding(绑定)的作用:如 test 方法,在编译期间生成的 Mach-O 文件中,会创建一个符号 !test,在运行时会将真正的地址赋值给符号,即绑定就是给符号赋值。 -
ObjC setup
dyld 会调用 objc_init 方法,这是 runtime 的初始化方法,主要的操作就是 Class 的注册、category 的注册、selector 的唯一性检查等;
当有新的 image 加载到内存的时候,就会触发 load_images 方法,该方法就是加载 image 里面的类,并调用 load 方法。(image 是程序中对应实例的简称,如程序可执行文件mach-o,Framework,bundle等) -
initializers
进行初始化,执行 +load 方法、创建 C++ 静态全局变量、执行声明为__attribute__((constructor))的C函数;
main 阶段
dyld 会调用 main 函数, main 函数是整个程序的入口函数,函数内部调用了应用初始化以及启动相关的回调方法。
1.main 函数内调用 UIApplicationMain()
2.创建 UIApplication 对象
3.创建 AppDelegate 代理对象
4.加载 info.plist 文件(权限的检查等)
5.创建以及管理 RunLoop
6.调用 AppDelegate 的 didFinishLaunching 方法
7.设置应用的 window 以及 rootViewController
post main 阶段
这一阶段就正式进入到我们自己的代码了,根据项目需求进行开发,如:
-
在 didFinishLaunching 方法中创建应用的 window 以及根页面
-
初始化一些项目需要的配置等;对于复杂项目来说,启动阶段需要初始化的任务比较多,需要一套启动框架来管理启动任务。
三、启动耗时监控
如下图所示,将启动过程划分了四个时间节点 t1~t4,可根据不同埋点需求来计算不同时段的耗时。
通过 Xcode13 之后 配置环境变量 DYLD_PRINT_STATISTICS 来获取 pre-main 的耗时的方式已经失效了,我们可通过 pre-main 过程中系统的调度顺序来模拟计算启动的耗时。
t1 进程创建时间点
当启动 App 时,系统首先创建一个进程,然后在该进程内加载程序所需的可执行文件等一系列操作。因此我们可以通过获取进程的创建时间,作为启动的开始时间,代码如下:
/// 进程创建时间
double get_process_start_time() {
if (t1 == 0) {
struct kinfo_proc proc;
int pid = [[NSProcessInfo processInfo] processIdentifier];
int cmd[4] = {CTL_KERN, KERN_PROC, KERN_PROC_PID, pid};
size_t size = sizeof(proc);
if (sysctl(cmd, sizeof(cmd)/sizeof(*cmd), &proc, &size, NULL, 0) == 0) {
t1 = proc.kp_proc.p_un.__p_starttime.tv_sec * 1000.0 +
proc.kp_proc.p_un.__p_starttime.tv_usec / 1000.0;
}
}
return t1;
}
t2 即将进入main 函数的时间点(pre main 终点)
在上述启动过程的介绍中,我们得知,在 main 函数之前的最后一个阶段会执行 initializer 等初始化操作,该阶段会触发以 attribute((constructor)) 修饰的构建器函数,因此我们可以自定义一个使用该修饰器修饰函数,在该函数内记录 pre main 阶段结束的时间点,代码如下:
void static __attribute__((constructor)) before_main() {
if (t2 == 0) {
t2 = CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970;
}
}
注意:当然也可以通过 initializer 阶段的 +load 方法来标记该节点,但是+load 方法的调用顺序和链接顺序有关,需要我们自己维护类的命名以及链接顺序。
t3 main 执行完的时间点
main 函数执行完后,随即会调用到 AppDelegate 的 willFinishLaunch、didFinishLaunch 等代理方法,我们将 t3 时刻设定在 willFinishLaunch 方法内,作为 main 函数执行完成的节点,也正是在此之后才开始正式的进入程序的开发,即 post main。
func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
if (t3 == 0) {
t3 = CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970;
}
}
t4 第一帧渲染完的时间点
我们一般在启动完成的代理方法 didFinishLaunch 中设置 window 的 rootVC,但此时只是初步开始渲染,距离完全显示到屏幕上还需要一定的时间。
对齐官方 MetricKit 框架,将 CA::Transaction::commit() 方法首次被调用的时间作为首屏渲染完成的时间,即启动结束的时间节点。
CA::Transaction::commit() 是 Core Animation 提供的一种事务机制,把一组 UI 上的修改打包,一起发给 Render Server 渲染。就是提交一组UI到GPU进行绘制。
但我们无法直接拿到 commit 方法第一次执行的时间, 这里借鉴了抖音的方案,发现commit 方法的调用与 RunLoop 中的任务执行有一定的关系,从而获得相对比较接近的时间点。关系如下图所示:
调用关系从早到晚依次是:CFRunLoopPerformBlock > CA::Transaction::commit() > kCFRunLoopBeforeTimers,因此我们监听 RunLoop 事件的回调来获取启动结束时刻,源码如下:
func observerAppLaunchEnd() {
let mainRunLoop: CFRunLoop = RunLoop.main.getCFRunLoop()
if #available(iOS 13.0, *) {
let activities: CFRunLoopActivity = CFRunLoopActivity.allActivities
let runloopObserver = CFRunLoopObserverCreateWithHandler(kCFAllocatorDefault, activities.rawValue, true, 0) { observer, activity in
if activity == CFRunLoopActivity.beforeTimers {
CFRunLoopRemoveObserver(mainRunLoop, observer, CFRunLoopMode.commonModes)
//启动完成,上报耗时
}
}
CFRunLoopAddObserver(mainRunLoop, runloopObserver, CFRunLoopMode.commonModes)
} else {
CFRunLoopPerformBlock(mainRunLoop, CFRunLoopMode.defaultMode.rawValue){
//启动完成,上报耗时
}
}
}
-
iOS13 及以上,采用向RunLoop 中注册一个 kCFRunLoopBeforeTimers 事件的回调,在此获取App 首屏渲染完成的时间;
-
iOS13 一下,通过 CFRunLoopPerformBlock 向RunLoop 中注册一个 block 事件的回到,在此获取App 首屏渲染完成的时间;
注意:prewarm 启动预热机制
iOS15 新出的特性,为了减少用户使用 App 之前的等待时间,系统在后台会启动非运行的程序,即对 App 提前预热,一直到 UIApplicationMain 函数的调用,相当于提前执行了 premain、main 函数的调用,从而减少 App 的启动时间。
prewarm 启动机制与正常冷启动的区别,如下图所示:
了解了 prewarm 机制后,对启动耗时埋点的区别处理了,要判读此次启动是否是 prewarm 启动,如何是的话,则可以不用上报 pre-main 的耗时了,源码如下图:
func isPrewarmLaunch() -> Bool {
let systemVersion = UIDevice.current.systemVersion.toFloat() ?? 0.0
if systemVersion >= 15.0 {
let environment = ProcessInfo.processInfo.environment
for key in environment.allKeys() {
if key.contains(substring: "prewarm") {
return true
}
}
}
return false
}
四、启动优化方案(pre main 阶段)
业界针对 pre main 阶段启动耗时的优化方案还是挺多的,这些方案对较大型项目的优化效果比较明显, 对一些中小型项目的效果可能不是很明显,在此介绍几个常见的针对 pre main 阶段的优化方案。
1.动态库懒加载
对于大型项目来说,清理无用类和代码依然不够的,随着业务的发展代码以及库的数据只能越来越多。在启动阶段,过多的动态库会导致启动 pemain过程耗时劣化,但并不是所有动态库都是启动后立刻就用到功能,因此可将暂时不需要的动态库延迟加载,等需要的时候再加载,以此来提高启动 pre main 阶段的加载速度。
实现动态库的懒加载主要分为两步:
1、首先需要整理出启动阶段不需要的动态库,使其不参与链接
2、运行过程中,通过“中间层” 调用动态库提供的接口,按需加载
1.1. 如何去除启动阶段的依赖?
对 iOS 项目,可通过在 Build Phases > Link Binary With Libraries 中去掉需要懒加载的动态库,去掉之后则不再参与项目的链接了,启动时候 dyld 就不会再加载这些动态库了。
1.2.如何手动加载动态库?
系统库通过 <dlfcn.h> 给开发者暴露了操作动态库的方法,我们可以使用其中的 dlopen 方法对动态库进行手动调用加载。
我们自定义的动态库在App 包中的目录是 "xxx.app/Frameworks/" 目录下,因此我们可以根据具动态库的名称,来按需加载指定的动态库,源码:
- (char *)lazyLoadFrameworkWith:(NSString *)name {
// 获取动态库所在路径
NSString *path = [NSString stringWithFormat:@"%@/Frameworks/%@.framework/%@",
[NSBundle mainBundle].bundlePath,
name,
name];
// 通过 dlopen 加载动态库
void* fp = dlopen(path.UTF8String, RTLD_LAZY);
// 获取 dlopen 过程产生的错误信息
char* err = dlerror();
return err;
}
1.3.如何使用懒加载的动态库?
需要懒加载的动态库在启动阶段没有被加载到内存中,我们无法直接调用,因为其符号不再内存中。当需要使用的时候,需要集中通过一个中间层框架来加载或调用指定的实现,业务层无需感知库的加载过程
- (Class)getIMPClassWithProtocol:(Protocol *)protocol {
NSString *impClassName = [实现该 protocol 的类];
Class impCls = NSClassFromString(impClassName);
if (impCls) {// 已加载:直接返回
return impCls;
} else {// 未加载:先加载, 再返回
NSString *frameworkName = [该 protocol 所在 framework];
[self lazyLoadFrameworkWith:frameworkName];
return NSClassFromString(impClassName);
}
}
1.4.防劣化
我们手动调用 dlopen 加载动态库是比系统 dyld 加载耗时的,需要避免在主线程直接调用 dlopen 加载。同时为了避免业务调用过程卡顿,我们可以在启动完成时,异步的触发加载所有需要懒加载的动态库。
如果开发过程在预加载之前调用了加载动态库的接口,容易导致启动性能劣化,所以需要进行全面的监控以及防劣化处理。
- 开发中 - 断言检查
在开发过程中加载动态库时候,通过断言检测是否在主线程执行 和 是否已经异步加载完成,如果命中断言,则根据提示来修改调用时机。
NSAssert(![NSThread isMainThread] || frameworkLoaded,
@"动态库(%@)被提前调用,或在主线程调用了,请检查代码并修改",frameworkName);
-
代码合入检查
从代码提交到合并进行会有完整的流水线检查,在 gitlab 中根据 MR 中的 diff 修改,匹配是否有新增的动态库相关的接口调用以及特定的目标文件是否有修改等,进行拦截并需要指定 owner 进行 review。 -
上线后 - 埋点监控
前两种阶段并不能覆盖所有劣化的场景,所以需要对启动阶段主线程调用 dlopen 的操作进行埋点,并将触发的堆栈进行上报,对上报的数据建立可视化看板监控所有异常的操作和堆栈。同时可配置报警机制,从而能够及时响应并处理。
2.动态库合并
在 load dylibs 阶段加载动态库比较耗时, 上面提到懒加载的方式将动态库的延迟加载来加快启动速度。还有一种直接减少动态库数量的方式,就是将多个动态库合并成一个。合并的过程也比较简单,大概步骤是:
1.创建一个新的工程
2.添加待合并的动态库到工程中,并添加到 “Build Phases” -> “Link Binary With Libraries”中
3.设置公开的头文件
4.导出合并后的动态库
整个过程就相当于开发一个新的动态库的过程,被合并的库可以理解为新动态库的依赖。
注意:动态库的合并可能涉及一些复杂的兼容问题,以及会提高代码的耦合度,这只是一种减少启动加载动态库数量方案,具体的适用性参考自己项目决定。
3.动态库转静态库
根据 WWDC 2019 Optimizing App Launch 建议,避免启动过程加载过多的动态库,尽量硬链接所有的依赖项,即直接将依赖编译进主执行文件中。因此,我们可以将启动动态库改成静态库,从而直接合并到了主执行文件中,减少了动态库数量的同时,也能减少包体积。
大概步骤是:
1.去除原来对动态库的依赖,以及其他依赖,如:删除原来Podfile中的依赖
2.提取库中的代码和资源,更改代码中对资源的依赖方式
3.重新编译为静态库,即生成.a 文件
其实,苹果也是建议开发者使用静态库的,建议动态库的使用上限最多 6 个。
4.+load 方法的治理
+load 方法也是在 pre main 阶段被调用的,我们经常会在这个方法里进行一些 swizzle 操作,只是为了在方法调用前进行了处理。但如果不是十分必要还是不建议在+load 方法中中添加一些自定义的代码的,主要原因是:
主要原因
- 方法调用时机很早,如果出现异常,不能被性能相关 SDK 捕获,无法保证代码稳定性。
- +load 方法是在主线程执行的,耗时操作必定会导致启动变慢。
- 除了 +load 方法本身的耗时,还会引起大量的 Page In,也会成为启动性能的瓶颈。
如何治理?
为了避免 +load 方法带来的启动性能问题,在 Swift 中就已经无法使用 +load 方法了,同样我们也需要在代码中进行一些优化处理:
-
对于存量的+load 方法, 确认每一个 +load 方法的用于,采用删除、延迟、懒加载或者子线程调用,常用的后移到 initialize 中执行一次(注意:要使用 dispatch_once)。
-
对于新增的 +load 方法,在 MR 流水线中进行新增 diff 检查,遇到 +load 关键字进行拦截并通知相关人进行处理。
5.static initializer
静态初始化操作,也是 pre main 阶段执行的,若这些初始化操作太多也会增加启动耗时。下面列举几个会触发静态初始化的代码:
种类
-
__attribute __((constructor)) 修饰的方法
-
全局的静态类对象
原因是:对象的初始化会调用构造函数,此时编译器就不知道怎么做了,只能延迟到运行时。
class Test{
public:
Test() {
usleep(4000);//demo 耗时任务
NSLog(@"test");
}
};
static Test test = Test();
- C++ string 初始化
string 的初始化同样是需要执行构造函数。
const std::string var_1 = "123";
- 通过函数返回值赋值全局变量
bool test() {
usleep(3000);//demo 耗时任务
NSLog(@"test");
return true;
}
static bool var_1 = test();
综上,大多数全局变量触发静态初始化的原因是:在变量赋值时会触发相关方法的调用,导致编译器在编译期间无法理解相关方法,只能延迟到运行时了。
注意:并不是所有的 static 变量都会产生静态初始化,对于再编译期间就能确定值和类型的变量是会直接 inline 的,如基本数据类型的变量:
//不会产生静态初始化
static const int var_1 = 123;
static const char * var_2 = "1234";
如何治理?
-
对于 attribute((constructor)) 修饰的方法可以参考 +load 方法的治理手段
-
考虑使用局部变量代替全局变量,或者减少全局变量在启动阶段的显示初始化。
6. Background Fetch
Background Fetch 是 iOS7 推出来的后台刷新,每隔一段时间将 App 在后台启动,使App 可以在后台刷新数据,以此提高数据的加载速度,进而提高用于体验。
使用步骤
按照官方的资料,整体是使用步骤如下:
-
Targets->Capabilities->Background Modes,勾选 Background fetch。
-
didFinishLaunchingWithOptions: 方法中调用 setMinimumBackgroundFetchInterval: 方法
-
实现代理方法 application:performFetchWithCompletionHandler:在该代理方法中请求相关数据。
提高启动速度?
Background Fetch 有点类似”后台包活“机制, 为什么能提高启动速度呢?
如下面两种 case:
如上图:
-
节点 1
系统在后台启动了应用,由于内存等原因又被系统 kill 了,此时用户启动了该 App,由于缓存还在,所以这一次为热启动。 -
节点 2
系统在后台启动了应用,此时用户启动了该 App,由于 App 在后台存活着,所以这一次是从后台回到了前台。
综合以上两种情况,Background Fetch 之所以能够提高启动速,可以理解为:提高了热启动在冷启动中的占比,另外对于用于来说,回前台也就是一次启动。
7.二进制重排
虚拟内存
物理内存
在虚拟内存出现之前,程序指令都是直接访问的物理内存,使得物理内存能够存放的进程十分有限,由于是相邻存存储,很容易发生访问越界等情况。
虚拟内存
虚拟内存作为内存管理和保护工具诞生的,是在物理内存之上建立的一层逻辑地址,保证安全的同时为每个进程提供一片连续的地址空间。
物理内存和虚拟内存都是以页为单位映射,但二者的映射关系不是一一对应的,一页物理内存可能映射多页虚拟内存,一页虚拟内存也可能不占用物理内存。
注意:iPhone 6s 开始,物理内存 Page = 16K,以前的设备都是 4K,启动速度得以大幅提升。
Page Fault (缺页中断)
当向系统申请内存时,系统并不会直接给我们分配物理内存,只是标记当前进程拥有该段内存。
当进程访问一个虚拟内存 Page 而其对应的物理内存不存在时,会触发一次 Page Fault 来分配物理内存。
Page Fault 的过程会有物理内存的分配、磁盘 IO、验证签名等耗时操作,启动过程中如有大量的 Page Fault 发生就会影响启动耗时。
符号重排
启动过程中,执行的代码相对有限,但是这些代码分布的随机性比较大(+load 和 initializer),如果这些代码处于不同的分页中,就会带来大量的 Page Fault 。
为了避免在启动过程触发大量的 Page Fault , 要尽可能的让启动过程执行的方法在二进制中的分布更加紧凑,使其分布在连续的区间,那么就可以减少 Page Fault 的次数,从而优化启动时间,如图所示:
启动时候需要调用 method 1和 method 3,由于这两个方法分布在两个 Page中, 正常执行需要触发两次 Page Fault。如果这两个方法处于同一Page 中,则只需要出发一次 Page Fault, 从而提升启动速度。
具体实现过程这里不再赘述,可参考:抖音研发实践:基于二进制文件重排的解决方案。
总结
本文主要介绍了 App 的启动过程,以及针对 pre main 阶段的耗时统计方法,最后介绍了一些业内常用的启动优化的方案。
- load dylibs 过程:动态库懒加载、动态库合并、动态库转静态库
- Page Fault 耗时:二进制重排
- rebase/binding 过程:减少无用代码
- initializer:+load 以及静态初始化方法的治理
- Background Fetch 后台拉取
对于具体采用哪种方案,还是根据我们自身 App 的具体情况来下选择,需要分析出是什么因素导致的耗时,从而采取针对性的方案。
参考: