iOS 启动优化(二)二进制重排

App启动分析

App启动分析

App启动分为 冷启动热启动

  • 冷启动:点击 App 启动前,它的进程不在系统里,需要系统新创建一个进程分配给它的情况。这是一次完整的启动过程
  • 热启动:App 在冷启动后,用户将App 退到后台,即在App的进程还在系统里的情况下,用户重新启动进入 App 的过程,这个过程做的事情非常少,启动速度非常快。

因此,我们主要针对 App 冷启动进行优化

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

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

1、pre-main耗时检测
通过设置环境变量来统计 pre-main 的耗时

选择 `Edit Scheme` - `Arguments` - `Environment Variables`

添加 name `DYLD_PRINT_STATISTICS` value : `${DEBUG_ACTIVITY_MODE}`
启动时间检测日志.png

可见,在 main() 函数执行前,系统主要会做下面几件事情:

  • dylib loading:加载可执行文件(App 的.o 文件的集合),加载动态链接库
  • rebase/binding:对动态链接库进行 rebase 指针调整和 bind 符号绑定;
  • Objc setup:Objc 运行时的初始化处理,包括 Objc 相关类的注册、category 注册、selector 唯一性检查等;
  • initializer:初始化,包括了执行 +load() 方法、attribute((constructor)) 修饰的函数的调用、创建 C++ 静态全局变量。

相应地,这个阶段对于启动速度优化来说,可以做的事情包括:

  • 减少动态库加载:每个库本身都有依赖关系,苹果公司建议使用更少的动态库,并且建议在使用动态库的数量较多时,尽量将多个动态库进行合并。数量上,苹果公司建议最多使用 6 个非系统动态库
  • 减少加载启动后不会去使用的类或者方法。
  • +load()方法里的内容可以放到首屏渲染完成后再执行,或使用 +initialize()方法替换掉。因为,在一个 +load() 方法里,进行运行时方法替换操作会带来 4 毫秒的消耗。不要小看这 4 毫秒,积少成多,执行 +load() 方法对启动速度的影响会越来越大。
  • 控制 C++ 全局变量的数量。
launch.png

当我们做了以上工作,对 pre-main 的时间有所优化之后,如果还想再进行优化,那就需要使用 LLVM 为我们提供的优化方式:二进制重排

Page Fault

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

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


PageFault.png

LinkMap

LinkMap 是iOS编译过程的中间产物,记录了二进制文件的布局,需要在Xcode的Build Settings里开启Write Link Map File:

MapLink.png

Path to Link Map File:
选中编译后的 app,Show In Finder -- 找到build目录 -- 具体路径如下:
Build/Intermediates.noindex/Spirit.build/Debug-iphonesimulator/Spirit.build/Spirit-LinkMap-normal-x86_64.txt

LinkMap 主要包括三大部分:

  • Object Files 生成二进制用到的link单元的路径和文件编号
  • Sections 记录Mach-O每个Segment/section的地址范围
  • Symbols 按顺序记录每个符号的地址范围
引入文件顺序.png
链接函数顺序.png

通过MapLink就可以看到链接的函数的顺序和引用的文件顺序是一致的

重排

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

静态库文件.a就是一组.o文件的ar包,可以用ar -t查看.a包含的所有.o。

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

PageFaultFlowOne.png

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

PageFaultFlowTwo.png

为了完成重排,有以下几个问题要解决:

  • 重排效果怎么样 - 获取启动阶段的page fault次数
  • 重排成功了没 - 拿到当前二进制的函数布局
  • 如何重排 - 让链接器按照指定顺序生成Mach-O
  • 重排的内容 - 获取启动时候用到的函数

获取启动阶段的page fault次数

System Trace

日常开发中性能分析是用最多的工具无疑是Time Profiler,但Time Profiler是基于采样的,并且只能统计线程实际在运行的时间,而发生Page Fault的时候线程是被blocked,所以我们需要用一个不常用但功能却很强大的工具:System Trace

