乐于分享
好东西不私藏

iOS开机启动优化--PreMain阶段

iOS开机启动优化--PreMain阶段
App的开机启动速度非常影响用户对产品的使用,若启动阶段过长还会被Watchdog给直接kill, 所以作为APM优化来说,开机启动时间耗时越长,对用户留存影响越严重。
一、 启动分类
1. 冷启动:App是首次启动,在ios操作系统里并没有此进程。也是我们重点优化的类型
2. 热启动: App进程快速杀死后, 然后立即重启,由于App的进程缓存还在。
3. 温启动: 实际也是系统帮根据我们的使用习惯, 提前创建好了App进程到main阶段的操作; 这样用户在点开App时,只需要加载main之后的耗时操作;
当然还有回前台(这个严格不属于启动的范畴),它只是从后台回前台,线程处于suspended状态,内存也都还在,这种通常我们不会纳入开机启动的范畴。
二、 启动过程
App的整个启动过程是以main函数为分界点,可以分为3个阶段,如图所示:
  • Pre-main阶段: 从进程创建到main函数执行前;
  • main函数执行阶段: 
  • Post-main阶段:从main函数到首页加载出首帧页面;
三、Pre-main阶段的具体流程
结合上图我们先逐个小阶段介绍一下pre-main所做的事情,
  • 点击App的icon之后, 系统会创建一个进程,然后加载我们的App的Mach-O可执行文件,之后再load dyld,根据获取到的dyld的路径进行载入;
  • 加载动态库 (load dylibs)
    在加载完dyld连接器之后,就可以去在加载我们app的动态库了,即:load dylibs阶段,加载的过程是dyld先加载App使用到的动态库,每个动态库还有它各自依赖的其它动态库,然后会通过递归的方式注意加载每一个动态库;
  • rebase & binding阶段
    加载完动态库之后, 需要对这些库进行链接(每个动态库其实也是一个Mach-O文件), reabase的过程实际就是我们Mach-O中定义的内存地址,可以理解为是对当前二进制文件的偏移地址, 当mach-o文件加载到内存时,会分配一个内存地址作为App的起始地址,这里简单理解就是做这些二进制符号的偏移修正过程; 最后就能得到实际加载到内存的逻辑地址了(由于操作系统原理我们所描述的都是内存虚拟地址,并不是物理内存地址,通常也用不到物理内存地址);
    binding的过程,就是给方法赋值的过程(例如: aa方法:编译到mach-o中是一个特定的符号aa,),binding就是创建一个符合!aa在runtime运行时会将真正的函数地址赋值给aa,简单一句话: 绑定就是给符合赋值。
  • Objc setup阶段
    这个也就是runtime的初始化阶段,dyld会调用objc_init方法,这里主要就是对Class进行注册, 对Category进行注册,还有类中的方法selector的唯一性的检查等;
  • initializers阶段
     主要指的就是我们熟悉的例如+load方法的执行, 创建C++静态变量, 执行例如c++中常用的声明操作__attribute__((constructor))
   这里补充一个小知识点: 我们的+load方法是在main函数之前操作, 它是在主线程中运行的,当前app主动调用+load方法,不需要我们手动调用,因此这里注意+load方法不要有特别耗时的操作。
main方法之后的优化,不在本节中介绍,后期会专门在讲解。
四、Pre-main阶段优化的内容
4.1 关于pre-main的各个时间点获取
  • 进程开始创建时间点
不同公司可能对开始时间不同,我参考网上有公司使用第一个+load的开始时间做startTime, 这种方案常见于比如:创建一个AAA开头的库,或者根据代码插桩的方式(这里不在展开说了,可以具体-fsanitize-coverage=trace-pc-guard 搜索一下教程),知道第一个调用库,新建一个类,在这个类中+load方法记录一个startTime。
另外一个就是使用进程的真正创建时间, 思路就是在进程创建时,根据进程的pid,通过c++层的函数sysctl来获取到进程的开始时间。
具体的代码:
// 获取进程idint pid = [[NSProcessInfo processInfo] processIdentifier];struct kinfo_proc proc;size_t size = sizeof(proc);int cmd[4] = {CTL_KERN, KERN_PROC, KERN_PROC_PID, pid};// 获取进程创建时间if (sysctl(cmd, sizeof(cmd)/sizeof(*cmd), &proc, &size, NULL0) == 0) {     beginTime = proc.kp_proc.p_un.__p_starttime.tv_sec * 1000.0 +                 proc.kp_proc.p_un.__p_starttime.tv_usec / 1000.0;     printf(beginTime);}
  • 即将进入到main函数的时间点(也就是pre-main的终点)
