我是如何让微博绿洲的启动速度提升30%的

15,962 阅读22分钟

绿洲iOS研发工程师,绿洲ID:收纳箱KeepFit。

目录

  1. 《我是如何让微博绿洲的启动速度提升30%的》
  2. 《我是如何让微博绿洲的启动速度提升30%的(二)》
  3. 《懒人版二进制重排》

0. 序言

启动是App给用户的第一印象,对用户体验至关重要。试想一个App需要启动5s以上,你还想用它么?

最初的工程肯定是没有这些问题的,但随着业务需求不断丰富,代码越来越多。如果放任不管的话,启动时间会不断上涨,最后让人无法接受。

本文从优化原理出发,介绍了我是如何通过修改库的类型和Clang插桩找到启动所需符号,然后修改编译参数完成二进制文件的重新排布提升应用的启动速度的。

下面我们先上结论:

  • 优化前:

    Total pre-main time: 1.2 seconds (100.0%)
             dylib loading time: 567.72 milliseconds (45.5%)
            rebase/binding time: 105.14 milliseconds (8.4%)
                ObjC setup time:  40.01 milliseconds (3.2%)
               initializer time: 532.47 milliseconds (42.7%)
               slowest intializers :
                 libSystem.B.dylib :   4.70 milliseconds (0.3%)
              libglInterpose.dylib : 295.89 milliseconds (23.7%)
                      AFNetworking :  48.75 milliseconds (3.9%)
                             Oasis : 285.94 milliseconds (22.9%)
    
  • 优化后

    Total pre-main time: 822.34 milliseconds (100.0%)
             dylib loading time: 196.71 milliseconds (23.9%)
            rebase/binding time: 104.95 milliseconds (12.7%)
                ObjC setup time:  31.14 milliseconds (3.7%)
               initializer time: 489.53 milliseconds (59.5%)
               slowest intializers :
                 libSystem.B.dylib :   4.65 milliseconds (0.5%)
              libglInterpose.dylib : 230.19 milliseconds (27.9%)
                      AFNetworking :  41.60 milliseconds (5.0%)
                             Oasis : 335.84 milliseconds (40.8%)
    

通过staticlib优化二进制重排两项技术,我成功将绿洲的pre-main时间从1.2s降到了大约0.82s,提升了大约31.6%

两台手机都是iPhone 11 Pro,右边是优化后的效果。(原谅我右边点开还慢了一点😂)

1. 动态库转静态库

苹果建议将应用程序的总启动时间设定在400毫秒以下,并且我们必须在20秒之内完成启动,否则系统会杀死我们的应用程序。我们可以尽量优化应用main函数到didFinishLaunchingWithOptions的时间,但如何调试在调用代码之前发生的启动速度慢的情况呢?

1.1 Pre-main时间的查看

在系统执行应用程序的main函数并调用应用程序委托函数(applicationWillFinishLaunching)之前,会发生很多事情。我们可以将DYLD_PRINT_STATISTICS环境变量添加到项目scheme中。

DYLD_PRINT_STATISTICS

运行一下,我们可以看到控制台的输出:

Total pre-main time: 1.2 seconds (100.0%)
         dylib loading time: 567.72 milliseconds (45.5%)
        rebase/binding time: 105.14 milliseconds (8.4%)
            ObjC setup time:  40.01 milliseconds (3.2%)
           initializer time: 532.47 milliseconds (42.7%)
           slowest intializers :
             libSystem.B.dylib :   4.70 milliseconds (0.3%)
          libglInterpose.dylib : 295.89 milliseconds (23.7%)
                  AFNetworking :  48.75 milliseconds (3.9%)
                         Oasis : 285.94 milliseconds (22.9%)

这是我使用iPhone 11 Pro的运行结果。这里只是讲解各个部分的作用,不讨论如何优化和对比,不用深究这个时间。

注意:如果你要测试应用的最慢启动时间,记得使用你支持的最慢的设备来进行测试。

输出显示系统调用应用程序main时所用的总时间,然后是主要步骤的分解。