SystemTraceIDE.png

选中主线程,在VM Activity中的File Backed Page In次数就是Page Fault次数,并且双击还能按时序看到引起Page Fault的堆栈:

File Backed Page In 即为 Page Fault 的个数

拿到当前二进制的函数布局

可以通过Map Link 获取到当前的二进制函数布局表

让链接器按照指定顺序生成Mach-O

ld

Xcode 使用的链接器件是ld,ld有一个不常用的参数 -order_file,通过man ld可以看到详细文档:

Alters the order in which functions and data are laid out.For each section in the output file, any symbol in that sec-tion that are specified in the order file file is moved to the start of its section and laid out in the same order as in the order file file. Order files are text files with one symbol name per line. Lines starting with a # are comments. A symbol name may be optionally preceded with its object file leaf name and a colon (e.g. foo.o:_foo). This is useful for static functions/data that occur in multiple files. A symbol name may also be optionally preceded with the architecture (e.g. ppc:_foo or ppc:foo.o:_foo). This enables you to have one order file that works for multiple architectures. Lit-eral c-strings may be ordered by by quoting the string (e.g."Hello, world\n") in the order file.

可以看到,order_file中的符号会按照顺序排列在对应section的开始,完美的满足了我们的需求。

Xcode的GUI也提供了order_file选项


orderFileXcode.png

Xcode 的连接器 ld 默认忽略 order file不存在的方法
如果在 Other Linker Flags: Debug 中添加-order_file_statistics,会以 warning 的形式把这些没找到的符号打印在日志里

获取启动调用的函数符号

Clang官方文档

  • LLVM支持我们在添加编译选项 -fsanitize-coverage=trace-pc-guard 的时候,编译时帮我们在函数中插入__sanitizer_cov_trace_pc_guard,当函数调用的时候,会callq__sanitizer_cov_trace_pc_guard
  • 利用 __builtin_return_address(0) 来获得当前函数返回地址,也就是调用方的地址。
  • 通过 dladdr 来将指针解析成 Dl_info 结构体信息,其中dli_sname 就是符号的名称

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

Build Settings:

在App 的 Target - Build Settings - Other C Flags Debug 添加 -fsanitize-coverage=func,trace-pc-guard

OC - Swift 混编,则在 Other Swift Flags Debug 添加 -sanitize-coverage=func-sanitize=undefined

Cocoapods 管理的第三方库 可以通过Pod提供的hook来修改所有的pod库的编译选项

代码如下: 在hock方法 post_install 里面做修改配置的操作

post_install do |installer| 
    # 二进制重排设置
  installer.pods_project.targets.each do |target|
    target.build_configurations.each do |config|
      if config.name == 'Debug'
        config.build_settings['OTHER_CFLAGS'] ||= '$(inherited)'
        config.build_settings['OTHER_CFLAGS'] << ' '
        config.build_settings['OTHER_CFLAGS'] << '-fsanitize-coverage=func,trace-pc-guard'
      end
    end
  end
end
  # 修改主工程的配置
  app_project = Xcodeproj::Project.open(Dir.glob('*.xcodeproj')[0])

  # 主工程二进制重排设置
  app_project.native_targets.each do |target|
    if target.name == '主工程的名称'
      target.build_configurations.each do |config|
        if config.name == 'Debug'
          config.build_settings['OTHER_CFLAGS'] ||= '$(inherited)'
          config.build_settings['OTHER_CFLAGS'] << ' '
          config.build_settings['OTHER_CFLAGS'] << '-fsanitize-coverage=func,trace-pc-guard'
        end
      end
    end
  end

我们就需要拿到clang的回调来进行函数转化

代码直接附上


#import <dlfcn.h>
#import <libkern/OSAtomicQueue.h>
#import <pthread.h>

// 队列头的数据结构。
static OSQueueHead queue = OS_ATOMIC_QUEUE_INIT;

static BOOL collectFinished = NO;

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

// The guards are [start, stop).
// This function will be called at least once per DSO and may be called
// more than once with the same values of start/stop.
void __sanitizer_cov_trace_pc_guard_init(uint32_t *start,
                                         uint32_t *stop) {
    static uint32_t Counter;  // 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 = ++Counter;  // 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);
// 该回调由编译器插入到
// 控制流(适用一些优化)
// 通常,编译器将发出如下代码:
//    if(*guard)
//      __sanitizer_cov_trace_pc_guard(guard);
// But for large functions it will emit a simple call:
//    __sanitizer_cov_trace_pc_guard(guard);
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
    if (collectFinished || !*guard) {
        return;
    }
    // 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.
    *guard = 0;
    // __builtin_return_address(0)的含义是,得到当前函数返回地址,即此函数被别的函数调用,然后此函数执行完毕后,返回,所谓返回地址就是那时候的地址
    void *PC = __builtin_return_address(0);
    PCNode *node = malloc(sizeof(PCNode));
    *node = (PCNode){PC, NULL};
    OSAtomicEnqueue(&queue, node, offsetof(PCNode, next));
}

extern void AppOrderFiles(void(^completion)(NSString *orderFilePath)) {
    collectFinished = YES;
    __sync_synchronize();
    NSString *functionExclude = [NSString stringWithFormat:@"_%s", __FUNCTION__];
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.01 * NSEC_PER_SEC)), dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        NSMutableArray <NSString *> *functions = [NSMutableArray array];
        while (YES) {
            PCNode *node = OSAtomicDequeue(&queue, offsetof(PCNode, next));
            if (node == NULL) {
                break;
            }
            Dl_info info = {0};
            dladdr(node->pc, &info);
            if (info.dli_sname) {
                NSString *name = @(info.dli_sname);
                BOOL isObjc = [name hasPrefix:@"+["] || [name hasPrefix:@"-["];
                NSString *symbolName = isObjc ? name : [@"_" stringByAppendingString:name];
                [functions addObject:symbolName];
            }
        }
        if (functions.count == 0) {
            if (completion) {
                completion(nil);
            }
            return;
        }
        NSMutableArray<NSString *> *calls = [NSMutableArray arrayWithCapacity:functions.count];
        NSEnumerator *enumerator = [functions reverseObjectEnumerator];
        NSString *obj;
        while (obj = [enumerator nextObject]) {
            if (![calls containsObject:obj]) {
                [calls addObject:obj];
            }
        }
        [calls removeObject:functionExclude];
        NSString *result = [calls componentsJoinedByString:@"\n"];
        NSLog(@"Order:\n%@", result);
        
        NSString *filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"app.order"];
        NSData *fileContents = [result dataUsingEncoding:NSUTF8StringEncoding];
        BOOL success = [[NSFileManager defaultManager] createFileAtPath:filePath
                                                               contents:fileContents
                                                             attributes:nil];
    });
}

在想要结束的地方执行一下AppOrderFiles()代码,就可以得到这句代码执行之前的所有的函数执行栈的记录

使用Xcode Run一下后会在沙盒tmp文件夹下面生成app.order文件

设置 order file

在Xcode中设置OrderFile的路径

PS:配置好 order file 之后,记得清理前面 Build SettingsPodfile 中与 Clang 相关的配置

可以使用System Trace验证一下前后 File Backed Page In 的次数

学习和实践过程中的参考文献:

https://www.cnblogs.com/chengxyyh/archive/2020/06/12/13099407.html

https://blog.csdn.net/chouju2014/article/details/100657380

https://www.jianshu.com/p/f9b305e2823d

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,772评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,458评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,610评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,640评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,657评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,590评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,962评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,631评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,870评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,611评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,704评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,386评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,969评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,944评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,179评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,742评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,440评论 2 342

推荐阅读更多精彩内容