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

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

一、pre-main阶段优化

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

  • dylib loading time:动态库的加载,包括系统动态库和三方动态库,这个部分的优化空间并不大,可以减少三方库的使用,或者合并自己的三方库。
  • rebase/binding time:地址绑定
  • ObjC setup time:OC注册,读取二进制data的内容,找到OC相关的信息;完成OC类名与类的关系映射、维护sel-imp的关系映射,protocol/category等插入宿主类列表中等。
  • initializer time:load方法的调用和attribute((constructor))函数的调用。

关于rebase/binding

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

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

  • 应用程序内访问的都是虚拟内存地址,操作系统使用硬件管理单元(MMU)来翻译地址,将虚拟内存地址映射到物理内存地址。物理地址由操作系统来管理。
  • 内存分页(PAGE),应用的数据是一页一页加载到内存中的,没有使用到数据是不会被加载的。如果使用到的数据页没有被加载到内存叫做缺页异常(Page Fault)/缺页中断,操作系统会看是否有空闲位置,结合页面置换算法将数据页插入到空闲位置或者覆盖掉不活跃的内存,操作系统处理缺页异常的速度非常快,都收毫秒级别的。这样以来,提供给应用的虚拟内存的地址是连续的,而物理内存中针对单个应用的数据就是不连续的。
  • 内存分页中iOS App PAGE的大小是16K(从iPhone 6s开始),Mac App PAGE的大小是4K。
  • 每个应用的虚拟地址空间是8G,可以可以使用的是4G空间。如果继续向上访问,访问的数据搜是nil(既没有物理物理内存与之对应)。

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

  • 关于内存不够用的问题:操作系统通过页面置换算法将不活跃的内存覆盖掉,内存可以被反复使用,解决了内存不够用的问题。
  • 关于不安全问题:应用只能访问自己进程空间内的地址,进程之间安全隔离。

关于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 
#include 
#include 

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的回调可能在主线程,也可能在子线程,所以这里要想完整的保证调用的顺序需要使用原子特性保证线程安全。

  • 引入头文件 #import
  • 定义一个新的结构体:
typedef struct{
    void *pc;
    void *next;
}SYNode;
  • 定义一个链表static OSQueueHead symbolList = OS_ATOMIC_QUEUE_INIT;
  • 修改__sanitizer_cov_trace_pc_guard()
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 *)touches withEvent:(UIEvent *)event {
    NSMutableArray *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.从本地缓存中读取首页的数据
  • 如果首页的内容是非UGC的,或者说实时性不是特别强的,我们可以将成功请求的数据缓存到本地磁盘,下一次全新启动可以优先从本地磁盘读取数据并渲染到屏幕上,通过通过接口获取远程最新数据(并存储到磁盘),如果本次数据与已经渲染的数据不一致,则刷新用户界面即可。
2.拆分接口请求或合并接口请求:
  • 如果首页的数据是分多个模块(或微服务)且模块之间是弱相关的,没有依赖关系的,这样把所有数据放在一个接口,势必会增加后端查询量,导致接口响应过慢。对于这种情况,我们可以按照逻辑划分,在子线程发出请求,数据响应后在子线程完成相关预处理,再回到主线程更新UI;哪个请求先回来就先渲染哪个模块的数据。
  • 如果首页的数据相关性很大,或有依赖关系,则可以将有依赖的接口合并为一个接口,以减少接口请求的实践,当然这需要后端的配合。
3.使用骨架屏等方案
  • 在网络请求过程中展示骨架屏会给用户一种数据即将展示出来的感觉。
4.延迟初始化三方服务
  • 第三方服务SDK可以在子线程进行初始化的,可以放在子线程完成,必须放在主线程初始化的,可以延迟几秒再初始化。
5.延迟请求相关接口
  • 很多应用首屏都会做版本升级提醒或者拉取全局配置信息,如果是单独的接口,则可以延迟请求数据。全局配置信息可以在每次请求到远程最新数据时缓存到本地磁盘,应用启动后优先读取本地的数据,并且延迟请求远程数据。(打包上架时可以获取一份最新的数据放在工程中,以解决首次没有缓存的问题)。

你可能感兴趣的:(iOS开发:启动优化及二进制重排初探)