iOS之App启动优化

基本知识

App启动分为冷启动和热启动,启动时间由两个阶段构成:

  • pre-main阶段:main()函数之前所需要的时间
  • main()main()之后需要的时间

分析每个阶段做了做什么

  1. App启动后,首先,系统内核(Kernel)创建一个进程。

  2. 加载可执行文件。(可执行文件是指Mach-O格式的文件,也就是App中所有.o文件的集合体)这时,能获取到dyld(dyld是苹果的动态链接器)的路径。

  3. 加载dyld,主要分为4步:

    (1)load dylibs:这一阶段dyld会分析应用依赖的dylib,找到其Mach-O文件,打开和读取这些文件并验证其有效性,接着会找到代码签名注册到内核,最后对dylib的每一个segment调用mmap()(在Linux中,使用mmap()用来在进程虚拟内存地址空间中分配地址空间,创建和物理内存的映射关系)。

    (2)rebase/bind:进行rebase指针调整和bind符号绑定。

    (3)ObjC setup:runtime运行时初始化。包括ObjC相关Class的注册、category注册、selector唯一性检查等。

    (4)Initializers:调用每个ObjC类与分类的+load方法,调用attribute((constructor))修饰的函数、创建C++静态全局变量。

  4. main函数执行后:

    从 main 函数执行开始,到 appDelegate 的 didFinishLaunchingWithOptions方法里首屏渲染相关方法执行完成。即,从main函数执行到设置self.window.rootViewController执行完成的阶段

    dylib的加载过程中系统为了安全考虑引入了ASLRAddress Space Layout Randomization)技术和代码签名。

    ASLR技术:镜像Image、可执行文件、dylibbundle在加载的时候会在其指向的地址(preferred_address)前面添加一个随机数偏差(slide),防止应用内部地址被定位。

    由于ASLR的存在,镜像(Image,包括可执件文件、 dylib和bundle)会在随机的地址上加载,和之前指针指向的地址(preferred_address)会有个偏差(slide), dyld需要修正这个偏差,来指向正确的地址。

    Rebase做的是将镜像读入内存,修正镜像内部的指针,性能消耗主要在 IO。

    Bind做的是查询符号表,设置指向镜像外部的指针,性能消耗主要在CPU计算。

如何测量pre-main阶段的耗时

  • Xcode通过添加环境变量:DYLD_PRINT_STATISTICS,设置Value值为1

  • 启动后就可以在Xcode控制台看到启动耗时:

  • 如果需要获取各阶段更详细的耗时统计:添加环境变量DYLD_PRINT_STATISTICS_DETAIL,设置Value为1。

    详细的耗时信息如下:

我们可以做哪些优化

  1. 减少动态库的加载,动态库之间通常都会有依赖关系,加载A库,A依赖B,就需要加载B,直到所有的依赖库加载完毕。Apple建议使用更少的动态库,最好能保持在除系统外6个以内;
  2. 减少已有的Objc类的数量,更多地使用Swift语言,Swift的struct能加载地更快;
  3. 尽可能少使用静态初始化器(static修饰)。比如,Swift全局变量在使用前确保被初始化但不使用初始化器,而是在需要的时候使用dispatch_once
  4. 减少load()方法,或者不在它内部做耗时操作,尽量放到initialize()方法或者首屏渲染完成后再执行;
  5. 减少加载启动后不会去使用的类或方法;少用C++全局变量;
  6. main()函数之后的操作中,除了首屏展示内容和必要的配置外,尽量延后其他操作。

动态库优化

因为项目中集成了Swift的三方库,而Apple没有实现构建和链接具有Swift代码的静态库,所以必须使用use_framework!

因为Podfile中use_framework!指定项目强制使用动态库的形式加载所有第三方库,所以罪魁祸首就是use_framework!

解决方法就是将动态库转为静态库:

1
2
3
4
5
6
7
8
9
post_install do |installer|
installer.pods_project.targets.each do |target|
target.build_configurations.each do |config|
if config.build_setting['PRODUCT_NAME'] == 'AFNetworking'
config.build_settings['MACH_O_TYPE'] = 'staticlib' # 将库类型强制从动态库转换为静态库
end
end
end
end

但这样的强制转换很不安全,而且需要穷举所有第三方库为静态库或者动态库,每次更新podfile文件,配置也要跟着更新

所以我们找到了新的解决方法:

https://github.com/CocoaPods/CocoaPods/issues/9099

这篇文章阐述了CocoaPods使用use_frameworks!的痛点——只能全部使用动态库或静态库的问题,并扩展了CocoaPods支持pod使用者根据需要使用动态库或者静态库的方式——比如,我们想要的方式——除了不支持静态库方式链接的第三方库之外,其他的三方库都使用静态库。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
pod 'AFNetworking', '~> 1.0'

target 'MyApp' do
use_frameworks!
end

target 'MyApp2' do
use_frameworks! :linkage => :dynamic # 对于支持动态库的链接为动态库
end

