iOS开发进阶收藏

iOS开发进阶:启动优化及二进制重排初探

2021-12-30  本文已影响0人  __Null

应用的(冷)启动过程主要分为两个阶段:pre-main阶段、从main到首屏加载完成的阶段。

一、pre-main阶段优化

这个阶段主要是做动态库的加载、地址的绑定、OC注册和相关初始化的工作。我们可以在scheme->Arguments->Environment Variables中添加环境变量 DYLD_PRINT_STATISTICS,并设置为YES,再次运行打印启动时各个操作的时间:

关于rebase/binding

在计算器发展的早期,也就是物理内存阶段,操作系统会默认将整个应用的数据一次性加载进物理内存(内存条),应用内访问到到地址都是物理地址。这样做应用确实加载出来了,但是也存在一些弊端:1.每个应用的数据都需要占用内存空间,如果应用不退出,这一块内存就会被一直占用着,那么开启的程序多了之后内存就不够用了,只能杀掉之前的程序在开启新程序。2.由于使用的是物理内存地址,那么应用内可以访问全局内存中的其他数据,非常不安全。

为了解决物理内存时代内存不够用和不安全的问题,科学家研究出了虚拟内存方案。

虚拟内存的技术方案解决了内存物理内存时代的的问题:

关于rebase:为了提高安全性,又引入了地址空间布局随机化(ASLR)技术,每次应用启动都是生成一个随机的初始地址,代码在虚拟内存中的地址是ASLR+Offset,其中Offset在编译完成之后就固定了。程序启动时ASLR+Offset的过程就叫重定位(rebase)。

关于binding:应用程序会访问外部外部,但是外部代码并没有在我们的二进制文件中,所以需要根据符号找到对应的地址,并且将地址跟符号绑定在一起。iOS的绑定是懒加载绑定,加载动态库的时候不会立即绑定,只有用到的时候才去查找找并绑定,这个过程通过libStsyem中的dyld_stub_binder完成,一个符号只用查找并绑定一次,后面再用到的时候就用已经绑定的地址。整个的过程叫做绑定(binding)。

进程间通信:操作系统提供专门的接口用于跨进程通信。

基于以上虚拟内存和内存分页懒加载的技术特点,尽管操作系统处理一次缺页异常是几毫秒,如果在某一瞬间发生大量的缺页异常,比如几百、几千缺页异常,积少成多也会消耗不少的时间。而大量缺页异常最可能发生的时机就是应用冷启动的瞬间。

用一个工程做测试:
测试方式:Xcode->Product->Profile->System Trace。用System Trace这个工具做测试,进入这个工具后,点击左上角运行按钮,运行结束后再点击一下,然后在下方列表中找到自己的应用,点开之后找到Main Thread选中,下方再切换到Summary:Virtual Memory,展开All


从上图可以看到这次启动共发生Page Fault次数为504次,耗时104.27ms,平均一次206.88us。多次测试结果会存在差异,冷启动过程测试才具备一定参考性。

基于上面存在的问题,如果启动过程中发生Page Fault的次数越少,则也相应的越快,如果我们将启动的时候调用的函数方法等放在前面几个页中就可以相应的检查page fault的发生。但是dyld加载文件的顺序默认是跟编译度读取文件的事情一致,有没有一种方案可以干预编译器读取代码的顺序呢?解决这个问题需要用到二进制重排技术。

二进制重排

1.查看Link-Map.text文件
在Build-Settings的Path to Link File中输入link-map的地址$(TARGET_TEMP_DIR)/$(PRODUCT_NAME)-LinkMap-$(CURRENT_VARIANT)-$(CURRENT_ARCH).txt,然后开启write Link Map File为YES。
然后运行代码,再根据上面的路径找到刚才的link-map的文件:


如上可以看到是按照Compile Sources中顺序读取的:

文件内部按照代码的顺序从上往下读取函数定义。

2.认为干预编译器读取代码的顺序
定义一个oc.order文件放在根目录,文件内容

_unknown_method_1
_main
-[AppDelegate application:didFinishLaunchingWithOptions:]
-[ViewController initialize]
_unknown_method_2

再在Build-Setting的Linking -> Order File中添加刚才的文件./oc.order,再次运行代码并查看link map文件:


这次的结果显示我们指定的几个方法函数都在最前面了,并且我们定义的两个不存在的函数_unknown_method_1_unknown_method_2也没有报错。

这个操作就实现了二进制重排,如果能将启动的时候调用的函数都手机起来写入这个.order文件,那么就达到了启动优化的目的了。

对于一个小的小项目,我们可以根据这个思路简单玩一下搞明白它的原理,也没优化的必要。但是对于微信或者抖音这样一个大的项目这种优化结果就是立竿见影的。苹果为我们提供了相关优化的方案和接口,即clang插妆。

clang插桩

clang插桩相关资料可以这个文档,点击获取

我们找到其中的Tracing PCs部分,阅读文档可以得知,我们需要在编译选项里边进行设置:Build Setting -> Apple Clang - Custom Compiler Flags -> Other C Flags添加-fsanitize-coverage=trace-pc-guard。并且实现两个函数:

//引入头文件
#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];
  // 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);
}

再次运行代码发现__sanitizer_cov_trace_pc_guard被多次调用了,后续每次调用函数都会来到这里。这里执行完毕之后相应的函数才会真正的执行。是的,所有的方法都被hook住了,也就是相当于在原有的每个方法的边缘插入了__sanitizer_cov_trace_pc_guard(...)的调用。

接下来的重要就在__sanitizer_cov_trace_pc_guard__builtin_return_address(0)函数。该函数返回了被调用函数的堆栈信息,我们可以通过对战信息还原函数的信息。代码如下:

void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
    if (!*guard) return;
    void *PC = __builtin_return_address(0);
    Dl_info info;
    dladdr(PC, &info);
    printf("fname=%s\nfbase=%p\nsname=%s\nsaddr=%p\n\n\n\n", info.dli_fname, info.dli_fbase, info.dli_sname, info.dli_saddr);
}

重新运行一下代码,得到如下的信息:



发现了新大陆,这里可以看到c函数、类方法、实例方法、block都被拦截下来了。
买下来需要做的事情就是将这些数据收集起来,然后去重、在根据.order所需要的格式做一些格式化,生成.order文件就可以使用了。

还有一点需要注意__sanitizer_cov_trace_pc_guard的回调可能在主线程,也可能在子线程,所以这里要想完整的保证调用的顺序需要使用原子特性保证线程安全。

typedef struct{
    void *pc;
    void *next;
}SYNode;
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
    if (!*guard) return;
    void *PC = __builtin_return_address(0);
    SYNode *node = malloc(sizeof(SYNode));
    *node = (SYNode){PC, NULL};
    OSAtomicEnqueue(&symbolList, node, offsetof(SYNode, next));
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    NSMutableArray <NSString *>*array = [NSMutableArray array];
    
    while (YES) {
        SYNode *node = OSAtomicDequeue(&symbolList, offsetof(SYNode, next));
        if(node == NULL){
            break;
        }
        
        Dl_info info;
        dladdr(node->pc, &info);
        NSString *name = @(info.dli_sname);
        if([name hasPrefix:@"+["] || [name hasPrefix:@"-["]){
            //OC方法
            [array addObject:name];
        }
        else {
            //函数
            [array addObject:[@"_" stringByAppendingString:name]];
        }
    }
    NSMutableArray *funcs = [NSMutableArray array];
    for(NSString *name in array) {
        if(![funcs containsObject:name]){
            [funcs addObject:name];
        }
    }
    [funcs removeObject:@"-[ViewController touchesBegan:withEvent:]"];
    NSLog(@"%@", funcs);
    
    NSData *data = [[funcs componentsJoinedByString:@"\n"]  dataUsingEncoding:NSUTF8StringEncoding];
    NSString *file = [NSTemporaryDirectory() stringByAppendingPathComponent:@"nx.order"];
    [[NSFileManager defaultManager] createFileAtPath:file contents:data attributes:nil];
}

这里要注意 这里的while循环会导致-[ViewController touchesBegan:withEvent:]被不断调用,造成死循环。修改Other C Flages-fsanitize-coverage=func,trace-pc-guard

运行如上代码,点击一下屏幕,然后找到nx.order文件:

一键生成排序函数,然后把文件拷贝出来放在工程里边,在Build Setting里边配置Order File即可。

swift的怎么捕获?
Other Swift Flags中配置:-sanitize=trace-pc-guard-sanitize=undefined

上线的时候怎么做?
上线之前先把Other C Flags、Other Swift Flags设置后生成并导出排序文件,然后清理掉Other C Flags、Other Swift Flags。并将导出的文件配置到Order File中进行打包。
Link Map文件的路径去掉,Link Map的开关关掉。

二、首屏加载优化

如上我们通过启动过程中dyld做的事情来优化启动时间,这些优化都是毫秒级别的,能优化的空间也是有极限的。用户可感知的更快是指从点击图标到应用的首页展示出来这个过程快,所以首屏的加载优化也是相当重要。
这部分大多跟业务强相关,需要具体情况具体分析。下面将结合我自己的理解与实践经历做如下梳理,仅做参考。

1.从本地缓存中读取首页的数据
2.拆分接口请求或合并接口请求:
3.使用骨架屏等方案
4.延迟初始化三方服务
5.延迟请求相关接口
上一篇 下一篇

猜你喜欢

热点阅读