1.冷启动

1.1 什么是冷启动?

冷启动是指内存中不包含该应用程序相关的数据,必须要从磁盘载入到内存中的启动过程。

注意:重新打开 APP, 不一定就是冷启动。

  1. 当内存不足,APP被系统自动杀死后,再启动就是冷启动。
  2. 如果在重新打开 APP 之前,APP 的相关数据还存储在内存中,这时再打开 APP,就是热启动
  3. 冷启动与热启动是由系统决定的,我们无法决定。
  4. 当然设备重启以后,第一次打开 APP 的过程,一定是冷启动

1.2 如何统计冷启动耗时?

一般来讲,统计 APP 启动时长,以 main 函数为节点 ,分两个大阶段:

  • main 函数之后的代码,是我们自己写的,我们可以自行统计进入 main 函数到第一个界面显示的耗时
    • 在 main 函数里打印一下当前的时间,
    • 在第一个要显示的控制器的 viewDidLoad 方法中打印一下当前时间
    • 两个时间的差值,即为main函数后的加载时长
  • main 函数之前,为 pre-main 阶段,由于是系统在做事情,这段时间 耗时,我们没办法直接统计,需要查 看系统给我们的反馈

1.2.1 pre-main阶段都做了什么?

接下来看一下项目中的 pre-main 阶段的耗时。

  • 查看系统给的反馈需要 增加一个环境变量
  • 增加路径:在 Xcode -> Edit Scheme -> Run -> Arguments -> Environment Variables 中,
  • 增加一个环境变量 DYLD_PRINT_STATISTICS:1。

下图是我项目的加载耗时:

耗时过程分为以下4部分:

  1. dylib loading time : 是指动态库加载的耗时,系统的动态库做过优化,耗时较少。 苹果官方推荐最多不要超过6个外部动态库,多余6个,需要考虑合并动态库,合并动态库对于启动时期的优化,非常有效。 像微信的动态库早期有八九个,现在也优化成6个了。
  2. rebase/binding
  • rebase:是指地址的 偏移修正耗时。
    • 在编译生成二进制文件的时候,每个函数都有一个地址,这个地址是相对于二进制文件的偏移地址
    • 在启动时,也就是在二进制文件在加载到虚拟内存的时候,为了安全起见,苹果有个安全机制(ASLR),会在整个二进制文件的最前面随机加一个偏移值
    • 比如 A 函数,相对于二进制文件的偏移值是 0x003。 启动时,整个二进制文件被分配了一个随机值0x100。 那么 A 函数在内存中的实际地址是 0x003 + 0x100 = 0x103。
    • 偏移修正指的就是计算方法在虚拟内存中的地址的过程!
  • binding: 动态库的方法绑定,是指将方法名字与方法的实现进行绑定过程的耗时。
    • 比如 NSLog 方法,在加载的时候需要先找到Foundation库,再找到库里的NSLog的方法的实现,将方法名字和方法实现绑定在一起。
  1. Objc setup time:  注册所有 OC类 耗时, 类越多耗时越多,有人统计过2万个自定义的OC的类,大概耗时800毫秒。删除不用的类,可以减少耗时。
  2. initializer timeload方法 和 C++构造函数的耗时. 减少重写load方法,尽量将事情延迟到 main 方法以后,可以减少耗时。
  3. slowest intializers : 指出了最耗时的几个库是下面的6个库(最后一个是我的项目)。

1.2.2 pre-main阶段耗时优化方法总结:

  • 减少外部动态库的数量
  • 不用的类和方法,删除
  • 类尽量使用懒加载,也就是尽量不要重写load方法。
  • 启动时加载的数据使用多线程
  • 使用纯代码。不用xib storyboard(要额外进行代码解析转换和页面的渲染)

以上方法,都是和自己的项目代码息息相关的优化方案。不同项目具体是实施动作不一样。

还有一个优化方法,不管是什么项目,实施动作都一样 ,对什么项目都有效,那就是二进制重排!

2. 二进制重排

学习二进制重排,首先要知道数据是如何加载到内存中的

内存加载机制:数据是如何加载到内存中的

我们已经知道数据加载到内存的过程,当虚拟内存页还没有对应的物理内存页时,会出现缺页异常(PageFault)。

当冷启动时,物理内存中是没有数据的,这时会出现大量的缺页异常,在iOS生产环境的app,在发生Page Fault进行重新加载时,iOS系统还会对其做一次签名验证,因此 iOS 生产环境的 Page Fault 比Debug环境下所产生的耗时更多

