
Pre-main阶段: 从进程创建到main函数执行前; main函数执行阶段: Post-main阶段:从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))
进程开始创建时间点
// 获取进程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, NULL, 0) == 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的终点)
void static __attribute__((constructor)) pre_main() {if (premainEndTime == 0) {premainEndTime= CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970;printf(premainEndTime);}}
这个的执行也就是上面图中提到的initializer阶段的统计;
当然简单点,统计也可以在main函数中首行作为pre-main的end阶段也是可以的,差别不太。
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参数即可, 这样就实现了二进制重排, 教程比较简单,不在详细赘述。
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;}}}
夜雨聆风