[关闭]
@qidiandasheng 2020-10-22T12:25:13.000000Z 字数 6120 阅读 1564

启动速度怎么做优化与监控?

极客时间


APP启动过程

  1. ①解析Info.plist
  2. 加载相关信息,例如闪屏
  3. 沙箱建立、权限检查
  4. Mach-O加载
  5. 如果是胖二进制文件,寻找合适当前CPU架构的部分
  6. 加载所有依赖的Mach-O文件(递归调用Mach-O加载的方法)
  7. 定位内部、外部指针引用,例如字符串、函数等
  8. 加载类扩展(Category)中的方法
  9. C++静态对象加载、调用ObjC +load 函数
  10. 执行声明为__attribute__((constructor))的C函数
  11. ③程序执行
  12. 调用main()
  13. 调用UIApplicationMain()
  14. 调用applicationWillFinishLaunching

什么是dyld

动态链接库的加载过程主要由dyld来完成,dyld是苹果的动态链接器。

App开始启动后,系统首先加载可执行文件(自身App的所有.o文件的集合),从里面获得dyld的路径,然后加载动态链接器dyld,dyld是一个专门用来加载动态链接库的库。

dyld去初始化运行环境,开启缓存策略,dyld从可执行文件的依赖开始,递归加载程序相关的所有依赖的动态链接库(其中也包含我们的可执行文件)。

并对这些库进行链接,最后调用每个依赖库的初始化方法,在这一步,runtime被初始化。

动态链接库包括:iOS 中用到的所有系统 framework,加载OC runtime方法的libobjc,系统级别的libSystem,例如libdispatch(GCD)和libsystem_blocks (Block)。

启动过程示例图

2.png-12.4kB

3.png-184.7kB

pre-main过程

4.png-68.7kB

1.创建进程

当启动一个应用程序时,系统最后会根据你的行为调用两个函数,fork和execve。fork功能创建一个进程;execve功能加载和运行程序。这里有多个不同的功能,比如execl,execv和exect,每个功能提供了不同传参和环境变量的方法到程序中。在OSX中,每个这些其他的exec路径最终调用了内核路径execve。

2.Load dylibs image

在每个动态库的加载过程中,dyld需要做下面工作:

优化:

1.减少非系统库的依赖
2.合并非系统库

3.Rebase/Bind image

由于ASLR(address space layout randomization)的存在,可执行文件和动态链接库在虚拟内存中的加载地址每次启动都不固定,所以需要这2步来修复镜像中的资源指针,来指向正确的地址。 rebase修复的是指向当前镜像内部的资源指针; 而bind指向的是镜像外部的资源指针。

rebase步骤先进行,需要把镜像读入内存,并以page为单位进行加密验证,保证不会被篡改,所以这一步的瓶颈在IO。bind在其后进行,由于要查询符号表,来指向跨镜像的资源,加上在rebase阶段,镜像已被读入和加密验证,所以这一步的瓶颈在于CPU计算。

优化:

1.减少Objc类数量, 减少selector数量
2.减少C++虚函数数量

4.Objc setup

Objc setup主要是在objc_init完成的。

  1. void _objc_init(void)
  2. {
  3. static bool initialized = false;
  4. if (initialized) return;
  5. initialized = true;
  6. //读取Runtime相关的环境变量
  7. environ_init();
  8. tls_init();
  9. static_init();
  10. lock_init();
  11. //初始化libobjc异常处理系统
  12. exception_init();
  13. _dyld_objc_notify_register(&map_images, load_images, unmap_image);
  14. }

这就是dyld在加载runtime的时候,runtime的动态库(libobjc)初始化时向dyld绑定了3个回调函数,分别是map_images,load_imagesunmap_image

  1. void map_images(unsigned count, const char * const paths[],
  2. const struct mach_header * const mhdrs[])
  3. {
  4. //开启Runtime锁
  5. mutex_locker_t lock(runtimeLock);
  6. //将具体任务交由map_images_nolock执行
  7. return map_images_nolock(count, paths, mhdrs);
  8. }

优化:

参照rebase/binding time,尽量减少类的数量,可以达到减少这一部分的时间

5.initializers

之前是在修改__DATA segment中的内容,而这里则开始动态调整,开始在堆和栈中写入内容。 工作主要有:

1、 Objc的+load()函数
2、 C++的构造函数属性函数 形如attribute((constructor)) void DoSomeInitializationWork()
3、 非基本类型的C++静态全局变量的创建(通常是类或结构体)(non-trivial initializer) 比如一个全局静态结构体的构建,如果在构造函数中有繁重的工作,那么会拖慢启动速度

由于上一步runtime的动态库(libobjc)向dyld绑定了load_images函数。

所以之后dyld开始将程序二进制文件初始化dyld_image_state_dependents_initialize)后交由ImageLoader读取image,其中包含了我们的类、方法等各种符号。

这时候会dyld会发出dyld_image_state_dependents_initialize通知 (依赖的所有 image 已走完 initializers 流程时)。

runtime接受到通知后调用mapimages做解析和处理,接下来loadimages中调用 callloadmethods方法,遍历所有加载进来的Class,按继承层级依次调用Class的+load方法和其 Category的+load方法。

优化:

1.使用+initialize来代替+load
2.不要使用atribute((constructor)) 将方法显式标记为初始化器,而是让初始化方法调用时才执行。比如使用 dispatch_once()、pthread_once() 或 std::once()。也就是在第一次使用时才初始化,推迟了一部分工作耗时。也尽量不要用到C++的静态对象。

main过程

5.png-212.1kB

优化

启动速度优化来说,可以做的事情包括:

main() 函数执行后:

二进制重排

具体可以参考iOS基于二进制重排的启动优化

简单解释看下面两张图就可以了:

导出图片Thu Oct 22 2020 11_58_36 GMT+0800 (中国标准时间).png-13.6kB

导出图片Thu Oct 22 2020 11_58_54 GMT+0800 (中国标准时间).png-13.6kB

假设我们只有两个page:page1/page2,其中绿色的method1和method3启动时候需要调用,为了执行对应的代码,系统必须进行两个Page Fault。

但如果我们把method1和method3排布到一起,那么只需要一个Page Fault即可,这就是二进制文件重排的核心原理。

如何找到启动时调用的函数呢?

使用Clang插件SanitizerCoverage

简单来说 SanitizerCoverage 是 Clang 内置的一个代码覆盖工具。它把一系列以 __sanitizer_cov_trace_pc_ 为前缀的函数调用插入到用户定义的函数里,借此实现了全局 AOP 的大杀器。其覆盖之广,包含 Swift/Objective-C/C/C++ 等语言,Method/Function/Block 全支持。

开启 SanitizerCoverage 的方法是:在 build settings 里的 “Other C Flags” 中添加 -fsanitize-coverage=func,trace-pc-guard。如果含有 Swift 代码的话,还需要在 “Other Swift Flags” 中加入 -sanitize-coverage=func-sanitize=undefined。所有链接到 App 中的二进制都需要开启 SanitizerCoverage,这样才能完全覆盖到所有调用。

以下两个函数就是SanitizerCoverage帮我们自动插入到函数了,通过__sanitizer_cov_trace_pc_guard我们能得到下一个函数调用的首地址,也就是其中最重要的PC

  1. typedef struct {
  2. void *pc;
  3. void *next;
  4. } PCNode;
  5. void __sanitizer_cov_trace_pc_guard_init(uint32_t *start,
  6. uint32_t *stop) {
  7. static uint32_t N; // Counter for the guards.
  8. if (start == stop || *start) return; // Initialize only once.
  9. printf("INIT: %p %p\n", start, stop);
  10. for (uint32_t *x = start; x < stop; x++)
  11. *x = ++N; // Guards should start from 1.
  12. }
  13. void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
  14. if (collectFinished || !*guard) {
  15. return;
  16. }
  17. *guard = 0;
  18. void *PC = __builtin_return_address(0);
  19. PCNode *node = malloc(sizeof(PCNode));
  20. *node = (PCNode){PC, NULL};
  21. OSAtomicEnqueue(&queue, node, offsetof(PCNode, next));
  22. }

可直接使用AppOrderFiles

注意:

有时候我们会得到如下的错误,找不到__sanitizer_cov_trace_pc_guard_init__sanitizer_cov_trace_pc_guard符号。

因为这两个符号是加在当前的库里面的,当我们开启use_frameworks!的时候,我们图中的AFNetWorking就自己打包成了一个动态库,所以找不到那两个符号。

所以我这里是去掉了use_frameworks!之后这些依赖库就都打进了可执行文件中,就不会报错了。或者让AFNetWorking也依赖AppOrderFiles库这样的话也可以(但显然是不可能的)。

截屏2020-10-22 下午12.16.33.png-183.5kB

监控

main()函数之后

使用Time Profiler,这里主要监控的是启动后的方法耗时。

截屏2020-10-16 下午5.56.59.png-153.2kB

截屏2020-10-16 下午5.57.16.png-133.1kB

pre-main阶段检测

在 Xcode 中 Edit scheme -> Run -> Auguments -> Environment Variables点击+添加环境变量 DYLD_PRINT_STATISTICS 设为 1,然后启动app,我们能在控制台看到如下的输出:

  1. Total pre-main time: 3.5 seconds (100.0%)
  2. dylib loading time: 2.7 seconds (77.0%)
  3. rebase/binding time: 575.27 milliseconds (16.3%)
  4. ObjC setup time: 90.65 milliseconds (2.5%)
  5. initializer time: 141.40 milliseconds (4.0%)
  6. slowest intializers :
  7. libSystem.B.dylib : 4.31 milliseconds (0.1%)
  8. AFNetworking : 80.39 milliseconds (2.2%)

1、main()函数之前总共用时3.5s
2、加载动态库使用了2.7s,指针重定位用了575.27ms,ObjC类初始化使用了90.65ms,其他依赖库各种初始化使用了141.40ms
3、在初始化用时的141.40ms中,用时较多的几个初始化是libSystem.B.dylibAFNetworking

检测Load耗时

iOS深思篇 | 启动时间的度量和优化
计算 +load 方法的耗时

参考

iOS启动时间优化
iOS基于二进制重排的启动优化
二进制重排
App 二进制文件重排已经被玩坏了
iOS App冷启动优化

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注