# 我们在项目中使用了这种方式
target 'MyApp3' do
use_frameworks! :linkage => :static # 对于支持静态库的链接为静态库
end

更新:

含有Swift代码的应用程序都需要附带特定的动态库版本代码,所以一般包含Swift代码的App的包要比纯OC的包体积大。

在iOS13的系统上,App内不再包含Swift库,所以整体体积会比iOS13之前的要小。

二进制重排

二进制重排是什么

由于虚拟内存技术会产生缺页中断(Page Fault),每页耗时有1微秒到0.8毫秒不等。由于启动时加载大量数据,如果产生大量缺页中断,时间叠加后用户会有明显感知。

二进制重排就是把所有启动时候的代码都放在连续的前几页,这样就很大程度减少了启动时的缺页中断(Page Fault)从而优化启动速度。

它的核心思想是:重新排列方法符号的顺序,将启动相关的方法排在最前面,从而减少Page Fault的数量。

准备知识:虚拟内存技术

系统的内存是分页管理的,映射表不是以字节为单位,而是 以页为单位

  • Linux4K为一页
  • macOS4K为一页
  • iOS16K一页

早期的计算机不断启动应用,到达一定数量以后会报错,应用无法正常运行,必须先关闭前面的部分应用才能继续开启。

这是因为早期计算机没有虚拟地址,一旦加载都会 全部加载到内存中 。一旦物理内存不够了,那么应用就无法继续开启。

应用在内存中的排序都是顺序排列的,这样进程只需要把自己的地址尾部往后偏移一点就能访问到别的进程中的内存地址,相当不安全。

用户使用时并不会使用到全部内存,如果 App 一启动就全部加载到内存中会浪费很多内存空间。 虚拟内存技术 的出现就是为了解决这个内存浪费问题。

App 启动后会认为自己已经获取到整个 App 运行所需的内存空间,但实际上并没有在物理内存上为他申请那么大的空间,只是生成了一张 虚拟内存和物理内存关联的表

地址翻译

App 需要使用某一块虚拟内存的地址时,会通过这张表查询该虚拟地址是否已经在物理内存中申请了空间。

  • 如果已经申请了,则通过表的记录访问物理内存地址;
  • 如果没有申请,则申请一块物理内存空间并记录在表中(Page Fault)。

这个通过进程映射表映射到不同的物理内存空间的操作叫 地址翻译 ,这个过程需要 CPU 和操作系统配合。

Page Fault

当数据未在物理内存会进行下列操作:

  • 系统阻塞该进程
  • 将磁盘中对应Page的数据加载到内存
  • 把虚拟内存指向物理内存

为保证当前App的正常使用,数据加载遵循以下原则:

  • 如果有空闲内存空间就放空的内存空间中
  • 如果没有就覆盖其他进程的数据
  • 具体覆盖由操作系统处理

检测Page Fault

使用Xcode的Instruments工具中的system trace模板

  1. 手机连接到电脑

  2. system trace中选择连好的手机,选择要检测的应用

  3. 点击左上角开始按钮,等App启动完成后,停止

  4. 在搜索框中输入main,搜索App主线程,选中Main Thread

  5. 在下方控制台中选中Narrative,在下拉列表中选中Summary: Virtual Memory,里面展示的File Backed Page In就是Page Fault的相关数据

怎么知道方法符号的顺序?

步骤:

  1. Xcode的配置中 Target -> Build Setting -> LinkingWrite Link Map File设置为YES,编译项目

  2. 在项目的Products下找到.app文件,show In Finder,回到上一级Products文件同级目录

  3. 找到Products同级目录下的intermediates.noindex—>{productName}.build —> {Scheme}-iphoneos—>{ProjectName}目录,

  4. 打开xx.build{appname}-LinkMap-normal-arm64.txt

    LinkMap文件最前面的部分就是文件的执行顺序:

    Symbols后面是方法的执行顺序:

怎么根据符号表进行排序