WWDC 2016 Session 406优化应用程序启动时间详细介绍了每个步骤以及改进时间的提示,以下是简要的总结说明:

  • dylib loading time 动态加载程序查找并读取应用程序使用的依赖动态库。每个库本身都可能有依赖项。虽然苹果系统框架的加载是高度优化的,但加载嵌入式框架可能会很耗时。为了加快动态库的加载速度,苹果建议您使用更少的动态库,或者考虑合并它们。
    • 建议的目标是六个额外的(非系统)框架
  • Rebase/binding time 修正调整镜像内的指针(重新调整)和设置指向镜像外符号的指针(绑定)。为了加快重新定位/绑定时间,我们需要更少的指针修复。
    • 如果有大量(大的是20000)Objective-C类、选择器和类别的应用程序可以增加800ms的启动时间。
    • 如果应用程序使用C++代码,那么使用更少的虚拟函数。
    • 使用Swift结构体通常也更快。
  • ObjC setup time Objective-C运行时需要进行设置类、类别和选择器注册。我们对重新定位绑定时间所做的任何改进也将优化这个设置时间。
  • initializer time 运行初始化程序。如果使用了Objective-C的 +load 方法,请将其替换为 +initialize 方法。

在系统调用main之后,main将依次调用UIApplicationMain和应用程序委托方法。

1.2 动态库与静态库加载的耗时

1.2.1 加载动态库耗时

我们先来看看工程里面有多少动态库:

  1. 在项目的Product文件夹找到我们的工程.app文件,右键选择Show in Finder
  2. 来到相应目录后右键选择显示包内容
  3. 找到Frameworks文件夹,打开。
  4. 项目是纯Swift编写,下面都是系统Swift库,我们没法优化,可以不管。

Product中Frameworks文件夹

可以看到我们的项目中有了36个动态库,下面是pre-main的总时间:

Total pre-main time: 1.2 seconds (100.0%)
         dylib loading time: 567.72 milliseconds (45.5%)
        rebase/binding time: 105.14 milliseconds (8.4%)
            ObjC setup time:  40.01 milliseconds (3.2%)
           initializer time: 532.47 milliseconds (42.7%)
           slowest intializers :
             libSystem.B.dylib :   4.70 milliseconds (0.3%)
          libglInterpose.dylib : 295.89 milliseconds (23.7%)
                  AFNetworking :  48.75 milliseconds (3.9%)
                         Oasis : 285.94 milliseconds (22.9%)

1.2.2 使用静态库耗时

在Pod的工程中,选择我们使用的库,然后点击Build Settings,搜索或者找到Mach-O Type设置,修改Mach-O TypeStatic Library

staticlib

按照上面的步骤,把我们的动态库的Mach-O Type都改成静态库,⇧+⌘+K执行一次Clean Build Folder,然后重新构建一次。

Product中Frameworks文件夹

这里还保留了3个动态库,是因为Objective-C没有命名空间,有符号冲突,就保留了下来。下面是pre-main的总时间:

Total pre-main time: 877.84 milliseconds (100.0%)
         dylib loading time: 220.07 milliseconds (25.0%)
        rebase/binding time: 112.29 milliseconds (12.7%)
            ObjC setup time:  30.78 milliseconds (3.5%)
           initializer time: 514.70 milliseconds (58.6%)
           slowest intializers :
             libSystem.B.dylib :   4.33 milliseconds (0.4%)
          libglInterpose.dylib : 253.44 milliseconds (28.8%)
                  AFNetworking :  37.08 milliseconds (4.2%)
                        OCLibs :  61.75 milliseconds (7.0%)
                         Oasis : 246.28 milliseconds (28.0%)

可以看到,通过修改Mach-O Type从动态库改为静态库,dylib loading time得到了很大的提升,而其他部分的耗时变化不大。总时间从1.2s降到了大约0.9s,优化了大约0.3s的启动时间。

1.2.3 遇到的坑

但是如果只改Mach-O Type的话,Archive之后在Organizer中尝试Validate App会报错:

  • Found an unexpected Mach-O header code: 0x72613c21

0x72613c21

其实这里是CocoaPods的一个配置问题,CocoaPods会在项目中的Build Phases添加一个 [CP] Embed Pods Frameworks 执行脚本。

"${PODS_ROOT}/Target Support Files/Pods-项目名/Pods-项目名-frameworks.sh"

我们在执行pod install后会生成一个Pods-项目名-frameworks.sh的脚本文件。由于我们是手动修改的Mach-O Type类型,这个脚本中的install_framework仍然会执行,所以我们要把转换成静态库的这些库从Pods-项目名-frameworks.sh文件中删除。

AFNetworking为例,需要从文件中删除:

install_framework "${BUILT_PRODUCTS_DIR}/AFNetworking/AFNetworking.framework"

当然你也可以写一个ruby脚本在使用CocoaPodspost_install进行处理。

  1. 把相关的库转成静态的。

    target.build_configurations.each do |config|
        config.build_settings['MACH_O_TYPE'] = 'staticlib'
    end
    
  2. 读取Pods-项目名-frameworks.sh文件,删除相关的字符串。

    regex = /install_framework.*\/#{pod_name}\.framework\"/
    pod_frameworks_content.gsub!(regex, "")
    

2. 二进制重排

2.1 App启动

进程如果能直接访问物理内存无疑是很不安全的,所以操作系统在物理内存的上又建立了一层虚拟内存。苹果在这个基础上还有 ASLR(Address Space Layout Randomization) 技术的保护,不过不是这次的重点。

iOS系统中虚拟内存到物理内存的映射都是以页为最小单位的。当进程访问一个虚拟内存Page而对应的物理内存却不存在时,就会出现Page Fault缺页中断,然后加载这一页。虽然本身这个处理速度是很快的,但是在一个App的启动过程中可能出现上千(甚至更多)次Page Fault,这个时间积累起来会比较明显了。

iOS系统中一页是16KB。

我们常说的启动是指点击App到第一页显示为止,包含pre-mainmaindidFinishLaunchingWithOptions结束的整个时间。maindidFinishLaunchingWithOptions结束,这个部分是我们可以控制的,已经有很多文章讲解应该怎么优化了,不是本文的重点。这里讲的二进制重排主要是针对如何减少Page Fault的优化。

另外,还有两个重要的概念:冷启动热启动。可能有些同学认为杀掉再重启App就是冷启动了,其实是不对的。

  • 冷启动

    程序完全退出,之间加载的分页数据被其他进程所使用覆盖之后,或者重启设备、第一次安装,才算是冷启动。

  • 热启动

    程序杀掉之后,马上又重新启动。这个时候相应的物理内存中仍然保留之前加载过的分页数据,可以进行重用,不需要全部重新加载。所以热启动的速度比较快。

后面会利用Instruments工具System Trace更直观地比较这两种启动。

2.2 二进制重排相关概念

2.2.1 二进制重排的意义

程序默认情况下是顺序执行的。

顺序加载

如果启动需要使用的方法分别在2页Page1Page2中(method1method3),为了执行相应的代码,系统就必须进行两个Page Fault

重排

如果我们对方法进行重新排列,让method1method3在一个Page,那么就可以较少一次Page Fault

那么怎么衡量重排效果并验证呢?

  • 查看Page Fault次数是否减少。
  • 查看编译过程的中间产物LinkMap文件进行确认。

2.2.2 System Trace

那么如何衡量页的加载时间呢?这里就用到了Instruments中的System Trace工具。

首先,重新启动设备(冷启动)。⌘+I打开Instruments,选择System Trace工具。

点击录制⏺后,出现第一个页面,马上停止⏹。过滤只显示Main Thread相关,选择Summary: Virtual Memory

  • File Backed Page In次数就是触发Page Fault的次数了。
  • Page Cache Hit就是页缓存命中的次数了。

冷启动

下面我们看看热启动的情况。杀掉App,接着直接重新执行一遍之前的操作(不重启):

热启动