这里有没有优化空间呢?接下来就是优化方案:二进制重排!

在了解二进制重排之前,再了解下在项目编译生成二进制文件的时候,类及其内部方法实现的排列顺序是什么样的呢?

2.2.1 二进制文件中方法实现排序是什么样的?

  1. 在 viewController 中,先随便写几个方法。

  1. 再看下源文件的编译顺序

接下来查看 Link Map文件查看符号顺序, 查看方式:

  1. 打开link map

****

  1. 编译生成link map 文件
  2. 找到link map 文件
  • 项目目录中,生成的 app 右键,show in Finder

  • 找到 app 的上上级目录

  • 进入Intermediates.noindex -> TraceDemo.build -> Debug-iphonesimulator -> TraceDemo.build -> TraceDemo-LinkMap-normal-x86_64.txt

  1. 打开link map 文件,找到自己的类及方法的名字

5.我们可以直观的看出 link map中符号的顺序,类是以源文件的编译顺序,从上到下按序排列。方法名是以类中方法的书写顺序,由上到下排序。

2.2.2 为什么需要二进制重排?

从源码的执行顺序上看,应该是 load -> test2 -> viewDidLoad -> test1.

但是二进制文件中符号的顺序是方法从上到下的书写顺序没有按照调用顺序去排列。

在冷启动分页加载二进制文件时,发现很多页中都有启动时需要用到的方法,那么即使页里面也存在启动时不需要的方法,但是由于内存是分页管理的,要加载就要整页加载。这样就导致了大量不需要在 pre-main 阶段执行的方法,也会被加载到内存中,增加了启动的耗时。

\

例如,启动需要加载100个页,每个页可以包含20个方法。但是每个页里只有2个方法是启动时后用到的。这样实际上启动时必须要的方法是2 * 100 = 200个,如果将这200个方法紧挨着放在一起,那么只需要2页。比100个页,减少了98页。这样耗时就会大大降低。

2.2.3 如何进行二进制重排?

1. 二进制重排的方法

在项目编译生成二进制文件的时候,找到启动时需要的方法,并且将它们放在一起 重新排序,这就是二进制重排。

两个关键点: 找到启动时需要方法 & 方法 的重排序


2.方法的重排序:

重排序其实很简单。xcode已经为我们提供了这个机制,它使用的链接器叫做 ld, ld有一个参数叫做Order File, 我们可以通过配置order文件,来使编译时生成的二进制的文件的Link Map种的符号顺序,按照我们指定的顺序排列生成。而且 libobjc 实际上也做了二进制重排


【第一步】在项目根目录下建一个xxx.order的文件,里面写上按照自己想排列的顺序,写上方法或者函数的名字。(如果写了一个不存在的符号,也不会报错,会被自动过滤掉~)

【第二步】在 Build Settings 搜索order file 的文件。将项目根目录创建的文件,设置上去。

【第三步】重新编译,查看 Link Map 文件的顺序,果然,按照我们指定的顺序排列啦!

3. 静态插桩 - 找到冷启动时的所有方法

接下来,需要做的就是写入 order 文件里的符号了,我们不可能手写上所有的启动时需要的执行的符号,这里的所有符号包括,调用的方法、函数、C++构造方法、swift方法、block。

这里使用 LLVM 内置的简单代码覆盖率检测工具SanitizerCoverage)。它在边缘、 函数、基本块 级别上插入对用户定义函数的调用。

  • edge (默认):检测边缘(所有的指令跳转都会被插入对用户定义函数的调用, 如循环、分支判断、方法函数等)。
  • bb检测基本块。
  • func:仅将检测每个 功能的输入块(这个就是我们要重排序的符号)。

按照文档,

  • 【第1步】搜索并设置 Other C Flags/ Other C++ Flags 为 -fsanitize-coverage=func,trace-pc-guard这里要用func, 不能用默认的edge, 不然会造成死循环)。
  • 如果有swift ,需要设置 Other Swift Flags 设置为 **** -sanitize-coverage=func -sanitize=undefined

  • 【第2步】编译器将插入对模块构造函数的调用,所以我们要实现这个方法:
  1. __sanitizer_cov_trace_pc_guard_init(uint32_t *start, uint32_t *stop);

通过打印start, stop 地址的内容,从 start 地址开始,到 stop 地址的前4位,存储的是 uint32 的 1-19的数字。