Xcode提供了排列符号的设置给开发者,设置 order_file 即可。

  1. 如何设置order_file

    1. 创建order_file.order文件
    2. TargetBuild settinglinkingorder file,设置order file路径为创建的order_file.order的路径即可
  2. 怎样自动生成order_file——编译器插桩

    编译器插桩生成order_file原理:跟踪到 每个方法的执行,从而获取到启动时 方法执行的顺序,然后再按照这个顺序去编写order file

    怎么拿到方法执行顺序呢?

    LLVM具有内置的简单代码覆盖率检测工具(SanitizerCoverage

    • 它可以在函数,块、边缘级别插入用户定义函数并提供回调
    • 它可以实现简单的可视化覆盖率报告

    我们可以借助这个工具对方法、block的调用进行插桩,也就是在方法调用的开始和结束位置插入特定代码,从而跟踪到启动时的方法执行顺序,根据方法执行顺序生成order_file

    具体用法:

    1. 添加设置:TargetBuild SettingCustom Complier FlagsOther C Flags添加 -fsanitize-coverage=func,trace-pc-guard

      如果工程支持Swift,需要额外在TargetBuild SettingCustom Complier FlagsOther Swift Flags中添加-sanitize-coverage=func-sanitize=undefined

    2. 由于需要在所有的二进制文件添加代码,所以对使用CocoaPods的工程来说,也要添加额外配置:

      1
      2
      3
      4
      5
      6
      7
      8
      post_install do |installer|
      installer.pods_project.target.each do |target|
      target.build_configurations.each do |config|
      config.build_settings['OTHER_CFLAGS'] = '-fsanitize-coverage=func,trace-pc-guard'
      config.build_settings['OTHER_SWIFT_FLAGS'] = '-sanitize-coverage=func,-sanitize=undefined'
      end
      end
      end
    3. 在AppDelegate中实现方法:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      void __sanitizer_cov_trace_pc_guard_init(uint32_t *start,
      uint32_t *stop) {
      static uint64_t N; // Counter for the guards.
      if (start == stop || *start) return; // Initialize only once.
      printf("INIT: %p %p\n", start, stop);
      for (uint32_t *x = start; x < stop; x++)
      *x = ++N; // Guards should start from 1.
      }

      void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
      if (!*guard) return;
      void *PC = __builtin_return_address(0);
      char PcDescr[1024];
      printf("guard: %p %x PC %s\n", guard, *guard, PcDescr);
      }
    4. __sanitizer_cov_trace_pc_guard方法内打上断点,运行项目,执行到断点时,选择DebugDebug WorkflowAlways Show Disassembly(一直显示汇编)

    5. 在启动时会调用到的某个方法里打上断点,会发现在汇编出被插入了__sanitizer_cov_trace_pc_guard

    6. block被执行时也会被插入__sanitizer_cov_trace_pc_guard

      验证方法和block都可以被插桩无误,就可以着手生成order_file了。

      在启动后加载的第一个controller中添加生成order_file的代码如下,在生成filePath后加断点,等运行到这里时,即可拿到order_file的路径。

      代码部分:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      59
      60
      61
      62
      void __sanitizer_cov_trace_pc_guard_init(uint32_t *start, uint32_t *stop) {
      static uint64_t N; // Counter for the guards.
      if (start == stop || *start) return; // Initialize only once.
      printf("INIT: %p %p\n", start, stop);
      for (uint32_t *x = start; x < stop; x++)
      *x = ++N; // Guards should start from 1.
      }


      //初始化原子队列
      static OSQueueHead list = OS_ATOMIC_QUEUE_INIT;
      //定义节点结构体
      typedef struct {
      void *pc; //存下获取到的PC
      void *next; //指向下一个节点
      } Node;


      void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
      void *PC = __builtin_return_address(0);
      Node *node = malloc(sizeof(Node));
      *node = (Node){PC, NULL};
      // offsetof() 计算出列尾,OSAtomicEnqueue() 把 node 加入 list 尾巴
      OSAtomicEnqueue(&list, node, offsetof(Node, next));
      }

      - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
      NSMutableArray *arr = [NSMutableArray array];
      while(1){
      //有进就有出,这个方法和 OSAtomicEnqueue() 类比使用
      Node *node = OSAtomicDequeue(&list, offsetof(Node, next));
      //退出机制
      if (node == NULL) {
      break;
      }
      //获取函数信息
      Dl_info info;
      dladdr(node->pc, &info);
      NSString *sname = [NSString stringWithCString:info.dli_sname encoding:NSUTF8StringEncoding];
      printf("%s \n", info.dli_sname);
      //处理c函数及block前缀
      BOOL isObjc = [sname hasPrefix:@"+["] || [sname hasPrefix:@"-["];
      //c函数及block需要在开头添加下划线
      sname = isObjc ? sname: [@"_" stringByAppendingString:sname];

      //去重
      if (![arr containsObject:sname]) {
      //因为入栈的时候是从上至下,取出的时候方向是从下至上,那么就需要倒序,直接插在数组头部即可
      [arr insertObject:sname atIndex:0];
      }
      }

      //去掉 touchesBegan 方法 启动的时候不会用到这个
      [arr removeObject:[NSString stringWithFormat:@"%s",__FUNCTION__]];
      //数组合成字符串
      NSString * funcStr = [arr componentsJoinedByString:@"\n"];
      //写入文件
      NSString * filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"link.order"];
      NSData * fileContents = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
      NSLog(@"%@", filePath); // 打断点拿到order_file路径
      [[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
      }

    拿到order_file后放到项目根目录中,在Build SettingLinkingOrder File中设置order_file路径即可。

    如果项目对于启动流程相关的文件方法进行调整,再重新生成order_file文件更新即可。

    小记:本次优化使用了动态库转静态库 + 二进制重排方案。最终,项目启动速度冷启动大约提升44%;热启动大约提升46%;。

    优化前后对比如下: