OC底层原理三十三:启动优化(二进制重排)

OC底层原理 学习大纲

  • 本节,我们分享APP启动优化
  1. 冷启动和热启动
  2. 启动性能检测和分析
  3. 虚拟内存与物理内存
  4. 二进制重排原理
  5. PageFault检测
  6. 体验二进制重排

1. 冷启动和热启动

首次启动应用、kill应用后重新打开应用、应用置于后台隔一段时间再返回前台等情况,都是应用启动

有时启动,有时启动。这是冷启动热启动的原因:

冷启动:

  • 内存不包含APP的数据所有数据都需要载入内存中,提供给应用使用
    (ps: 内存中的数据不会被删除的,但是存储空间可能被其他应用使用了,从而数据被覆盖。)

热启动:

  • 内存中仍然存在APP的数据,数据不需要重新载入内存
    (ps: 当前应用所占的内存空间未被其他应用覆盖。所以数据依旧可读取

冷启动热启动区别场景

【区别】内存是否有加载的数据

  • 有:热启动无需重新加载数据速度快
  • 无:冷启动需要磁盘读取数据加载内存中,耗时,速度慢

【场景】

  • 首次启动: 一定冷启动。(内存中无数据
  • kill后启动:冷启动热启动 (取决于内存中是否有数据
  • 置于后台再回到前台: 冷启动热启动 (取决于内存中是否有数据)
    (ps: 如果其他应用需要更多内存空间系统可能自动覆盖你的内存空间提供给其他应用使用,此时你的数据就被覆盖了,回到前台时,应用自动重启

2. 启动性能检测和分析

测试APP启动,分为两个阶段:

  • main函数前dyld负责的启动流程(参考dyld 应用程序加载)

系统处理,我们从dyld应用加载的流程来优化。(借助系统工具分析耗时)

  • main函数后开发者自己的业务代码

通过检测业务流程优化main函数打个时间点第一个页面渲染完成打个时间点。测算耗时)

2.1 main函数前

大家可以使用自己的项目作为观察对象,此处是以砸包后的某个应用为测试对象仅供观察学习

  • 创建Demo项目,新增环境变量 DYLD_PRINT_STATISTICS:
    OC底层原理三十三:启动优化(二进制重排)_第1张图片
    image.png

ps: 此处记录下砸壳后的包重签名过程:(看官们可忽略此处 )

    1. 新建APP文件夹,放入砸壳后的包
      OC底层原理三十三:启动优化(二进制重排)_第2张图片
      image.png
    1. 加入appSign.sh重签名脚本:
# ${SRCROOT} 它是工程文件所在的目录
TEMP_PATH="${SRCROOT}/Temp"
#资源文件夹,我们提前在工程目录下新建一个APP文件夹,里面放ipa包
ASSETS_PATH="${SRCROOT}/APP"
#目标ipa包路径
TARGET_IPA_PATH="${ASSETS_PATH}/*.ipa"
#清空Temp文件夹
rm -rf "${SRCROOT}/Temp"
mkdir -p "${SRCROOT}/Temp"

#----------------------------------------
# 1. 解压IPA到Temp下
unzip -oqq "$TARGET_IPA_PATH" -d "$TEMP_PATH"
# 拿到解压的临时的APP的路径
TEMP_APP_PATH=$(set -- "$TEMP_PATH/Payload/"*.app;echo "$1")
# echo "路径是:$TEMP_APP_PATH"

#----------------------------------------
# 2. 将解压出来的.app拷贝进入工程下
# BUILT_PRODUCTS_DIR 工程生成的APP包的路径
# TARGET_NAME target名称
TARGET_APP_PATH="$BUILT_PRODUCTS_DIR/$TARGET_NAME.app"
echo "app路径:$TARGET_APP_PATH"

rm -rf "$TARGET_APP_PATH"
mkdir -p "$TARGET_APP_PATH"
cp -rf "$TEMP_APP_PATH/" "$TARGET_APP_PATH"

#----------------------------------------
# 3. 删除extension和WatchAPP.个人证书没法签名Extention
rm -rf "$TARGET_APP_PATH/PlugIns"
rm -rf "$TARGET_APP_PATH/Watch"

#----------------------------------------
# 4. 更新info.plist文件 CFBundleIdentifier
#  设置:"Set : KEY Value" "目标文件路径"
/usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier >$PRODUCT_BUNDLE_IDENTIFIER" "$TARGET_APP_PATH/Info.plist"

#----------------------------------------
# 5. 给MachO文件上执行权限
# 拿到MachO文件的路径
APP_BINARY=`plutil -convert xml1 -o - $TARGET_APP_PATH/Info.plist|grep -A1 Exec|tail -n1|cut -f2 -d\>|cut -f1 -d\<`
#上可执行权限
chmod +x "$TARGET_APP_PATH/$APP_BINARY"

#----------------------------------------
# 6. 重签名第三方 FrameWorks
TARGET_APP_FRAMEWORKS_PATH="$TARGET_APP_PATH/Frameworks"
if [ -d "$TARGET_APP_FRAMEWORKS_PATH" ];
then
for FRAMEWORK in "$TARGET_APP_FRAMEWORKS_PATH/"*
do

#签名
/usr/bin/codesign --force --sign "$EXPANDED_CODE_SIGN_IDENTITY" "$FRAMEWORK"
done
fi

#注入
#yololib "$TARGET_APP_PATH/$APP_BINARY" >"Frameworks/HankHook.framework/HankHook"
    1. Demo工程添加脚本指令./appSign.sh
      OC底层原理三十三:启动优化(二进制重排)_第3张图片
      image.png
  • 真机运行后,可看到:
Total pre-main time: 1.2 seconds (100.0%)
         dylib loading time: 326.38 milliseconds (25.4%)
        rebase/binding time: 146.54 milliseconds (11.4%)
            ObjC setup time:  40.49 milliseconds (3.1%)
           initializer time: 767.04 milliseconds (59.9%)
           slowest intializers :
             libSystem.B.dylib :   6.86 milliseconds (0.5%)
    libMainThreadChecker.dylib :  38.26 milliseconds (2.9%)
          libglInterpose.dylib : 447.73 milliseconds (34.9%)
             marsbridgenetwork :  48.86 milliseconds (3.8%)
                          mars :  30.85 milliseconds (2.4%)
                       砸壳应用 : 212.00 milliseconds (16.5%)

2.2 分析DYLD耗时元素:

  • Total pre-main time: main函数前总耗时

    • dylib loading time: dylib库加载耗时(官方建议,动态库不超过6个

      此应用的Frameworks:

      OC底层原理三十三:启动优化(二进制重排)_第4张图片
      image.png

    • rebase/binding time重定向绑定操作的耗时
      [rebase重定向]:从磁盘的MachOimage镜像内存中)
      [binding绑定]:MachO中每个文件使用其他库符号时,绑定库名地址

      出于安全考虑,编译时运行时地址不一样。使用了ASLR(Address space layout randomization)地址空间配置随机加载,每次载入内存后,需要将原地址加上ASLR随机偏移值来进行内存读取。 具体原因,下面分析虚拟内存物理内存时,就清楚

    • ObjC setup timeOC类注册耗时 (OC类越多,越耗时)

      swift没有OC类,所以在这一步有优越性

    • initializer time:初始化耗时(load非懒加载类和c++构造函数的耗时)

  • slowest intializers最慢启动对象

    • libSystem.B.dylib : 系统库
    • libMainThreadChecker.dylib : 系统库
    • libglInterpose.dylib: 系统库(调试使用的,不影响)
    • 砸壳应用 :自己的APP耗时

2.2 main函数后

  • 业务层面
  1. 启动用不到的类和页面,移到启动后创建
  2. 耗时操作使用多线程处理
  3. 启动页面,尽量不用XIBStoryBoard
  • 技术层面
    1.二进制重排
    (重排的是编译阶段文件顺序减少启动时刻,硬盘内存操作次数

在讲二进制重排前,必须知道虚拟内存物理内存

3. 虚拟内存与物理内存

  • 物理内存内存条真实大小。 (4G内存条,物理内存就是4G)
  • 虚拟内存物理内存的衍生物。(每个虚拟内存的大小都是物理内存的大小)

物理内存容易理解,就是真实内存条容量。但虚拟内存是个

3.1 虚拟内存

  • 早期计算机,没有虚拟内存概念,只有物理内存每个应用都直接全部信息写入内存条中的。当内存条空间不够时(被其他应用占据了),就会报内存警告。这时我们只能手动关闭一些应用腾出内存来让当前应用运行。
    OC底层原理三十三:启动优化(二进制重排)_第5张图片
    image.png
  • 有两个问题:
  1. 内存不够: 每个应用打开,就把所有信息加载进去,占用太多资源大软件直接无法加载
  2. 不安全: 每次加载应用,内存地址固定了,很容易被人直接通过内存地址篡改数据
    • 早期本地外挂,就是通过内存地址篡改数据
      (如:游戏中捡到500金币时,搜索所有内存地址,有记录500金币的,就是金币计数地址。直接通过这个地址修改金额)
  • 后来,经过研究,发现每个应用内存中使用的部分,仅占该应用小部分(活跃部分)。于是聪明的前辈们,将内存均匀分割很多页
  • 应用不用一启动就全部加载进去,而是每个启动的应用,都分配一个虚拟内存大小,里面也跟物理内存一样切割成一样大小的的内存页

现在就变成了这样:


image.png

补充:

  1. 内存管理单元
  • MMU:(Memory Management Unit) 内存管理单元,有时称作PMMUpaged memory management unit分页内存管理单元
  • 负责处理中央处理器(CPU)的内存访问请求计算机硬件
  1. 内存页大小
  • LinuxMacOS系统:每页4K
  • iOS系统: 每页16K
  1. 页表
    应用的虚拟内存物理内存地址映射关系

  2. 五大分区

  • 栈区堆区常量区代码区全局静态区都是指的虚拟内存区域。都依赖于进程(启动的应用)
    比如应用A,有个地址0x00000666, 如果应用A关闭了,应用B也有0x00000666。他们指向的完全不一样。
    应用访问的都是虚拟内存空间
  1. 虚拟空间大小
  • 每个应用(进程)默认可以分配4G大小。但它实际只是一张页表记录映射关系就可以。

  • 页表存放在操作系统内存区域

  • 应用用到的,都是物理内存实际占有物理内存大小应用运行时决定的。

    比如你1T空间百度网盘。你本地只是个地址链接而已,并不会占用电脑空间。你用了200M,它就在数据库给你200M空间资源,然后将这个资源地址和你的网盘地址 关联起来。剩余800M需要的时候,它再分配空间资源给你。
    你的所有资料,都是在它的数据库中。而你的网盘,只是记录了每个资料资料存放地址映射关系而已。

4.二进制重排原理

  • 应用启动前页表的,每一页都是PageFault(页缺省),启动时用到的每一页都需要cpu从硬盘读取物理内存中,虽然加载一页的耗时没什么感觉。但如果同时加载几百页,这个耗时就得考虑了。

本节我们研究的就是APP启动优化,所以这里也是一个优化点

  • 优化核心: 减少启动时需要加载页数
  • iOS每一页16K大小,但是16K中,可能真正在启动时刻需要用到的,可能不到1K启动需要访问到这1K数据,不得不整页加载
  • 我们的二进制重排,就是为了启动用到的这些数据整合到一起,然后进行内存分页。这样启动用到的数据都在前几页中了。启动时只需加载几页数据就可以了。
OC底层原理三十三:启动优化(二进制重排)_第6张图片
image.png
  • 知道了优化原理,但是有几个问题:
  1. 二进制重排中的二进制是啥?
  2. 二进制数据原来是什么顺序?
  3. 二进制如何重排?

4.1 二进制重排中的二进制

二进制: 只有01的两个数的数制。是机器识别进制

  • 此处二进制,主要是我们代码文件中的函数编译后变成的机器识别符号,再转换二进制文件。

  • 所以二进制重排,重排的是代码文件函数顺序

4.2 二进制数据顺序

  • 创建个Demo项目,加入测试代码:
#import "ViewController.h"

@interface ViewController ()
@end

@implementation ViewController

void test1() {
    printf("1");
}

void test2() {
    printf("2");
}

- (void)viewDidLoad {
    [super viewDidLoad];

    printf("viewDidLoad");
    test1();
}

+(void)load {
    printf("load");
    test2();
}

@end
  • Build Settings中搜索link Map,设置Write Link Map FileYES:

    OC底层原理三十三:启动优化(二进制重排)_第7张图片
    image.png

  • Command + B编译后,右键 Show In Finder打开包文件夹

    OC底层原理三十三:启动优化(二进制重排)_第8张图片
    image.png

  • 包文件上两层级,找到Intermediates.noindex:

    image.png

  • 沿路径找到并打开Demo-LinkMap-normal-x86_64.txt文件:

    OC底层原理三十三:启动优化(二进制重排)_第9张图片
    image.png

  • 函数顺序:(书写顺序)

OC底层原理三十三:启动优化(二进制重排)_第10张图片
image.png
  • 文件顺序:(加入顺序)
OC底层原理三十三:启动优化(二进制重排)_第11张图片
image.png

总结

  • 二进制的排列顺序:先文件按照加载顺序排列,文件内部按照函数书写顺序从上到下排列

我们要做的,就是把启动用到函数排列在一起

5.PageFault检测

大家可以用自己项目检测

  • 连接真机运行自己项目,打开Instruments检测工具:

    OC底层原理三十三:启动优化(二进制重排)_第12张图片
    image.png

  • 选择System Trace:

    OC底层原理三十三:启动优化(二进制重排)_第13张图片
    image.png

  • 选择真机,选择自己的项目点击第一个按钮运行,等APP启动后点击第一个按钮停止

    image.png

  • 选择自己项目,选中主线程,选择虚拟内存,查看File Backed Page In(就是PageFault缺省页):

    OC底层原理三十三:启动优化(二进制重排)_第14张图片
    image.png

  • 可以看到这里启动加载了1783页,总耗时278毫秒,平均耗时156微秒
    多试几次可能物理内存中存在已有数据,加载页数少一些。完全冷启动的话,加载页数应该会更多,耗时更明显)

6.体验二进制重排

二进制重排,关键是order文件

  • 前面讲objc源码时,会在工程中看到order文件:

    OC底层原理三十三:启动优化(二进制重排)_第15张图片
    image.png

  • 打开.order文件,可以看到内部都是排序好函数符号

    OC底层原理三十三:启动优化(二进制重排)_第16张图片
    image.png

  • 这是因为苹果自己的都进行了二进制重排

  • 我们打开创建的Demo项目,我想把排序改成load->test1->ViewDidAppear->main
  1. Demo项目根目录创建一个.order文件

    image.png

  2. ht.order文件中手动顺序写入函数(还写了个不存在的hello函数)

    OC底层原理三十三:启动优化(二进制重排)_第17张图片
    image.png

  1. Build Settings中搜索order file,加入./ht.order

    OC底层原理三十三:启动优化(二进制重排)_第18张图片
    image.png

  2. Command + B编译后,再次去查看link map文件

    OC底层原理三十三:启动优化(二进制重排)_第19张图片
    image.png

  • 发现order文件不存在的函数(hello),编译器直接跳过
  • 其他函数符号,完全按照我们order顺序排列。
  • order没有的函数,按照默认顺序接在order函数后面

此时此刻,还有谁!!宝剑在手,天下我有 哈哈哈

  • 但是,靠手写一个个函数写进order文件中。代码写了那么,还有些代码不是我写的,我怎么知道哪个函数先哪个函数后??
  • 手握宝剑看不到敌人有啥用?

目标: 拿到启动完成后的某个时刻之前的所有被调用函数劳烦你们自己排队入我的order文件中。

  • 哈哈哈,喝口水,休息下。

下一节 Clang插桩 教你宝剑口诀(函数~ 函数~ ,快到我的碗里来 )

你可能感兴趣的:(OC底层原理三十三:启动优化(二进制重排))