这里可以main.m文件中main函数上面添加一个c++的 attribute的函数(上面也简单提到), 这里直接贴一下代码
void static __attribute__((constructor)) pre_main() {    if (premainEndTime == 0) {        premainEndTime= CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970;printf(premainEndTime);    }}

这个的执行也就是上面图中提到的initializer阶段的统计;

当然简单点,统计也可以在main函数中首行作为pre-main的end阶段也是可以的,差别不太。

main函数之后的耗时统计,本章节不涉及,这里不再过多解释,接下来重点说一下优化点;

4.2 关于pre-main的各个时间点获取
  • <一>  删除无用类和无用方法等
    这一操作跟包瘦身一样,都需要定时删除无用的方法和无用类, 并且将重复的代码,尽量提取为公共方法,减少Mach-O文件里符号量。
    这里具体的做法思路:
    (1) 使用OClint来检索扫描或者使用AppCode IDE都有类似的功能, 如果是做CI持续集成,建议可以使用OClint在流程中增加这一项的检索;
     (2) 可以使用App中的Mach-O中的(__objc_selfrefs__object_classrefs)引用到的sel和class, 它与(__objc_classlist)这个是存储了所有的class和sel,这2组数据进行相减,多余的部分就是可能要删除的方法或类;
     注意: 无论是哪种方案, 这些检索的僵尸代码都不要直接删除, 由于iOS是Runtime机制,因此上面检索的内容还需要二次确认,避免有类似

    NSSelectorFromString这样的方法调用。

    这里的二次确认,如果方法不多,可以直接人工确认一下,如果方法比较多,或者是长期规范,可以使用比如python脚本进行代码二次源码检索来确认。 总之,对于删除代码的操作一定慎重。

    我们根据实战这种收益实际效果并不太高,结合包体瘦身来说,还是值得长期维护的。

  • <二> 动态库的操作

   (1) 动态库的懒加载

     思路: 整理不需要在开机启动阶段调用的动态库,让其不直接参与链接,在运行过程中,通过“中间层”调用动态库提供的接口按需加载。

这里去掉的方式: Build Phases > Link Binary with Libraries去掉要加载的库即可。

这种操作在dyld阶段就不会直接load 这个dylib了。

关于如何确认动态库是否可以懒加载,一方面可以采用插桩的逻辑,查看各个库实际在开机启动阶段是否真的被执行到的方式来实现。 这个操作比较简单, 但往往对于一个迭代时间很久的项目, 最好是从业务功能先处理,是不是可以在启动阶段不直接加载它, 这样的方式会更有效果。

动态库虽然这种方式开机阶段加载了,但是后期主动加载的方式就使用到了dlopen的方式, 由于dlopen并不建议直接使用,这里通常是使用

// framework的pathNSString *frameworkpath = @"";NSBundle *bundle = [NSBundle bundleWithPath:frameworkpath];//可以先判断bundle是否加载if (!bundle.isLoaded){// 调用load方法来加载NSError *error;	[bundle loadAndReturnError:&error]}

(2) 动态库的合并

 根据苹果给的规范,建议动态库的数量最好不要超过6个,如果动态库过多也会非常影响启动阶段的耗时。

动态库的合并操作多个动态库合并为一个动态库之后,Mach-O文件是一个全新的, dyld加载时只需要加载时,只需要加载少数的几个即可,效率能提升一下,操作流程比较简单这里不再描述,这里需要注意的一点,在crash解析时,dsym文件要记得使用合并后的文件。

(3) 动态库转静态库

思路:还是尽量减少动态库的数量,我们知道静态库打包时,符号会被合并到App的Mach-O文件中, 因此能减少dyld的一点耗时。

  • <三> +load方法的治理

上面也描述了各个类的+load方法是在主线程中执行,如果+load方法中有IO操作,那么会引起主线程整体的耗时,最好的方式可以结合实际业务场景,将不必要的+load中的代码删除或转移到运行时处理, 比如+

initialize方法中执行, 通过dispatch_once方式避免多次调用

补充一个知识点:

+initialize 是在方法首次调用时,通常也就是在alloc时或调用类方法时,会触发调用。

另外从Runtime角度, 一般不建议在Category中写+initialize方法, 如果在主类中也有写+initialize方法的实现,通常只会运行Category中的,因此这里需要注意。

  • <四> static initalizer 静态初始化

    如果静态初始化的代码过多,也会增加启动耗时, 这里的种类包含:__atrribute__((constructor))修饰的方法;

     全局的静态类对象;

    C++中的string初始化;

    函数返回值复制给全局变量等。

    我们尽可能的减少这些的定义,尽量放在真正使用的位置。

  • <五> 二进制重排

二进制重排的核心原理: App使用的是虚拟内存而非物理内存,当向系统申请内存时, 系统是直接给一个虚拟内存,它只是标记当前进程中拥有该段内存,在进程真正访问一个虚拟内存Page而其对应的物理内存不存在时,会出发一次Page Fault(缺页中断)来分配物理内存, Page Fault的过程(有物理内存的分配, 磁盘I/O, 验证签名等耗时操作),若启动过程中有大量的Page Fault会比较影响启动耗时,

我们知道App中的内存是按照Page页,一页一页存储的, 由于在编译时,编译的文件代码是分散的, 而执行代码时, 这些代码就会处于不同的Page页中, 这样就会带来大量的Page Fault的现象。一般稍微大一些的App能有600-700ms的耗时。

如图所示: 启动阶段Page1的method1和Page2的method3都是启动阶段要调用的方法,这里就需要发生2次Page Fault,那么根据重拍以后,让method1和method3都放在Page1中,这样只需要发生1次Page Fault。

为优化解决这样的问题, 二进制重排就是尽可能的在启动过程中将执行的方法在二进制Page分布更紧凑。 以此来达到优化启动时间。

执行方案:链接器ld有个参数是-order_file它支持按照符号的方式排列二进制, 这里可以采用上面提到的Facebook的LLVM函数插桩的方式,针对order_file文件定制, 这样再编译时,将这个order文件路径配置到-order_file参数即可, 这样就实现了二进制重排, 教程比较简单,不在详细赘述。

五、Pre-main阶段系统做的一些优化介绍
5.1 dyld版本差异
在ios13之前,系统的dyld使用的是dyld2版本, 在ios13及以上使用的是dyld3版本, 
dyld3版本的优化,引入了closure(闭包缓存),也就是在沙盒的tmp中会有一个com.apple的缓存文件,这个是将启动时的工作,在首次运行以后将相关的启动操作,缓存到tmp文件目录下,这样后续再启动时,会优先判断closure缓存的文件是否存在,来加速优化。因此不建议清空缓存时,直接对此目录进行删除操作。
5.2 温启动的优化
在ios15以上,系统为了减少用户使用App的等待时间,系统在后台会启动非运行的程序(根据用户的使用习惯), 一直到UIApplicationMain函数的调用, 相当于提前执行的premain阶段的函数调用,从而减少了App的启动时间。
这里需要对这种优化做一下埋点上的处理,由于如果计算进程创建时间为startTime,到首页加载出来为EndTime,那么这个耗时可能会非常离谱, 这是就需要对温启动,做一下特殊判断。
判断是否是温启动的方式:
if (@available(iOS 15.0*)) {   NSDictionary *environment = [[NSProcessInfo processInfo] environment];   for (NSString *key in environment.allKeys) {      // 判断 key 中是否包含 "prewarm"      if ([key rangeOfString:@"prewarm" options:NSCaseInsensitiveSearch].location != NSNotFound) {	  // 判断为温启动	  return YES      }   }}