七猫 iOS 启动时间优化

前言

随着产品的迭代,产品功能越来越多, App 大小越来越大,导致越来越多的体验和性能问题,其中用户首先感知的肯定是启动速度。传统的启动优化有减少不必要代码,懒加载动态库,任务优先级划分等,此类相关优化的策略已经很普遍了,这些优化主要是从减少主线程任务的角度来出发,很难再做出大的提升。

App 启动时都做了些什么?

一般而言,App 的启动时间,指的是从用户点击 App 开始,到用户看到第一个界面之间的时间。启动主要包括三个阶段:

  1. main() 函数执行前;
  2. main() 函数执行后;
  3. 首屏渲染完成后。

main函数之前

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

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

main函数

main函数执行后的阶段,指的是从 main函数执行开始,到 AappDelegate 的 didFinishLaunchingWithOptions 方法里首屏渲染相关方法执行完成。

首屏渲染完成后

首屏渲染后的这个阶段,主要完成的是,非首屏其他业务服务模块的初始化、监听的注册、配置文件的读取等。

减少main函数前的dyld的加载

如何统计各个阶段的执行时间?

Xcode 为我们提供了一个参数DYLD_PRINT_STATISTICS,配置完成后,就可以获取 pre-main 各个阶段的时间。

运行一下工程,控制台会输出以下内容:

Total pre-main time: 1.1 seconds (100.0%)
         dylib loading time: 296.28 milliseconds (25.3%)
        rebase/binding time: 135.08 milliseconds (11.5%)
            ObjC setup time:  71.43 milliseconds (6.1%)
           initializer time: 664.75 milliseconds (56.9%)
           slowest intializers :
             libSystem.B.dylib :  14.72 milliseconds (1.2%)
                      YYReader : 668.40 milliseconds (57.2%)

在减少十个动态库后,使用 iPhone 7 测试十组启动数据:

看出来,使用静态库确实可以减少一部分的启动时候,而且减少的动态库越多,效果越明显。

动态库和静态库对比

库类型 优点 缺点
静态库 1、链接时会完整的复制到 mach-o 中
2、由系统一次性加载,效率更高
1. 会使主 mach-o 文件增大
动态库 1、 无需拷贝到主 mach-o 文件中,主 mach-o 体积小
2、可运多个应用程序共享内存中得同一份库文件,节省资源
1. 需要额外加载动态库,造成性能损耗
2、动态库在程序运行时由系统动态加载到内存,供程序调用,如果环境缺少动态库或者库的版本不正确,就会导致程序无法运行

动态库如何修改为静态库?

  • 在公司的git仓库中创建一个私有项目,用于存放私有podspec
  • Alamofire开源代码中找到原作者提供的podspec文件
Pod::Spec.new do |s|
  s.name = 'Alamofire'
  s.version = '4.8.1'
  s.license = 'MIT'
  s.summary = 'Elegant HTTP Networking in Swift'
  s.homepage = 'https://github.com/Alamofire/Alamofire'
  s.social_media_url = 'http://twitter.com/AlamofireSF'
  s.authors = { 'Alamofire Software Foundation' => 'info@alamofire.org' }
  s.source = { :git => 'https://github.com/Alamofire/Alamofire.git', :tag => s.version }
  s.documentation_url = 'https://alamofire.github.io/Alamofire/'

  s.ios.deployment_target = '8.0'
  s.osx.deployment_target = '10.10'
  s.tvos.deployment_target = '9.0'
  s.watchos.deployment_target = '2.0'
  
  s.source_files = 'Source/*.swift'
end
  • 拷贝作者的podspec文件,在文件中加入支持静态库打包的代码
Pod::Spec.new do |s|
  s.name = 'Alamofire'
  s.version = '4.8.1'
  s.license = 'MIT'
  s.summary = 'Elegant HTTP Networking in Swift'
  s.homepage = 'https://github.com/Alamofire/Alamofire'
  s.social_media_url = 'http://twitter.com/AlamofireSF'
  s.authors = { 'Alamofire Software Foundation' => 'info@alamofire.org' }
  s.source = { :git => 'https://github.com/Alamofire/Alamofire.git', :tag => s.version }
  s.documentation_url = 'https://alamofire.github.io/Alamofire/'

  s.ios.deployment_target = '8.0'
  s.osx.deployment_target = '10.10'
  s.tvos.deployment_target = '9.0'
  s.watchos.deployment_target = '2.0'
  
  # 支持静态库打包
  s.static_framework = true
  s.source_files = 'Source/*.swift'
end
  • 将新生成的文件放入私有仓库中 为了与线上版本进行准确区分可以搞一个特殊的版本号
  • 在Pod中引入私有仓库 pod集成特定版本的项目即可

Clang插桩实现二进制重排

自从抖音分析了抖音研发实践:基于二进制文件重排的解决方案 APP启动速度提升超15%文章, 二进制重排优化 pre-main 阶段的启动时间自此被大家广为流传 。抖音采用的是静态扫描+运行时trace的方案,目前仍存一些问题:hook Objc_msgSend 无法解决的 纯swift , block , c++ 方法
通过调研,我们发现clang 插桩的方式可以完美解决抖音遇到问题。

原理

  1. Page Fault
    进程如果能直接访问物理内存无疑是很不安全的,所以操作系统在物理内存的上又建立了一层虚拟内存。为了提高效率和方便管理,又对虚拟内存和物理内存又进行分页(Page)。当进程访问一个虚拟内存Page而对应的物理内存却不存在时,会触发一次缺页中断(Page Fault),分配物理内存,有需要的话会从磁盘mmap读取数据。

通过App Store渠道分发的App,Page Fault还会进行签名验证,所以一次Page Fault的耗时比想象的要多:

查看 Page Fault 的数量
我们用到 Instruments 中的 System Trace工具

  1. 重排
    编译器在生成二进制代码的时候,默认按照链接的Object File(.o)顺序写文件,按照Object File内部的函数顺序写函数

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

iOS App之所以能够使用二进制重排,是因为Xcode 已经提供好这个机制 , 并且 libobjc 实际上也是用了二进制重排进行优化 .

  1. 获取启动加载所有的函数的符号
    如何获取所有的符号信息?
    • Hook: oc 或者 swift @objc dynamic 修饰的方法,调用都会通过 objc_MsgSend 发送消息,hook objc_MsgSend 可以做到这个方法的检测。但如果是可变参数个数,则需要汇编来获取参数
    • 二进制静态扫描: Mach-O文件在特定段Segment和Section里存储着符号及函数数据,通过静态扫描Mach-O文件,主要是分析获取load方法和c++ constructor 构造方法。
    • clang 汇编插桩: clang 本身已经提供了一个代码覆盖率检测机制SanitizerCoverage

实施

  1. clang 插桩
    使用 clang 自带的 SanitizerCoverage 工具
    新建一个测试工程:Xcode -> Build Setting -> Other C Flags,添加 -fsanitize-coverage=trace-pc-guard
    注: 如果项目内使用了 Swift, 需要在 Other Swift Flags -sanitize-coverage=func 和 -sanitize=undefined__
    在 ViewController.m 中添加SanitizerCoverage中代码
#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);
}

运行工程,查看打印

代码命名 INIT 后面打印的两个指针地址叫 start 和 stop . 那么我们通过 lldb 来查看下从 start 到 stop 这个内存地址里面所存储的到底是啥 .

发现存储的是从 1 到 14 这个序号 . 那么我们来添加一个 oc 方法 .

- (void)test{
    
}

发现从 0e 变成了 0f . 也就是说存储的 1 到 14 这个序号变成了 1 到 15 .

在添加一个 一些函数 ,oe 变成了 11,也就十进制的 17

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

void test()
{
    block();
}

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

也就说明这个stop内存保存的就是工程所有函数的个数.
我们发现,每点击一次屏幕就有3个打印。我们在touchesBegan:touches withEvent:开头设置一个点断,并开启汇编显示(菜单栏Debug→Debug Workflow→Always Show Disassembly)

可以看出,编译器在touchesBegan前面调用了 __sanitizer_cov_trace_pc_guard函数。
通过断点调试,我们发现每次函数的调用,都会先到__sanitizer_cov_trace_pc_guard函数中来,所以我们可以在 __sanitizer_cov_trace_pc_guard 获取 PC 寄存器,根据 PC 寄存器地址从而获取方法的名称。
拿到了全部的符号之后需要保存,但是由于方法会在各个线程执行,所以不能用数组直接保存数据。所以可以把获取到的数据保存在原子队列中,然后我们从队列获取调用的方法名称。


#import <libkern/OSAtomic.h>
#import <dlfcn.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.
  for (uint32_t *x = start; x < stop; x++)
    *x = (uint32_t)++N;  // Guards should start from 1.
}

void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
    if (!*guard) return;  // Duplicate the guard check.
    // 获取函数__sanitizer_cov_trace_pc_guard调用时下个寄存的地址
    void *PC = __builtin_return_address(0);
    
    // 根据地址获取调用的方法名称
    SymbolNode * node = malloc(sizeof(SymbolNode));
    *node = (SymbolNode){PC, NULL};
    
    // offsetof 用在这里是为了入队添加下一个节点找到 前一个节点next指针的位置
    OSAtomicEnqueue(&symbolList, node, offsetof(SymbolNode, next));
}

static OSQueueHead symbolList = OS_ATOMIC_QUEUE_INIT;

typedef struct {
    void *pc;
    void *next;
} SymbolNode;

+ (BOOL)export {
    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);
        // 判读是否是 oc 的方法
        BOOL isObjc = [name hasPrefix:@"+["] || [name hasPrefix:@"-["];
        // c 方法前加 _
        NSString * symbolName = isObjc? name : [@"_" stringByAppendingString:name];
        // 去重
        if (![symbolNames containsObject:symbolName]) {
            [symbolNames addObject:symbolName];
        }
    }
    
    // 取反
    NSMutableArray *funcs = [[symbolNames reverseObjectEnumerator] allObjects].mutableCopy;
    // 去除当前方法的 symbols
    [funcs removeObject:[NSString stringWithFormat:@"%s", __FUNCTION__]];
    
    
    //将结果写入到文件
    NSString * funcString = [funcs componentsJoinedByString:@"\n"];
    NSData * fileContents = [funcString dataUsingEncoding:NSUTF8StringEncoding];
    // 写入指定文件夹
    return [[NSFileManager defaultManager] createFileAtPath:[NSHomeDirectory() stringByAppendingString:@"/lb.roder"] contents:fileContents attributes:nil];
}
  1. 从真机上获取order文件
    我们把order文件存在了真机上的tmp文件夹中,要怎么拿到呢?
    Window→Devices And Simulators(快捷键⇧+⌘+2)中:

  2. Xcode 是用的链接器叫做 ld , ld 有一个参数叫 Order File , 我们可以通过这个参数配置一个 order 文件的路径,在这个 order 文件中 , 将你需要的符号按顺序写在里面 ,当工程 build 的时候 , Xcode 会读取这个文件 , 打的二进制包就会按照这个文件中的符号顺序进行生成对应的 mach-O

  3. 校验是重排成功
    Build Settings中修改Write Link Map File为YES编译后会生成一个Link Map符号表txt文件

    执行⌘ + B构建后,选择Product中的App,在Finder中打开,选择Intermediates.noindex文件夹,找到LinkMap文件,这里是*-LinkMap-normal-arm64.txt。