对比冷启动和热启动的File Backed Page In次数,可以看到热启动情况下,触发的Page Fault的次数就变得很小了。

2.2.3 启动顺序

2.2.3.1 文件顺序

Build PhasesCompile Sources列表顺序决定了文件执行的顺序(可以调整)。如果不进行重排,文件的顺序决定了方法、函数的执行顺序。

Compile Sources

我们在ViewControllerAppDelegate中加入以下代码,并执行。

+ (void)load {
    NSLog(@"%s", __FUNCTION__);
}

//输出
2020-04-23 22:56:13.551729+0800 BinaryOptimization[59505:5477304] +[ViewController load]
2020-04-23 22:56:13.553714+0800 BinaryOptimization[59505:5477304] +[AppDelegate load]

我们调整Compile Sources中这两个类的顺序,然后再执行。

交换后

2020-04-23 23:00:08.248118+0800 BinaryOptimization[59581:5482198] +[AppDelegate load]
2020-04-23 23:00:08.249015+0800 BinaryOptimization[59581:5482198] +[ViewController load]

可以看到,随着Compile Sources中的文件顺序的修改,+load方法的执行顺序也发生了改变。

2.2.3.2 符号表顺序

Build Settings中修改Write Link Map FileYES编译后会生成一个Link Map符号表txt文件。

执行⌘ + B构建后,选择Product中的App,在Finder中打开,选择Intermediates.noindex文件夹,

Intermediates.noindex

找到LinkMap文件,这里是BinaryOptimization-LinkMap-normal-arm64.txt

image.png

打开文件之后来到第一部分的最后。

LinkMap

我们可以看到这个顺序和我们Compile Sources中的顺序是一致的。接下来的部分:

# Sections:
# Address	Size    	Segment	Section
0x100005ECC	0x0000065C	__TEXT	__text
0x100006528	0x0000009C	__TEXT	__stubs
0x1000065C4	0x000000B4	__TEXT	__stub_helper
0x100006678	0x000000BE	__TEXT	__cstring
0x100006736	0x00000D2B	__TEXT	__objc_methname
0x100007461	0x00000070	__TEXT	__objc_classname
0x1000074D1	0x00000ADA	__TEXT	__objc_methtype
0x100007FAC	0x00000054	__TEXT	__unwind_info
0x100008000	0x00000008	__DATA_CONST	__got
0x100008008	0x00000040	__DATA_CONST	__cfstring
0x100008048	0x00000018	__DATA_CONST	__objc_classlist
0x100008060	0x00000010	__DATA_CONST	__objc_nlclslist
0x100008070	0x00000020	__DATA_CONST	__objc_protolist
0x100008090	0x00000008	__DATA_CONST	__objc_imageinfo
0x10000C000	0x00000068	__DATA	__la_symbol_ptr
0x10000C068	0x00001348	__DATA	__objc_const
0x10000D3B0	0x00000018	__DATA	__objc_selrefs
0x10000D3C8	0x00000010	__DATA	__objc_classrefs
0x10000D3D8	0x00000008	__DATA	__objc_superrefs
0x10000D3E0	0x00000004	__DATA	__objc_ivar
0x10000D3E8	0x000000F0	__DATA	__objc_data
0x10000D4D8	0x00000188	__DATA	__data

这个是Mach-O的一些信息,不是这次的重点。接在这部分之后的符号才是,由于比较多,我只截取了部分。

# Symbols:
# Address	  Size    	  File  Name
0x100005ECC	0x0000003C	[  1] +[AppDelegate load]
0x100005F08	0x00000088	[  1] -[AppDelegate application:didFinishLaunchingWithOptions:]
0x100005F90	0x00000108	[  1] -[AppDelegate application:configurationForConnectingSceneSession:options:]
0x100006098	0x00000080	[  1] -[AppDelegate application:didDiscardSceneSessions:]
0x100006118	0x0000003C	[  2] +[ViewController load]
0x100006154	0x0000004C	[  2] -[ViewController viewDidLoad]
0x1000061A0	0x000000A0	[  3] _main
0x100006240	0x000000B4	[  4] -[SceneDelegate scene:willConnectToSession:options:]
0x1000062F4	0x0000004C	[  4] -[SceneDelegate sceneDidDisconnect:]
0x100006340	0x0000004C	[  4] -[SceneDelegate sceneDidBecomeActive:]
0x10000638C	0x0000004C	[  4] -[SceneDelegate sceneWillResignActive:]
0x1000063D8	0x0000004C	[  4] -[SceneDelegate sceneWillEnterForeground:]
0x100006424	0x0000004C	[  4] -[SceneDelegate sceneDidEnterBackground:]
0x100006470	0x0000002C	[  4] -[SceneDelegate window]
0x10000649C	0x00000048	[  4] -[SceneDelegate setWindow:]
0x1000064E4	0x00000044	[  4] -[SceneDelegate .cxx_destruct]
0x100006528	0x0000000C	[  5] _NSLog
0x100006534	0x0000000C	[  5] _NSStringFromClass
0x100006540	0x0000000C	[  7] _UIApplicationMain
0x10000654C	0x0000000C	[  6] _objc_alloc
0x100006558	0x0000000C	[  6] _objc_autoreleasePoolPop
0x100006564	0x0000000C	[  6] _objc_autoreleasePoolPush
...

可以看到,整体的顺序和Compile Sources的中的顺序是一样的,并且方法是按照文件中方法的顺序进行链接的。AppDelegate中的方法添加完后,才是ViewController中的方法,以此类推。

  • Address 表示文件中方法的地址。
  • Size 表示方法的大小。
  • File 表示在第几个文件中。
  • Name 表示方法名。

2.2.4 二进制重排初体验

在项目根目录创建一个order文件。

touch BinaryOptimization.order

然后在Build Settings中找到Order File,填入./BinaryOptimization.order

Order File

BinaryOptimization.order文件中填入:

+[ViewController load]
+[AppDelegate load]
_main
-[ViewController someMethod]

然后执行⌘ + B构建。

image.png

可以看到Link Map中的最上面几个方法和我们在BinaryOptimization.order文件中设置的方法顺序一致!

Xcode的连接器ld还忽略掉了不存在的方法 -[ViewController someMethod]

如果提供了link选项 -order_file_statistics,会以warning的形式把这些没找到的符号打印在日志里。

2.3 二进制重排实战

要真正的实现二进制重排,我们需要拿到启动的所有方法、函数等符号,并保存其顺序,然后写入order文件,实现二进制重排。

抖音有一篇文章抖音研发实践:基于二进制文件重排的解决方案 APP启动速度提升超15%,但是文章中也提到了瓶颈:

基于静态扫描+运行时trace的方案仍然存在少量瓶颈:

  • initialize hook不到
  • 部分block hook不到
  • C++通过寄存器的间接函数调用静态扫描不出来

目前的重排方案能够覆盖到80%~90%的符号,未来我们会尝试编译期插桩等方案来进行100%的符号覆盖,让重排达到最优效果。

同时也给出了解决方案编译期插桩

2.3.1 Clang插桩

其实就是一个代码覆盖工具,更多信息可以查看官网

Build SettingsOther C Flags添加-fsanitize-coverage=trace-pc-guard配置,编译的话会报错。

Undefined symbol: ___sanitizer_cov_trace_pc_guard_init
Undefined symbol: ___sanitizer_cov_trace_pc_guard

查看官网会需要我们添加一个两个函数:

#include <stdint.h>
#include <stdio.h>
#include <sanitizer/coverage_interface.h>

// This callback is inserted by the compiler as a module constructor
// into every DSO. 'start' and 'stop' correspond to the
// beginning and end of the section with the guards for the entire
// binary (executable or DSO). The callback will be called at least
// once per DSO and may be called multiple times with the same parameters.
extern "C" 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.
}

// This callback is inserted by the compiler on every edge in the
// control flow (some optimizations apply).
// Typically, the compiler will emit the code like this:
//    if(*guard)
//      __sanitizer_cov_trace_pc_guard(guard);
// But for large functions it will emit a simple call:
//    __sanitizer_cov_trace_pc_guard(guard);
extern "C" void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
  if (!*guard) return;  // Duplicate the guard check.
  // If you set *guard to 0 this code will not be called again for this edge.
  // Now you can get the PC and do whatever you want:
  //   store it somewhere or symbolize it and print right away.
  // The values of `*guard` are as you set them in
  // __sanitizer_cov_trace_pc_guard_init and so you can make them consecutive
  // and use them to dereference an array or a bit vector.
  void *PC = __builtin_return_address(0);
  char PcDescr[1024];
  // This function is a part of the sanitizer run-time.
  // To use it, link with AddressSanitizer or other sanitizer.
  __sanitizer_symbolize_pc(PC, "%p %F %L", PcDescr, sizeof(PcDescr));
  printf("guard: %p %x PC %s\n", guard, *guard, PcDescr);
}

我们把代码添加到ViewController.m中,我们不需要 extern "C" 所以可以删掉, __sanitizer_symbolize_pc() 还会报错,不重要先注释了然后继续。

#include <stdint.h>
#include <stdio.h>
#include <sanitizer/coverage_interface.h>

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;  // Duplicate the guard check.
//  void *PC = __builtin_return_address(0);
  char PcDescr[1024];
//  __sanitizer_symbolize_pc(PC, "%p %F %L", PcDescr, sizeof(PcDescr));
  printf("guard: %p %x PC %s\n", guard, *guard, PcDescr);
}

函数 __sanitizer_cov_trace_pc_guard_init统计了方法的个数。运行后,我们可以看到:

INIT: 0x104bed670 0x104bed6b0

(lldb) x 0x104bed670
0x104bed670: 01 00 00 00 02 00 00 00 03 00 00 00 04 00 00 00  ................
0x104bed680: 05 00 00 00 06 00 00 00 07 00 00 00 08 00 00 00  ................
(lldb) x 0x104bed6b0-0x4
0x104bed6ac: 10 00 00 00 10 00 00 00 00 00 00 00 00 00 00 00  ................
0x104bed6bc: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................

读取内存之后,我们可以看到一个类似计数器的东西。最后一个打印的是结束位置,按显示是4位4位的,所以向前移动4位,打印出来的应该就是最后一位。

根据小端模式,10 00 00 00对应的是00 00 00 10即16。我们在ViewController中添加一些方法:

void(^block)(void) = ^(void){
    
};

void test()
{
    block();
}

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    test();
}

再打印一次:

(lldb) x 0x10426d6dc-0x4
0x10426d6d8: 13

可以看到增加了3(block是匿名函数),计数器统计了函数/方法的个数,这里添加了三个,索引增加了3。

我们再点击一下屏幕:

guard: 0x1007196ac 8 PC 
guard: 0x1007196a8 7 PC 
guard: 0x1007196a4 6 PC Hq

我们发现,每点击一次屏幕就有3个打印。我们在touchesBegan:touches withEvent:开头设置一个点断,并开启汇编显示(菜单栏DebugDebug WorkflowAlways Show Disassembly)。

断点汇编

如果我们查看其他函数也会发现汇编代码中有类似的显示。

也就是说Clang插桩就是在汇编代码中插入了 __sanitizer_cov_trace_pc_guard函数的调用。

拿到了全部的符号之后需要保存,但是不能用数组,因为有可能会有在子线程执行的,所以用数组会有线程问题 。这里我们使用原子队列:

#import <libkern/OSAtomic.h>
#import <dlfcn.h>

/*
 原子队列特点
 1、先进后出
 2、线程安全
 3、只能保存结构体
 */
static OSQueueHead symbolList = OS_ATOMIC_QUEUE_INIT;

// 符号结构体链表
typedef struct {
    void *pc;
    void *next;
} SymbolNode;