我们可以从这个函数中知道, 当前项目中自定义的功能输入块的数量。

  • 【第3步】编译器会在生成二进制文件的时候,在每个func调用之初,插入以下代码
  1. __sanitizer_cov_trace_pc_guard(&guard_variable)

也就是说,每个方法在执行的时候,都会调用上面这个方法。 接下来:

      1. 我们要实现这个方法,并在这个方法里,获取到本方法结束后要返回的地址
  1. // 获取到本方法结束后,要返回的地址去,这个地址包含在被hook的方法内部,但不是被hook 的方法的首地址
  2. void *PC = __builtin_return_address(0);

      1. 并将地址保存一个系统的原子队列( ( 底层实际上是个栈结构 , 利用队列结构 + 原子性来保证顺序 ))中,使用原子队列,是为了防止多线程资源抢夺。原子队列的存值方法如下:
  1. // 将结构体存入到原子队列中。
  2. // offsetof(type,member) 返回结构体中成员的偏移值,由于指针PC是8字节,所以这里返回8字节。
  3. // 详见下图
  4. OSAtomicEnqueue(&symbolList, node, offsetof(SYNode, next));

每个 SYNode 首地址都距离上一个偏移 PC 所占的字节数。这样做的妙处就是,每个 SYNode 的 next 的地址,恰巧就是下一个结构体的地址。这样方便获取队列里面的所有数据。

  • 【第4步】我们在点击屏幕的事件中
    • 把存储到原子队列中的地址遍历出来,
    • 根据地址获取当前地址所在的方法的名称并存入数组中,
  1. typedef struct dl_info {
  2. const char *dli_fname; /* 所在文件 */
  3. void *dli_fbase; /* 文件地址 */
  4. const char *dli_sname; /* 符号名称 */
  5. void *dli_saddr; /* 函数起始地址 */
  6. } Dl_info;
  7. //这个函数能通过函数内部地址找到函数符号
  8. int dladdr(const void *, Dl_info *);
    • 由于原子队列是栈结构,先进后出,所以我们需要将数组倒序排列
    • 由于方法可能会被多次调用,我们需要去重
    • 再将最后我们当前点击屏幕的方法删除掉
    • 将方法名字的数组,转成字符串,写到沙盒文件中

