- 本节,我们分享
APP启动优化:
- 冷启动和热启动
- 启动性能检测和分析
- 虚拟内存与物理内存
- 二进制重排原理
- PageFault检测
- 体验二进制重排
1. 冷启动和热启动
首次启动应用、kill应用后重新打开应用、应用置于后台隔一段时间再返回前台等情况,都是应用的启动。
有时启动很快,有时启动很慢。这是冷启动和热启动的原因:
冷启动:
-
内存中不包含APP的数据,所有数据都需要载入内存中,提供给应用使用。
(ps:内存中的数据是不会被删除的,但是存储空间可能被其他应用使用了,从而数据被覆盖。)
热启动:
-
内存中仍然存在APP的数据,数据不需要重新载入内存。
(ps:当前应用所占的内存空间,未被其他应用覆盖。所以数据依旧可读取)
冷启动与热启动的区别和场景【区别】
内存中是否有加载的数据。
- 有:
热启动,无需重新加载数据,速度快。- 无:
冷启动,需要从磁盘读取数据加载到内存中,耗时,速度慢。【场景】
- 首次启动:
一定是冷启动。(内存中无数据)- kill后启动:
冷启动或热启动(取决于内存中是否有数据)- 置于后台再回到前台:
冷启动或热启动(取决于内存中是否有数据)
(ps: 如果其他应用需要更多内存空间,系统可能自动覆盖你的内存空间提供给其他应用使用,此时你的数据就被覆盖了,回到前台时,应用自动重启)
2. 启动性能检测和分析
测试APP启动,分为两个阶段:
-
main函数前:
dyld负责的启动流程(参考dyld 应用程序加载)
系统处理,我们从
dyld应用加载的流程来优化。(借助系统工具分析耗时)
-
main函数后:
开发者自己的业务代码
通过
检测业务流程来优化(main函数打个时间点、第一个页面渲染完成打个时间点。测算耗时)
2.1 main函数前
大家可以使用
自己的项目作为观察对象,此处是以砸包后的某个应用为测试对象,仅供观察和学习
- 创建
Demo项目,新增环境变量 DYLD_PRINT_STATISTICS:
image.png
ps: 此处记录下
砸壳后的包重签名过程:(看官们可忽略此处 😂)
- 新建
APP文件夹,放入砸壳后的包
image.png
- 加入
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"
Demo工程添加脚本指令./appSign.sh
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:
image.png -
rebase/binding time:重定向和绑定操作的耗时
[rebase重定向]:从磁盘的MachO中image镜像到内存中)
[binding绑定]:MachO中每个文件使用其他库的符号时,绑定库名和地址出于
安全考虑,编译时和运行时地址不一样。使用了ASLR(Address space layout randomization)地址空间配置随机加载,每次载入内存后,需要将原地址加上ASLR随机偏移值来进行内存读取。 具体原因,下面分析虚拟内存与物理内存时,就清楚了 -
ObjC setup time:OC类的注册耗时(OC类越多,越耗时)swift没有OC类,所以在这一步有优越性。 -
initializer time:初始化耗时(load非懒加载类和c++构造函数的耗时)
-
-
slowest intializers:
最慢的启动对象:-
libSystem.B.dylib: 系统库 -
libMainThreadChecker.dylib: 系统库 -
libglInterpose.dylib: 系统库(调试使用的,不影响) -
砸壳应用:自己的APP耗时
-
2.2 main函数后
- 业务层面:
-
启动时用不到的类和页面,移到启动后创建 -
耗时操作使用多线程处理 -
启动页面,尽量不用XIB和StoryBoard
-
技术层面:
1.二进制重排
(重排的是编译阶段的文件顺序,减少启动时刻,硬盘到内存的操作次数)
在讲
二进制重排前,必须知道虚拟内存和物理内存
3. 虚拟内存与物理内存
-
物理内存:
内存条的真实大小。 (4G内存条,物理内存就是4G) -
虚拟内存:
物理内存的衍生物。(每个虚拟内存的大小都是物理内存的大小)
物理内存容易理解,就是真实内存条的容量。但虚拟内存是个啥?
3.1 虚拟内存
早期计算机,没有虚拟内存概念,只有物理内存,每个应用都直接全部信息写入内存条中的。当内存条的空间不够时(被其他应用占据了),就会报内存警告。这时我们只能手动关闭一些应用,腾出点内存来让当前应用运行。
image.png
- 有两个问题:
-
内存不够: 每个
应用一打开,就把所有信息都加载进去,占用太多资源。大软件直接无法加载。 -
不安全: 每次
加载应用,内存地址就固定了,很容易被人直接通过内存地址去篡改数据。- 早期
本地外挂,就是通过内存地址去篡改数据。
(如:游戏中捡到500金币时,搜索所有内存地址,有记录500金币的,就是金币的计数地址。直接通过这个地址修改金额)
- 早期
- 后来,经过研究,发现
每个应用在内存中使用的部分,仅占该应用的小部分(活跃部分)。于是聪明的前辈们,将内存均匀分割成很多页。应用也不用一启动就全部加载进去,而是每个启动的应用,都分配一个虚拟的内存大小,里面也跟物理内存一样切割成一样大小的的内存页。
现在就变成了这样:
image.png
补充:
- 内存管理单元
MMU:(Memory Management Unit)内存管理单元,有时称作PMMU(paged memory management unit)分页内存管理单元负责处理中央处理器(CPU)的内存访问请求的计算机硬件。
- 内存页大小
Linux和MacOS系统:每页4KiOS系统: 每页16K
页表
应用的虚拟内存与物理内存的地址映射关系表五大分区
栈区、堆区、常量区、代码区、全局静态区都是指的虚拟内存区域。都依赖于进程(启动的应用)
比如应用A,有个地址0x00000666, 如果应用A关闭了,应用B也有0x00000666。他们指向的完全不一样。
应用访问的都是虚拟内存空间
- 虚拟空间大小
每个应用(进程)默认可以分配4G大小。但它实际只是一张页表,记录映射关系就可以。
页表存放在操作系统的内存区域。
应用用到的,都是物理内存,实际占有物理内存大小是应用运行时决定的。比如你
1T空间的百度网盘。你本地只是个地址链接而已,并不会占用你电脑空间。你用了200M,它就在数据库给你200M的空间资源,然后将这个资源地址和你的网盘地址关联起来。剩余800M你需要的时候,它再分配空间资源给你。
你的所有资料,都是在它的数据库中。而你的网盘,只是记录了每个资料和资料存放地址的映射关系而已。
4.二进制重排原理
- 应用
启动前,页表是空的,每一页都是PageFault(页缺省),启动时用到的每一页都需要cpu从硬盘读取到物理内存中,虽然加载一页的耗时没什么感觉。但如果同时加载几百页,这个耗时就得考虑了。
本节我们研究的就是APP启动优化,所以这里也是一个优化点。
- 优化核心:
减少在启动时需要加载的页数
iOS中每一页是16K大小,但是16K中,可能真正在启动时刻需要用到的,可能不到1K。但是启动需要访问到这1K数据,不得不把整页都加载。- 我们的
二进制重排,就是为了把启动用到的这些数据,整合到一起,然后再进行内存分页。这样启动用到的数据都在前几页中了。启动时,只需要加载几页数据就可以了。
image.png
- 知道了优化原理,但是有几个问题:
- 二进制重排中的二进制是啥?
- 二进制数据原来是什么顺序?
- 二进制如何重排?
4.1 二进制重排中的二进制
二进制: 只有0和1的两个数的数制。是机器识别的进制。
-
此处的二进制,主要是指我们代码文件中的函数,编译后变成的机器识别符号,再转换的二进制文件。 -
所以二进制重排,
重排的是代码文件和函数的顺序。
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 File为YES:
image.png
-
Command + B编译后,右键 Show In Finder打开包文件夹:
image.png
-
在
包文件的上两层级,找到Intermediates.noindex:
image.png
-
沿路径找到并打开Demo-LinkMap-normal-x86_64.txt文件:
image.png
-
函数顺序:(书写顺序)
image.png
- 文件顺序:(加入顺序)
image.png
总结
-
二进制的排列顺序:先
文件按照加载顺序排列,文件内部按照函数书写顺序从上到下排列
我们要做的,就是把
启动会用到的函数排列在一起
5.PageFault检测
大家可以用自己项目检测
-
连接真机,运行自己项目,打开Instruments检测工具:
image.png
-
选择
System Trace:
image.png
-
选择
真机,选择自己的项目,点击第一个按钮运行,等APP启动后,点击第一个按钮停止。
image.png
-
选择自己项目,选中主线程,选择虚拟内存,查看File Backed Page In(就是PageFault缺省页):
image.png
-
可以看到这里
启动加载了1783页,总耗时278毫秒,平均耗时156微秒。
(多试几次,可能物理内存中存在已有数据,加载页数会少一些。完全冷启动的话,加载页数应该会更多,耗时更明显)
6.体验二进制重排
二进制重排,关键是order文件
前面讲objc源码时,会在工程中看到
order文件:
image.png
打开
.order文件,可以看到内部都是排序好的函数符号。
image.png
这是因为
苹果自己的库,也都进行了二进制重排。
- 我们打开创建的
Demo项目,我想把排序改成load->test1->ViewDidAppear->main。
在
Demo项目根目录创建一个.order文件
image.png
在
ht.order文件中手动顺序写入函数(还写了个不存在的hello函数)
image.png
在
Build Settings中搜索order file,加入./ht.order
image.png
Command + B编译后,再次去查看link map文件:
image.png
- 发现
order文件中不存在的函数(hello),编译器会直接跳过。- 其他
函数符号,完全按照我们order顺序排列。order中没有的函数,按照默认顺序接在order函数后面。此时此刻,还有谁!!宝剑在手,天下我有 哈哈哈 😃
- 但是,靠
手写一个个函数写进order文件中。代码写了那么多,还有些代码不是我写的,我怎么知道哪个函数先,哪个函数后?? -
手握宝剑,看不到敌人有啥用?
目标: 拿到
启动完成后的某个时刻,之前的所有被调用的函数。劳烦你们自己排队进入我的order文件中。
- 哈哈哈,喝口水,休息下。
下一节 Clang插桩 教你宝剑口诀(函数~ 函数~ ,快到我的碗里来 😂 )

image.png
image.png
image.png
image.png
image.png
image.png
image.png
image.png
image.png











网友评论