void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
    if (!*guard) return;  // Duplicate the guard check.
    
    // 函数执行前会将下一个要执行的函数地址保存到寄存器中
    // 这里是拿到函数的返回地址
    void *PC = __builtin_return_address(0);
    
    SymbolNode * node = malloc(sizeof(SymbolNode));
    *node = (SymbolNode){PC, NULL};
    // 入队
    OSAtomicEnqueue(&symbolList, node, offsetof(SymbolNode, next));
    
    // 以下是一些打印,只是看一下,实际中可以注释
    // dlopen 通过动态库拿到句柄 通过句柄拿到函数的内存地址
    // dladdr 通过函数内存地址拿到函数
    typedef struct dl_info {
        const char      *dli_fname;     /* Pathname of shared object      函数的路径  */
        void            *dli_fbase;     /* Base address of shared object  函数的地址  */
        const char      *dli_sname;     /* Name of nearest symbol         函数符号    */
        void            *dli_saddr;     /* Address of nearest symbol      函数起始地址 */
    } Dl_info;
    Dl_info info;
    dladdr(PC, &info);
    printf("fnam:%s \n fbase:%p \n sname:%s \n saddr:%p \n",
           info.dli_fname,
           info.dli_fbase,
           info.dli_sname,
           info.dli_saddr);
}

运行后这里我们可以看到很多打印,只取一条来说明,很明显其中sname就是我们需要的符号名了。

fnam:/private/var/containers/Bundle/Application/3EAE3817-0EF7-4892-BC55-368CC504A568/BinaryOptimization.app/BinaryOptimization 
 fbase:0x100938000 
 sname:+[AppDelegate load] 
 saddr:0x10093d81c 