完整代码如下:

  1. //
  2. // ViewController.m
  3. // TraceDemo
  4. //
  5. // Created by hank on 2020/3/16.
  6. // Copyright 2020 hank. All rights reserved.
  7. //
  8. #import "ViewController.h"
  9. #import <dlfcn.h>
  10. #import <libkern/OSAtomic.h>
  11. #import "TraceDemo-Swift.h"
  12. @interface ViewController ()
  13. @end
  14. @implementation ViewController
  15. +(void)initialize
  16. {
  17. }
  18. void(^block1)(void) = ^(void) {
  19. };
  20. void test(){
  21. block1();
  22. }
  23. +(void)load
  24. {
  25. }
  26. - (void)viewDidLoad {
  27. [super viewDidLoad];
  28. [SwiftTest swiftTestLoad];
  29. test();
  30. }
  31. -(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
  32. {
  33. NSMutableArray <NSString *> * symbolNames = [NSMutableArray array];
  34. while (YES) {
  35. SYNode * node = OSAtomicDequeue(&symbolList, offsetof(SYNode, next));
  36. if (node == NULL) {
  37. break;
  38. }
  39. Dl_info info;
  40. dladdr(node->pc, &info);
  41. NSString * name = @(info.dli_sname);
  42. BOOL isObjc = [name hasPrefix:@"+["] || [name hasPrefix:@"-["];
  43. NSString * symbolName = isObjc ? name: [@"_" stringByAppendingString:name];
  44. [symbolNames addObject:symbolName];
  45. }
  46. //取反
  47. NSEnumerator * emt = [symbolNames reverseObjectEnumerator];
  48. //去重
  49. NSMutableArray<NSString *> *funcs = [NSMutableArray arrayWithCapacity:symbolNames.count];
  50. NSString * name;
  51. while (name = [emt nextObject]) {
  52. if (![funcs containsObject:name]) {
  53. [funcs addObject:name];
  54. }
  55. }
  56. //移除本方法
  57. [funcs removeObject:[NSString stringWithFormat:@"%s",__FUNCTION__]];
  58. //将数组变成字符串
  59. NSString * funcStr = [funcs componentsJoinedByString:@"\n"];
  60. NSString * filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"demo.order"];
  61. NSData * fileContents = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
  62. [[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
  63. NSLog(@"%@",funcStr);
  64. }
  65. void __sanitizer_cov_trace_pc_guard_init(uint32_t *start,
  66. uint32_t *stop) {
  67. static uint64_t N; // Counter for the guards.
  68. if (start == stop || *start) return; // Initialize only once.
  69. printf("INIT: %p %p\n", start, stop);
  70. for (uint32_t *x = start; x < stop; x++)
  71. *x = ++N; // Guards should start from 1.
  72. }
  73. //原子队列
  74. static OSQueueHead symbolList = OS_ATOMIC_QUEUE_INIT;
  75. //定义符号结构体
  76. typedef struct {
  77. void *pc;
  78. void *next;
  79. }SYNode;
  80. void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
  81. // 会导致load 方法被return
  82. // if (!*guard) return;
  83. // 获取到本方法结束后,要返回的地址去,这个地址包含在被hook的方法内部,但不是被hook 的方法的首地址
  84. void *PC = __builtin_return_address(0);
  85. SYNode *node = malloc(sizeof(SYNode));
  86. *node = (SYNode){PC,NULL};
  87. //进入
  88. OSAtomicEnqueue(&symbolList, node, offsetof(SYNode, next));
  89. }
  90. @end

2.2.4 如何验证二进制重排的效果?

1.查看缺页异常数量Page Fualt:

  1. 查看一下项目的缺页异常数量。注意需要卸载 APP 或者重启手机,来保证这个APP完全没有被加载到内存中,因为如果物理内存中有该APP的数据,
  2. 打开 Instrument -> System Trace

3.选择真机、项目、点击启动,当第一个页面显示出来后,点击停止。

  1. xcode 12搜索main thread, 选择Virtual MemoryFile Backed Page in 就是缺页异常的数量

优化前:项目的缺页遗产数量是427


优化后:

优化前:项目的缺页遗产数量是286


减少了启动时大概40%的缺页异常~


3.自动更新order 文件

随着代码迭代,order文件需要更新,每次手动更新很麻烦,所以需要自动更新。

  1. brew install ios-deploy
  2. APP_ORDER_DIR=appOrderDir
  3. APP_ORDER=./$APP_ORDER_DIR/Documents/app.order
  4. mkdir $APP_ORDER_DIR
  5. ios-deploy --download=/Documents --bundle_id $PRODUCT_BUNDLE_IDENTIFIER --to ./$APP_ORDER_DIR
  6. if [ -e $APP_ORDER ] ;then
  7. cp -f $APP_ORDER ./Resource/app.order
  8. fi
  9. rm -r $APP_ORDER_DIR



【补充xcode13】查看缺页异常的方式

选择真机、项目、点击启动,当第一个页面显示出来后,点击停止。

青山不改,绿水常流。谢谢大家!

使用二进制重排 & Clang插桩技术点来进行iOS冷启动进行优化的更多相关文章

  1. 方案设计:基于IDEA插件开发和字节码插桩技术,实现研发交付质量自动分析

    作者:小傅哥 博客:https://bugstack.cn 沉淀.分享.成长,让自己和他人都能有所收获! 一.前言 如何保证代码质量? 业务提需求,产品定方案,研发做实现,测试验流程.四种角色的相互配 ...

  2. Java Instrumentation插桩技术学习

    Instrumentation基础 openrasp中用到了Instrumentation技术,它的最大作用,就是类的动态改变和操作. 使用Instrumentation实际上也可以可以开发一个代理来 ...

  3. 插桩 inline hook 动态二进制插桩的原理和基本实现过程

    插桩测试 https://source.android.google.cn/compatibility/tests/development/instrumentation https://zhuanl ...

  4. APK修改神器:插桩工具 DexInjector

    本文介绍了一个针对Dex进行插桩的工具,讲解了一下直接修改Dalvik字节码和Dex文件时遇到的问题和解决方法 作者:字节跳动终端技术-- 李言 背景 线下场景中,我们经常需要在APK中插入一些检测代 ...

  5. 开发 IDEA Plugin 引入探针,基于字节码插桩获取执行SQL

    作者:小傅哥 博客:https://bugstack.cn 沉淀.分享.成长,让自己和他人都能有所收获! 一.前言 片面了! 一月三舟,托尔斯泰说:"多么伟大的作家,也不过就是在书写自己的片 ...

  6. 手淘架构组最新实践 | iOS基于静态库插桩的⼆进制重排启动优化 抖音研发实践:基于二进制文件重排的解决方案 APP启动速度提升超15% 编译期插桩

    抖音研发实践:基于二进制文件重排的解决方案 APP启动速度提升超15% 原创 Leo 字节跳动技术团队 2019-08-09 https://mp.weixin.qq.com/s/Drmmx5JtjG ...

  7. intel Pin:动态二进制插桩的安装和使用,以及如何开发一个自己的Pintool

    先贴几个你可能用得上的链接 intel Pin的官方介绍Pin: Pin 3.21 User Guide (intel.com) intel Pin的API文档Pin: API Reference ( ...

  8. ICSFUZZ:操纵I/O、二进制代码重用以及插桩,来Fuzzing工业控制应用程序

    ​ 本文系原创,转载请说明出处 Please Subscribe Wechat Official Account:信安科研人,获取更多的原创安全资讯 源码:GitHub - momalab/ICSFu ...

  9. Flymeos插桩适配教程

    插桩适配前提,安装Ubuntu或者其他linux系统. 安装JDK7 sudo apt--jdk Ubuntu 16.04与基于它的版本,需要添加源 sudo add-apt-repository p ...

  10. zorka源码解读之通过beanshell进行插桩的流程

    zorka中插桩流程概述 1.在SpyDefinition中配置插桩属性,将SpyDefinition实例提交给插桩引擎.2.SpyDefinition实例中包含了插桩探针probes,probe插入 ...

随机推荐

  1. [转帖]银河麒麟服务器操作系统V10SP1-x86_64系统环境下部署aarch64虚拟机

    文章目录 主机系统环境 搭建aarch64虚拟机环境 ①安装"虚拟系统管理器" ②编译安装Qemu for Aarch64 ③获取aarch64架构的qcow2镜像 ④使用qcow ...

  2. [翻译]-hugePage的简要说明--部分内容

    hugePage的简要说明 本篇文档的主旨给linux内核支持的大页内存做一个简要的概述. 大页内存的实现是建立在大多数现代架构所都支持的多级页大小的特性之上的. 举例: x86架构下大部署CPU 的 ...

  3. 万能shell 简单查看已存在日志所有的启动记录

    程序将日志 自动打包成了 gz 文件,  今天突然想查查所有的日志有没有相关关键字. 第一步解压缩所有的日志 cd 到相关目录 for i in `ls` ; do gzip -d $i ; done ...

  4. elementui中el-checkbox 选中时的详细介绍

    checkbox-group 把多个checkbox管理为一组(需要注意的坑) 很多时候我们需要会遇见这样的场景. 比如用户需要选择多个值. 这个时候我们需要把多个 checkbox 放置在check ...

  5. Vue中this.$options.data()和this.$data知多少?

    1.场所描述 如何获取vue-data中的所有值? 如何获取vue-data中的某一个值? 如何获取vue-data中的初始值? 如何设置data中的值位初始值? 2.主角登场 this.$optio ...

  6. docker 推送镜像到harbor

    服务器A的镜像要推送到已安装harbor的服务器B 1.修改服务器A的/etc/docker/daemon.json文件 其中,http://211.131.241.221:8888为你要推送的服务器 ...

  7. Gin 路由注册与请求参数获取

    目录 一.Web应用开发的两种模式 1.前后端不分离模式 2.前后端分离模式 二.RESTful介绍 三.API接口 3.1 RESTful API设计指南 3.2 API与用户的通信协议 3.3 R ...

  8. 站点用css一键变灰色

    默哀站点变灰色效果看本站即可 css代码如下 <style>html{-webkit-filter:grayscale(100%);-moz-filter:grayscale(100%); ...

  9. 【一】飞桨paddle【GPU、CPU】安装以及环境配置+python入门教学

    相关文章: [一]飞桨paddle[GPU.CPU]安装以及环境配置+python入门教学 [二]-Parl基础命令 [三]-Notebook.&pdb.ipdb 调试 [四]-强化学习入门简 ...

  10. 【3】opencv_contrib 4.3.0库配置+opencv安装

    相关文章: [1]windows下安装OpenCV(4.3)+VS2017安装+opencv_contrib4.3.0配置 [2]Visual Studio 2017同时配置OpenCV2.4 以及O ...