对比 txt 文档和 order 文件是否可以匹配
同时可以在 System Trace 工具中查看是否减少。

优化

由于每次都要要导入重排的源代码,操作很复杂,所以我把上述的一些代码封装成了一个组件,方便后续的重排文件的生产。

使用起来也很简单

// 第一步
use_frameworks!

platform :ios, '9.0'

target 'QMTracingPCs_Example' do
  pod 'QMTracingPCs'
end

// 第二步
// 如果包含三方库,可以添加如下配置
post_install do |installer|
    require './Pods/QMTracingPCs/QMTracingPCs/Classes/target_track.rb'
    target_track(installer)
end

// 第三步
// 在项目内启动成功的地方,添加如何代码
override func viewDidLoad() {
    super.viewDidLoad()
    let filePath = NSTemporaryDirectory().appending("/YYTracingPCs.order")
    QMTracingPCs.exportSymbols(filePath: filePath)
}

同时在这个工具中添加了二进制重排预分析工具,只需要切换 target 就可以使用

使用方式:

  1. 在 Target -> Build Settings 下,找到 Write Link Map File 来设置输出与否 , 默认是 no .
  2. 修改完毕之后,clean 一下,运行工程,Products -> Show in Finder,在mach-o文件上上层目录 __Intermediates.noindex__文件下找到一个txt文件。将其重命名为linked_map.txt
  3. 从沙盒路径获取 order 文件,
  4. 把 txt 文件和 order 放在 Desktop 文件夹下,配置路径
// 链接文件和order文件根目录 注意:换成自己的路径字符串
static let BASE_PATH: String = "/Users/mumu/Desktop"
// 链接文件名
static let LINKED_MAP: String = "linked_map.txt"
// order 文件名
static let LB_ORDER: String = "lb.order"

控制台输出

linked map __Text(链接文件):
     起始地址:0x1000051ec
     结束地址:0x101a38c58
     分配的虚拟内存页个数:1677
order symbol(重排文件):
     需要重排的符号个数:%:1774
     分布的虚拟内存页个数:238
     二进制重排后分布的虚拟内存页个数:21
     内存缺页中断减少的个数:217
     预估节省的时间:108.5 ms

使用二进制重排之后的工程,再次分别编译出 linked_map.txt 和 lb.order 文件,使用此工具再次运行检查,确认重排效果。

linked map __Text(链接文件):
     起始地址:0x1000060ac
     结束地址:0x102b0c658
     分配的虚拟内存页个数:2754
order symbol(重排文件):
     需要重排的符号个数:%:3570
     分布的虚拟内存页个数:71
     二进制重排后分布的虚拟内存页个数:71
     内存缺页中断减少的个数:0
     预估节省的时间:0.0 ms

PGO 优化

PGO(Performance Guided Optimization), 是 Xoode 提供一种优化方案,但苹果本身的方案放在我们这些采用 CI 工具构建的大型 app 上部署和使用起来较为麻烦,且不利于我们自己去发现分析问题。同时 PGO 是针对 Objective-C 的一种优化方案,对 Swift支持不太好,所有我们就不再做次方面的优化了。

总结

  1. 通过将动态库转为静态库,我们优化了dylib loading time
  2. 通过二进制重排,让启动需要的方法排列更紧凑,减少了Page Fault的次数
  3. 由于代码逻辑的改动,建议三个月做一次重排,生成一次 order 文件。

这篇文章主要讲 pre-mian 之前的一些优化操作,其实 mian 之后也有一些优化操作,比如启动是不必要代码的减少,动态库的懒加载,以及一些非必要启动任务的延后等等。

参考文档

libobjc

Clang SanitizerCoverage

抖音研发实践:基于二进制文件重排的解决方案 APP启动速度提升超15%

脉脉iOS如何启动秒开

懒人版二进制重排

iOS 优化篇 - 启动优化之Clang插桩实现二进制重排

展示评论