下面我们通过点击屏幕导出所需要的符号,需要注意的是C函数和Swift方法前面需要加下划线。(这里点可以在前面提到的LinkMap文件中确认)

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
    NSMutableArray <NSString *>* symbolNames = [NSMutableArray array];
    while (YES) {
        SymbolNode * node = OSAtomicDequeue(&symbolList, offsetof(SymbolNode, next));
        if (node == NULL) {
            break;
        }
        Dl_info info;
        dladdr(node->pc, &info);
        
        NSString * name = @(info.dli_sname);
        BOOL isObjc = [name hasPrefix:@"+["] || [name hasPrefix:@"-["]; //OC方法不处理
        NSString * symbolName = isObjc? name : [@"_" stringByAppendingString:name]; //c函数、swift方法前面带下划线
        [symbolNames addObject:symbolName];
        printf("%s \n",info.dli_sname);
    }
    
    NSEnumerator * emt = [symbolNames reverseObjectEnumerator];
    NSMutableArray<NSString*>* funcs = [NSMutableArray arrayWithCapacity:symbolNames.count];
    NSString * name;
    while (name = [emt nextObject]) {
        if (![funcs containsObject:name]) {
            [funcs addObject:name];
        }
    }
    // 删掉当前方法,因为这个点击方法不是启动需要的
    [funcs removeObject:[NSString stringWithFormat:@"%s",__FUNCTION__]];
    
    NSString * filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"BinaryOptimization.order"];
    NSString *funcStr = [funcs componentsJoinedByString:@"\n"];
    NSData * fileContents = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
    // 在路径上创建文件
    [[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
    
    NSLog(@"%@",filePath);
}

这时如果你直接点击屏幕,有个巨坑,会看到控制台一直在输出,出现了死循环:

-[ViewController touchesBegan:withEvent:] 
-[ViewController touchesBegan:withEvent:] 
...

我们在while里面设置一个断点:

image.png

发现 __sanitizer_cov_trace_pc_guard居然有10个,这个地方会触发 __sanitizer_cov_trace_pc_guard中的入队,这里又进行出队,最后就死循环了。

解决办法:

Build SettingsOther C Flags添加func配置,即-fsanitize-coverage=func,trace-pc-guard

官网对func的参数的解释:只检测每个函数的入口。

再次运行点击屏幕就不会有问题了。

2.3.2 从真机上获取order文件

我们把order文件存在了真机上的tmp文件夹中,要怎么拿到呢?

WindowDevices And Simulators(快捷键⇧+⌘+2)中:

获取真机文件

2.3.3 Swift

Swift也可以重排么?当然可以!

我们在项目中添加一个Swift类,然后在viewDidLoad调用一下:

class SwiftTest: NSObject {
    @objc class public func swiftTestLoad(){
        print("swiftTest");
    }
}

- (void)viewDidLoad {
    [super viewDidLoad];
    [SwiftTest swiftTestLoad];
}

Build SettingOther Swift Flags设置:

-sanitize-coverage=func
-sanitize=undefined

运行后点击一下屏幕,查看控制台:

-[ViewController touchesBegan:withEvent:] 
-[SceneDelegate window] 
-[SceneDelegate window] 
-[SceneDelegate sceneDidBecomeActive:] 
-[SceneDelegate sceneWillEnterForeground:] 
// 下面这4个就是Swift的
$ss5print_9separator10terminatoryypd_S2StFfA1_ 
$ss5print_9separator10terminatoryypd_S2StFfA0_ 
$s18BinaryOptimization9SwiftTestC05swiftD4LoadyyFZ 
$s18BinaryOptimization9SwiftTestC05swiftD4LoadyyFZTo 
-[ViewController viewDidLoad] 
-[SceneDelegate window] 
-[SceneDelegate window] 
-[SceneDelegate window] 
-[SceneDelegate scene:willConnectToSession:options:] 
-[SceneDelegate window] 
-[SceneDelegate window] 
-[SceneDelegate setWindow:] 
-[SceneDelegate window] 
-[AppDelegate application:didFinishLaunchingWithOptions:] 
main 
2020-04-24 13:08:43.923191+0800 BinaryOptimization[459:65420] /private/var/mobile/Containers/Data/Application/DA2EC6F0-93C9-45A0-9D95-C21883E0532C/tmp/BinaryOptimization.order

所有处理完之后,最后需要Write Link Map File改为NO,把Other C Flags/Other Swift Flags的配置删除掉。

因为这个配置会在我们代码中自动插入跳转执行 __sanitizer_cov_trace_pc_guard。重排完就不需要了,需要去除掉。 同时把ViewController中的 __sanitizer_cov_trace_pc_guard也要去除掉。

2.3.4 二进制重排前后的对比

在项目中进行实践并测试之后:

  • 进行二进制重排前,File Backed Page In(Page Fault Count)发生了2569次,耗时298ms

Page Fault Count

  • 进行二进制重排后,File Backed Page In(Page Fault Count)发生了2311次,耗时248ms

Page Fault Count

可以看到,经过二进制重排减少了Page Fault的次数,总时间从298ms降到了大约248ms,优化了大约50ms的启动时间。

3. 总结

  1. 通过将动态库转为静态库,我们优化了dylib loading time
    • 苹果官方建议为6个以下,这里我们因为符号冲突,只保留了3个动态库。
  2. 通过二进制重排,让启动需要的方法排列更紧凑,减少了Page Fault的次数。
    • 获取符号表时,采用Clang插桩可以直接hook到Objective-C方法、Swift方法、C函数、Block,可以不用区别对待。相比于抖音之前提出的方案确实简单很多,门槛也要低一些。

重要:

有朋友问到Pod中的三方库能否加入order文件中,答案是可以的!

文中的二进制重排实践过程,考虑了三方库的启动时需要的符号。文章里面没有特别说明,但原理是一样的。

4.补充

静态化后,三方库会被合并到主工程的Mach-O文件中,可能出现的问题:

  1. 三方库中使用了[NSBundle bundleForClass:[self class]]的行为会和[NSBundle mainBundle]一致。
  2. 由于上一个问题可能导致Bundle找不到的问题(目前正在尝试能否处理)。

资源的问题已经找到,解决方案已经有了。有空我整理一下,把文章发出来。感谢大家的支持~

答应大家的文章来了~我是如何让微博绿洲的启动速度提升30%的(二)

如果觉得本文对你有所帮助,给我点个赞吧~