OC底层原理 学习大纲
- 本节,我们分享
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
:
ps: 此处记录下
砸壳后
的包重签名
过程:(看官们可忽略此处 )
- 新建
APP
文件夹,放入砸壳后
的包
- 加入
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
-
真机运行
后,可看到:
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
:
-
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 虚拟内存
早期计算机
,没有虚拟内存
概念,只有物理内存
,每个应用
都直接全部信息
写入内存条
中的。当内存条
的空间
不够时(被其他应用占据
了),就会报内存警告
。这时我们只能手动关闭
一些应用
,腾出
点内存
来让当前应用
运行。
- 有两个问题:
- 内存不够: 每个
应用
一打开
,就把所有信息
都加载
进去,占用太多资源
。大软件
直接无法加载
。 - 不安全: 每次
加载
应用,内存地址
就固定
了,很容易被人直接通过内存地址
去篡改数据
。- 早期
本地外挂
,就是通过内存地址
去篡改数据
。
(如:游戏
中捡到500金币
时,搜索
所有内存地址
,有记录500金币
的,就是金币
的计数地址
。直接通过这个地址修改金额
)
- 早期
- 后来,经过研究,发现
每个应用
在内存中
使用的部分,仅占该应用
的小部分
(活跃部分)。于是聪明的前辈们
,将内存
均匀分割
成很多页
。应用
也不用
一启动就全部加载进去
,而是每个启动的应用
,都分配一个虚拟
的内存大小
,里面也跟物理内存
一样切割成一样大小
的的内存页
。
现在就变成了这样:
补充:
- 内存管理单元
MMU
:(Memory Management Unit
)内存管理单元
,有时称作PMMU
(paged memory management unit
)分页内存管理单元
负责
处理中央处理器
(CPU)的内存访问请求
的计算机硬件
。
- 内存页大小
Linux
和MacOS
系统:每页4K
iOS
系统: 每页16K
页表
应用的虚拟内存
与物理内存
的地址映射
关系表
五大分区
栈区
、堆区
、常量区
、代码区
、全局静态区
都是指的虚拟内存
区域。都依赖于进程
(启动的应用)
比如应用A,有个地址0x00000666
, 如果应用A关闭了,应用B也有0x00000666
。他们指向的完全不一样。
应用访问
的都是虚拟内存
空间
- 虚拟空间大小
每个应用
(进程)默认可以
分配4G
大小。但它实际
只是一张页表
,记录映射关系
就可以。
页表
存放在操作系统
的内存区域
。
应用用到
的,都是物理内存
,实际
占有物理内存大小
是应用运行时
决定的。比如你
1T空间
的百度网盘
。你本地
只是个地址链接
而已,并不会占用
你电脑空间
。你用了200M
,它就在数据库
给你200M
的空间资源
,然后将这个资源地址
和你的网盘地址
关联
起来。剩余800M
你需要
的时候,它再分配
空间资源给你。
你的所有资料
,都是在它的数据库
中。而你的网盘,只是记录了每个资料
和资料存放地址
的映射关系
而已。
4.二进制重排原理
- 应用
启动前
,页表
是空
的,每一页
都是PageFault
(页缺省),启动时用到的
每一页都需要
cpu从硬盘读取
到物理内存
中,虽然加载一页
的耗时没什么感觉
。但如果同时
加载几百页
,这个耗时就得考虑了。
本节我们研究的就是APP启动优化
,所以这里也是一个优化点
。
- 优化核心:
减少
在启动时
需要加载
的页数
iOS
中每一页
是16K
大小,但是16K中
,可能真正
在启动时刻需要
用到的,可能不到1K
。但
是启动需要
访问到这1K
数据,不得不
把整页
都加载
。- 我们的
二进制重排
,就是为了把
启动用到的
这些数据
,整合
到一起,然后再
进行内存分页
。这样启动用到的
数据都在前几页
中了。启动时
,只需
要加载几页数据
就可以了。
- 知道了优化原理,但是有几个问题:
- 二进制重排中的二进制是啥?
- 二进制数据原来是什么顺序?
- 二进制如何重排?
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
:
-
Command + B
编译后,右键 Show In Finder
打开包文件夹
:
-
在
包文件
的上两层级
,找到Intermediates.noindex
:
-
沿路径
找到并打开Demo-LinkMap-normal-x86_64.txt
文件:
函数顺序:(书写顺序)
- 文件顺序:(加入顺序)
总结
- 二进制的排列顺序:先
文件
按照加载顺序
排列,文件内部
按照函数
书写顺序从上到下
排列
我们要做的,就是把
启动
会用到
的函数
排列在一起
5.PageFault检测
大家可以用自己项目
检测
-
连接真机
,运行自己项目
,打开Instruments
检测工具:
-
选择
System Trace
:
-
选择
真机
,选择自己的项目
,点击
第一个按钮运行
,等APP启动后
,点击
第一个按钮停止
。
-
选择
自己项目
,选中主线程
,选择虚拟内存
,查看File Backed Page In
(就是PageFault缺省页):
可以看到这里
启动
加载了1783页
,总耗时278毫秒
,平均耗时156微秒
。
(多试几次
,可能
物理内存中存在
已有数据
,加载页数
会少一些
。完全冷启动
的话,加载页数
应该会更多
,耗时更明显)
6.体验二进制重排
二进制重排,关键是order
文件
前面讲objc源码时,会在工程中看到
order
文件:
打开
.order
文件,可以看到内部都是排序好
的函数符号
。
这是因为
苹果
自己的库
,也
都进行了二进制重排
。
- 我们打开创建的
Demo
项目,我想把排序改成load
->test1
->ViewDidAppear
->main
。
在
Demo
项目根目录
创建一个.order文件
在
ht.order
文件中手动
顺序写入函数
(还写了个不存在的hello函数)
在
Build Settings
中搜索order file
,加入./ht.order
Command + B
编译后,再次去查看link map文件
:
- 发现
order文件
中不存在的函数
(hello),编译器
会直接跳过
。- 其他
函数符号
,完全按照我们order
顺序排列。order
中没有的函数
,按照默认顺序
接在order
函数后面
。此时此刻,还有谁!!宝剑在手,天下我有 哈哈哈
- 但是,靠
手写
一个个函数写进order文件
中。代码
写了那么多
,还有些代码不是我写的,我怎么知道哪个函数先
,哪个函数后
?? -
手握宝剑
,看不到敌人
有啥用?
目标: 拿到
启动完成
后的某个时刻
,之前
的所有被调用
的函数
。劳烦
你们自己
排队进
入我的order文件
中。
- 哈哈哈,喝口水,休息下。
下一节 Clang插桩 教你宝剑口诀
(函数~ 函数~ ,快到我的碗里来 )