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

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

0. 序言

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

最初的工程确定是没有这些问题的,但随着业务需求不断丰富,代码愈来愈多。若是听任无论的话,启动时间会不断上涨,最后让人没法接受。swift

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

下面咱们先上结论:缓存

  • 优化前:sass

    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%ruby

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

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>

/* 原子队列特色 一、先进后出 二、线程安全 三、只能保存结构体 */
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文件中,答案是能够的!

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

相关文章
相关标签/搜索