mirror of
https://github.com/NohamR/knowledge-kit.git
synced 2026-05-25 12:27:15 +00:00
552 lines
30 KiB
Markdown
552 lines
30 KiB
Markdown
# App 启动时间优化与二进制重排
|
||
|
||
## 启动分类
|
||
|
||
- 冷启动(Cold Launch):点击 App 图标启动前,进程不在系统中。需要系统新创建一个进程并加载 Mach-O 文件,dyld 从 Mach-O 头信息中读取依赖(Load Commands),从动态库共享缓存中读取并链接,经历一次完整的启动过程。(dyld 加载 Mach-O 完整的流程可以查看我[另一篇文章](./1.91.md))
|
||
- 热启动(Warm Launch):App 在冷启动后,用户将 App 退后台。此阶段,App 的进程还在系统中,用户重新启动进入 App 的过程,开发对该阶段能做的事情非常少。
|
||
|
||
所以我们聊启动时间优化,通常是指冷启动。此外启动慢,也就是看到主页面的过程很慢,都是发生在主线程上的。
|
||
|
||
为了量化启动时间,要么自定义 APM 监控。要么利用 Xcode 提供的启动时间统计。通过添加环境变量可以打印出APP的启动时间分析(Edit scheme -> Run -> Arguments)
|
||
|
||
- `DYLD_PRINT_STATISTICS` 设置为1
|
||
|
||
- 如果需要更详细的信息,那就将 `DYLD_PRINT_STATISTICS_DETAILS` 设置为1
|
||
|
||
|
||
|
||
## 启动阶段划分
|
||
|
||
App 冷启动可以划分为3大阶段:
|
||
|
||
- 第一阶段:进程创建到 main 函数执行(dyld、runtime)
|
||
|
||
- 第二阶段:main 函数到 `didFinishLaunchingWithOptions`
|
||
|
||
- 第三阶段:`didFinishLaunchingWithOptions` 到业务首页加载完成
|
||
|
||
这里说的阶段都是某一个步骤的最后一步。比如第一阶段的 main 函数执行的结束时刻
|
||
|
||
```shell
|
||
xnu_run () {
|
||
t1
|
||
//...
|
||
}
|
||
|
||
main () {
|
||
// ..
|
||
// t2
|
||
}
|
||
```
|
||
|
||
|
||
|
||
## 第一阶段:进程创建到 main 函数执行(dyld、runtime)
|
||
|
||

|
||
|
||
这一阶段主要包括 dyld 和 Runtime 、main 函数的工作。
|
||
|
||
### dyld
|
||
|
||
iOS 的可执行文件都是 Mach-O 格式,所以 App 加载过程就是加载 Mach-O 文件的过程。
|
||
|
||
```c
|
||
struct mach_header_64 {
|
||
uint32_t magic; // 64位还是32位
|
||
cpu_type_t cputype; // CPU 类型,比如 arm 或 X86
|
||
cpu_subtype_t cpusubtype; // CPU 子类型,比如 armv8
|
||
uint32_t filetype; // 文件类型
|
||
uint32_t ncmds; // load commands 的数量
|
||
uint32_t sizeofcmds; // load commands 大小
|
||
uint32_t flags; // 标签
|
||
uint32_t reserved; // 保留字段
|
||
};
|
||
```
|
||
|
||
加载 Mach-O 文件,内核会先 fork 进程,并为进程分配虚拟内存、为进程创建主线程、代码签名等,用户态 dyld 会对 Mach-O 文件做库加载和符号解析。
|
||
|
||
细节可以查看代码,在 xnu 的 `kern_exec.c` 中
|
||
|
||
```c
|
||
int __mac_execve(proc_t p, struct __mac_execve_args *uap, int32_t *retval) {
|
||
// 字段设置
|
||
...
|
||
int is_64 = IS_64BIT_PROCESS(p);
|
||
struct vfs_context context;
|
||
struct uthread *uthread; // 线程
|
||
task_t new_task = NULL; // Mach Task
|
||
...
|
||
|
||
context.vc_thread = current_thread();
|
||
context.vc_ucred = kauth_cred_proc_ref(p);
|
||
|
||
// 分配大块内存,不用堆栈是因为 Mach-O 结构很大。
|
||
MALLOC(bufp, char *, (sizeof(*imgp) + sizeof(*vap) + sizeof(*origvap)), M_TEMP, M_WAITOK | M_ZERO);
|
||
imgp = (struct image_params *) bufp;
|
||
|
||
// 初始化 imgp 结构里的公共数据
|
||
...
|
||
|
||
uthread = get_bsdthread_info(current_thread());
|
||
if (uthread->uu_flag & UT_VFORK) {
|
||
imgp->ip_flags |= IMGPF_VFORK_EXEC;
|
||
in_vfexec = TRUE;
|
||
} else {
|
||
// 程序如果是启动态,就需要 fork 新进程
|
||
imgp->ip_flags |= IMGPF_EXEC;
|
||
// fork 进程
|
||
imgp->ip_new_thread = fork_create_child(current_task(),
|
||
NULL, p, FALSE, p->p_flag & P_LP64, TRUE);
|
||
// 异常处理
|
||
...
|
||
|
||
new_task = get_threadtask(imgp->ip_new_thread);
|
||
context.vc_thread = imgp->ip_new_thread;
|
||
}
|
||
|
||
// 加载解析 Mach-O
|
||
error = exec_activate_image(imgp);
|
||
|
||
if (imgp->ip_new_thread != NULL) {
|
||
new_task = get_threadtask(imgp->ip_new_thread);
|
||
}
|
||
|
||
if (!error && !in_vfexec) {
|
||
p = proc_exec_switch_task(p, current_task(), new_task, imgp->ip_new_thread);
|
||
|
||
should_release_proc_ref = TRUE;
|
||
}
|
||
|
||
kauth_cred_unref(&context.vc_ucred);
|
||
|
||
if (!error) {
|
||
task_bank_init(get_threadtask(imgp->ip_new_thread));
|
||
proc_transend(p, 0);
|
||
|
||
thread_affinity_exec(current_thread());
|
||
|
||
// 继承进程处理
|
||
if (!in_vfexec) {
|
||
proc_inherit_task_role(get_threadtask(imgp->ip_new_thread), current_task());
|
||
}
|
||
|
||
// 设置进程的主线程
|
||
thread_t main_thread = imgp->ip_new_thread;
|
||
task_set_main_thread_qos(new_task, main_thread);
|
||
}
|
||
...
|
||
}
|
||
```
|
||
|
||
Mach-O 文件非常大,系统为 Mach-O 分配一块大内存 imgp,接下来会初始化 imgp 里的公共数据。内存处理完,__mac_execve 会通过 `fork_create_child` 函数 fork 出一个新的进程,然后通过 `exec_activate_image` 函数解析加载 Mach-O 文件到内存 imgp 中。最后调用 `task_set_main_thread_qos` 设置新 fork 出来进程的主线程。
|
||
|
||
```c
|
||
struct execsw {
|
||
int (*ex_imgact)(struct image_params *);
|
||
const char *ex_name;
|
||
} execsw[] = {
|
||
{ exec_mach_imgact, "Mach-o Binary" },
|
||
{ exec_fat_imgact, "Fat Binary" },
|
||
{ exec_shell_imgact, "Interpreter Script" },
|
||
{ NULL, NULL}
|
||
};
|
||
```
|
||
|
||
可以看到 Mach-O 文件解析使用 `exec_mach_imgact` 函数。该函数内部调用 `load_machfile` 来加载 Mach-O 文件,解析 Mach-O 文件后得到 load command 信息,通过映射方式加载到内存中。`activate_exec_state()` 函数处理解析加载 Mach-O 后的结构信息,设置执行 App 的入口点。
|
||
|
||
之后会通过 `load_dylinker()` 函数来解析加载 dyld,然后将入口地址改为 dyld 入口地址。至此,内核部分就完成 Mach-O 文件的加载,剩下的就是用户态 dyld 加载 App 了。
|
||
|
||
dyld 入口函数为 `_dyld_start`,dyld 属于用户态进程,不在 xnu 中,具体实现可以查看 [dyld/dyldStartup.s at master · opensource-apple/dyld · GitHub](https://github.com/opensource-apple/dyld/blob/master/src/dyldStartup.s),`_dyld_start` 会加载 App 动态库,处理完成后会返回 App 的入口地址。然后执行 App 的 main 函数。
|
||
|
||
dyld(dynamic link editor),Apple 的动态链接器,可以用来装载 Mach-O 文件(可执行文件、动态库等)
|
||
|
||
启动 APP 时,dyld 所做的事情有
|
||
|
||
- 装载 APP 的可执行文件,同时会递归加载所有依赖的动态库
|
||
|
||
- 当 dyld 把可执行文件、动态库都装载完毕后,会通知 Runtime 进行下一步的处理。
|
||
其中包括 ASLR,rebase、bind。
|
||
|
||
QA:这里的通知 Runtime 怎么理解?
|
||
查看 objc4 的源代码 `objc-os.mm` 文件中的 `_objc_init` 方法
|
||
|
||
```c
|
||
/***********************************************************************
|
||
* _objc_init
|
||
* Bootstrap initialization. Registers our image notifier with dyld.
|
||
* Called by libSystem BEFORE library initialization time
|
||
**********************************************************************/
|
||
void _objc_init(void){
|
||
static bool initialized = false;
|
||
if (initialized) return;
|
||
initialized = true;
|
||
// fixme defer initialization until an objc-using image is found?
|
||
environ_init();
|
||
tls_init();
|
||
static_init();
|
||
lock_init();
|
||
exception_init();
|
||
_dyld_objc_notify_register(&map_images, load_images, unmap_image);
|
||
}
|
||
```
|
||
|
||
方法注释说的很明白,被 dyld 所调用。
|
||
|
||
dyld 加载解析 Mach-O,根据 Mach-O 的 Load Commands 读取所需的动态库、静态库去加载。从 Mach-O 的 `LC_LOAD_DYLINKER` load command 中,根据 name 路径信息,然后加载,dyld 开始执行。它的主要任务是加载应用程序的主可执行文件以及其他依赖的动态库。
|
||
|
||
对于动态库,分为系统动态库和开发者写的动态库:
|
||
|
||
- 系统共享缓存:系统动态库,Apple 为了启动优化在 iOS 3.1及以后的版本中,Apple 将系统库文件打包合并成一个大的缓存文件,存放在 `/System/Library/Caches/com.apple.dyld/ ` 目录下,以减少冗余并优化内存使用。
|
||
- 检查共享缓存:当应用程序启动时,dyld 首先检查共享缓存中是否已经包含了所需的系统库。共享缓存是一种优化机制,用于存储系统级别的动态库,以便多个应用程序可以重用这些库,减少内存占用并加快加载速度。
|
||
- 映射到地址空:如果动态库在共享缓存中,dyld 将直接从缓存中映射库到应用程序的地址空间,而不是从磁盘加载
|
||
- 验证和解密:对于加密的 Mach-O 文件(例如应用商店发布的应用程序),dyld 将解密并验证代码签名以确保安全性
|
||
- Rebase & Binding:由于存在 ASLR 和系统共享缓存库的存在,dyld 会进行 rebase 和 binding。解析符号真正的地址。具体可以看[FishHook 原理](./1.88.md) 和[DYLD 及 Mach-O ](./1.91.md) 文章
|
||
- 开发者编写的动态库:
|
||
- 解析依赖:dyld 从应用程序的主可执行文件开始,解析出所有依赖的动态库,包括开发者添加的自定义动态库。
|
||
- 加载 Mach-O 文件:对于每个依赖的动态库,dyld 会找到对应的 Mach-O 文件,并进行加载。
|
||
- 读取和映射:dyld 打开并读取 Mach-O 文件,然后使用 mmap 系统调用来将文件的内容映射到内存中。
|
||
- 依赖递归加载:如果动态库本身还依赖其他库,dyld 会递归地加载这些依赖库。
|
||
- 符号解析和绑定:与系统库类似,dyld 也会对自定义动态库进行 Rebase 和 Binding,确保所有符号引用都是正确的。
|
||
- 初始化:加载完成后,dyld 会调用动态库中的初始化代码,例如 C++ 的静态构造函数和 Objective-C 的 +load 方法
|
||
|
||
|
||
|
||
到了 dyld3 之后,带来了**启动闭包**技术。
|
||
|
||
dyld 会首先创建启动闭包,闭包是一个缓存,用来提升启动速度的。既然是缓存,那么必然不是每次启动都创建的,只有在重启手机或者更新/下载 App 的第一次启动才会创建。闭包存储在沙盒的 tmp/com.apple.dyld 目录,清理缓存的时候切记不要清理这个目录
|
||
|
||
闭包是怎么提升启动速度的呢?我们先来看一下闭包里都有什么内容:
|
||
|
||
- dependends:依赖动态库列表
|
||
- fixup:bind & rebase 的地址
|
||
- initializer-order:初始化调用顺序
|
||
- optimizeObjc: Objective C 的元数据
|
||
- 其他:main entry, uuid…
|
||
|
||
为什么闭包能提高启动速度呢?
|
||
|
||
这些信息是每次启动都需要的,把信息存储到一个缓存文件就能避免每次都解析,尤其是 Objective C 的运行时数据(Class/Method...)解析非常慢
|
||
|
||
|
||
|
||
### Runtime
|
||
|
||
启动 APP 时,Runtime 所做的事情有
|
||
|
||
- 调用 `map_images` 进行可执行文件内容的解析和处理
|
||
|
||
- 在 `load_images` 中调用 `call_load_methods`,调用所有 Class、Category 的 `+load`方法
|
||
|
||
- 进行各种 objc 结构的初始化(注册 Objc 类 、初始化类对象等等)
|
||
|
||
- 调用 C++ 静态初始化器和 `__attribute__((constructor))` 修饰的函数
|
||
|
||
到此为止,可执行文件和动态库中所有的符号(Class,Protocol,Selector,IMP,…)都已经按格式成功加载到内存中,被 Runtime 所管理
|
||
|
||
|
||
|
||
## 第二阶段:main 函数到 didFinishLaunchingWithOptions
|
||
|
||
APP的启动由 dyld 主导,将可执行文件加载到内存,顺便加载所有依赖的动态库。并由Runtime 负责加载成 objc 定义的结构。所有初始化工作结束后,dyld 就会调用 main 函数
|
||
|
||
接下来就是 `UIApplicationMain` 函数。main 函数内部其实没啥逻辑,可能会存在一些防止逆向相关的安全代码。这部分对启动耗时没啥影响,可以忽略先。
|
||
|
||
AppDelegate 的`application:didFinishLaunchingWithOptions:` 方法。此阶段主要是各种 SDK 的注册、初始化、APM 监控系统的启动、热修复 SDK 的设置、App 初始化数据的加载、IO 等等。
|
||
|
||
|
||
|
||
## 第三阶段:`didFinishLaunchingWithOptions` 到业务首页加载完成
|
||
|
||
这部分主要是首页渲染相关逻辑,不同的场景,首页的定义可能不一样。比如未登录的时候首页就是登陆页、否则就是某个主页
|
||
|
||
- 首屏数据的网络/DB IO 读取
|
||
|
||
- 渲染数据的计算
|
||
|
||
|
||
|
||
## 启动优化
|
||
|
||
### 第一阶段
|
||
|
||
#### dyld
|
||
|
||
- 减少动态库数量:过多的动态库会增加 dyld 的解析和加载时间。优化库的依赖关系,合并功能相似的库。(定期清理不必要的动态库,iOS 规定开发者写的动态库不能超过6个)
|
||
- 使用静态库:考虑将动态库转换为静态库,以减少运行时的加载和链接时间。
|
||
- 懒加载:对于非启动必需的动态库,可以推迟到实际需要时再加载。
|
||
- 减少 Objective-C 元数据:Objective-C 的类、分类和选择器数量会影响 dyld 的 Binding 时间。减少这些元数据的数量可以加快启动速度
|
||
- 优化 C++ 虚函数:C++ 中的虚函数需要在运行时进行解析,这会增加 dyld 的工作量。尽量减少虚函数的使用或使用其他设计模式替代
|
||
- 利用 Swift 结构体:Swift 的结构体是值类型,它们在编译时会进行优化,减少运行时的符号解析需求
|
||
- 优化 +load 方法:Objective-C 中的 +load 方法会在类或分类加载时执行,这可能会影响启动速度。尽量避免在 +load 方法中执行耗时操作,或者使用 +initialize 方法替代,后者只有在类被实际使用时才会调用
|
||
- 二进制重排:接下去单独的篇章会讲。
|
||
- 利用 dyld 缓存:iOS 13 引入的 dyld 3 可以生成“启动闭包”(launch closure),预先处理一些加载和链接工作,加快启动速度。
|
||
- 使用 Xcode 的分析工具:利用 Xcode 的分析工具识别启动过程中的性能瓶颈。单点问题单点追踪分析。
|
||
- 关注 dyld 版本变更:iOS 13 引入了 dyld 3,它在性能上有所改进,但也可能带来兼容性问题。了解 dyld 版本变更对 App 启动性能的影响,如果有需要,请根据需要进行适配。
|
||
|
||
|
||
|
||
#### Runtime
|
||
|
||
- 用 `+initialize` 方法和 `dispatch_once` 取代所有的 `__attribute__((constructor))`、C++静态构造器、ObjC 的 `+load`
|
||
|
||
- +load 方法中的代码可以监控等 App 启动完成后才去执行。或使用 + initialize 方法。一个 +load 方法中如果执行 hook 方法替换,大约影响4ms。
|
||
|
||
- 减少 Objc 类、分类的数量、减少 selector 数量(定期清理不必要的类、分类)。推荐工具 fui。
|
||
|
||
- 减少 C++ 虚函数数量
|
||
|
||
- Swift 尽量使用 struct
|
||
|
||
- 控制 C++ 的全局变量的数据
|
||
|
||
### 第二阶段
|
||
|
||
- 在不影响用户体验的前提下,尽可能将一些操作延迟,不要全部都放在 `application:didFinishLaunchingWithOptions` 方法中
|
||
- SDK 初始化遵循规范。什么时候注册、什么时候启动
|
||
- 任务启动器:其实就是按照一定的规则进行任务编排。将任务分类:
|
||
- 那些任务是需要在 App 启动完成前主线程同步执行的
|
||
- 那些任务是需要在 App 启动完成前主线程异步执行的
|
||
- 那些任务是需要在 App 启动完成后编排的
|
||
- 闲时主线程队列(监听 runloop 状态,`KCFRunLoopBeforeWaiting` 时执行,在 `KCFRunLoopAfterWaiting` 时停止)
|
||
- 异步串行队列
|
||
- 异步并行队列
|
||
- 闲时异步串行队列
|
||
-
|
||
|
||
- 二进制重排
|
||
- 方法耗时统计(time profiler、os_signpost)
|
||
|
||
AppDelegate 中 `application:didFinishLaunchingWithOptions` 函数阶段一般有很多业务代码介入,大多数启动时间问题都是在此阶段造成的。
|
||
|
||
- 比如二方、三方 SDK 的注册、初始化。这其中有些 SDK 比如 APM 是有必要放在很早的阶段。有些则不需要,比如日志 SDK 的初始化
|
||
- 梳理业务,非必要的延迟加载、启动
|
||
|
||
### 第三阶段
|
||
|
||
很多时候,需要梳理出那些功能是首屏渲染所需要的初始化功能,那些是非首页需要的功能,按照业务场景梳理并治理。
|
||
|
||
QA:
|
||
|
||
静态库、动态库?
|
||
|
||
静态库:.o文件集合。静态库编译、链接后就不存在了,变为可执行文件了
|
||
|
||
动态库:一个已经链接完全的镜像。已经被静态链接过
|
||
|
||
动态库不可以变为静态库。静态库可以变为动态库。
|
||
|
||
静态库缺点:产物体积比较大,影响包大小(大)。链接到 App 之后,App 体积会比较小(??)静态库 strip
|
||
|
||
动态库缺点:除了系统动态库之外,没有真正意义上的动态库(不会放到系统的共享缓冲区)
|
||
|
||
适用场景:
|
||
|
||
静态库不影响启动时间、动态库代码保密性好。
|
||
|
||
Framework 只是打包方式,动态、静态库都可以支持。甚至可以存放资源文件。
|
||
|
||
|
||
|
||
## 二进制重排
|
||
|
||
### 虚拟内存、物理内存、内存分页、ASLR
|
||
|
||
早期:应用程序的可执行文件是存储在磁盘上的,启动应用,则需要将可执行文件加载进内存,早期计算机是没有虚拟内存的,一旦加载就会全部加载到内存中,并且进程都是按照顺序排列的,这样存在两个问题:
|
||
|
||
- 当前进程只需要在合适的地址基础上,加减一些地址,就能访问到下一个进程所使用的内存,安全性很低
|
||
|
||
- 当前软件越来越大,开启多个软件时会直接全部加载到内存中,导致内存不够用。而且使用软件的时候大多数情况只使用部分功能。如果软件一打开就全部加载到内存中,会造成内存的严重浪费。
|
||
|
||
基于上述2个问题,诞生了虚拟内存技术。App 进程通过内存管理单元(Memory Management Unit, MMU)来管理的。MMU 使用一张特殊的表来跟踪虚拟内存地址和物理内存地址之间的映射关系。这张表通常被称为页表(Page Table)
|
||
|
||
内存分页:
|
||
|
||
- 页表结构:页表包含了一系列的表项,每个表项对应虚拟内存中的一个页(通常是 4KB)。每个表项包含了该虚拟页对应的物理页的信息,包括物理页的起始地址和一些状态信息。
|
||
- 虚拟地址到物理地址的转换:当程序访问一个虚拟地址时,MMU 查看页表来找到对应的表项,然后根据表项中的信息将虚拟地址转换为物理地址。
|
||
- 页表缓存(TLB - Translation Lookaside Buffer):为了提高地址转换的速度,MMU 通常会有一个 TLB,它缓存了最近访问的页表项。这样,对于频繁访问的地址,转换过程可以更快地完成
|
||
- 页错误处理:如果一个虚拟地址在页表中没有对应的条目,就会发生页错误。操作系统的页错误处理程序会被调用,它负责加载缺失的页到物理内存中,并更新页表。
|
||
- 内存分配:当应用程序请求内存时,操作系统会分配虚拟地址空间,并在页表中创建相应的条目。如果需要,操作系统还会分配物理内存页,并将其与虚拟页关联起来。
|
||
|
||
当物理内存满的时候,会发生覆盖。用户使用的活跃的数据,覆盖内存中最不活跃的数据那一页。对应现实的表现就是:iPhone 上永远可以较好的打开一个 App,比如打开了 App1、App2、App3...,等打开使用了一阵子后,内存紧张,我们尝试切换打开最早的 App1,会发现 App1 重新启动了,之前用的功能 A 的页面1,已经不见了,经历一个新的启动流程。
|
||
|
||
|
||
|
||
但虚拟内存方案带来一个问题。比如黑客不断探索发现,某个重要的功能位于第3页,是不是完全可以通过固定的地址去访问??
|
||
|
||
因为早期物理内存方案下,App 启动后位于什么地址是不确定的。有了虚拟内存后,App 内符号的地址都是从0到4G,都是相对地址。
|
||
|
||
为了解决该问题,Apple 又推出了 ASLR 技术。核心就是为了解决虚拟内存地址固定不变的问题。就不能通过固定的地址访问内存或者数据了。
|
||
|
||
|
||
|
||
### 内存缺页异常
|
||
|
||
每个进程在创建加载时,会被分配一个大小大概为1~2倍真实地内存的连续虚拟地址空间,让当前软件认为自己拥有一块很大内存空间。实际上是把磁盘的一小部分作为假想内存来使用。
|
||
|
||
CPU 不直接和物理内存打交道,而是通过 MMU(Memory Manage Unit,内存管理单元),MMU 是一种硬件电路,速度很快,主要工作是内存管理,地址转换是功能之一。
|
||
|
||
每个进程都会有自己的页表 `Page Table` ,页表存储了进程中虚拟地址到物理地址的映射关系,所以就相当于地图。MMU 收到 CPU 的虚拟地址之后就开始查询页表,确定是否存在映射以及读写权限是否正常。
|
||
|
||
iOS 程序在进行加载时,会根据一 page 大小16kb 将程序分割为多页,启动时部分的页加载进真实内存,部分页还在磁盘中,中间的调度记录在一张内存映射表(Page Table),这个表用来调度磁盘和内存两者之间的数据交换。
|
||
|
||
<img src="https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/iOSPageInPageOut.png" style="zoom:40%" />
|
||
|
||
如上图,App 运行时执行某个任务时,会先访问虚拟页表,如果页表的标记为1,则说明该页面数据已经存在于内存中,可以直接访问。如果页表为0,则说明数据未在物理内存中,这时候系统会阻塞进程,叫做**缺页中断(page fault)**,进程会从用户态切换到内核态,并将缺页中断交给内核的 `page Fault Handler` 处理。等将对应的 page 从磁盘加载到内存之后再进行访问,这个过程叫做 page in。1次缺页异常耗时较少,用户感知不到,但是在 App 启动阶段,容易发生缺页异常,如果发生几十次、几百次,这对于 App 启动时间来说,影响较大。
|
||
|
||
|
||
|
||
因为磁盘访问速度较慢,所以 page in 比较耗时,而且 iOS 不仅仅是将数据加载到内存中,还要对这页做 Code Sign 签名认证,所以 iOS 耗时更长
|
||
|
||
Tips:Code Sign 加密哈希并不少针对于整个文件,而是针对于每一个 Page 的,保证了在 dyld 进行加载的时候,可以对每一个 page 进行独立验证。
|
||
|
||
等到程序运行时用到了才去内存中寻找虚拟地址对应的页帧,找不到才进行分配,这就是内存的惰性(延时)分配机制。
|
||
|
||
<img src="https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/PageFault.png" style="zoom:50%" />
|
||
|
||
为了提高效率和方便管理,对虚拟内存和物理内存进行分页(Page)管。进程在访问虚拟内存的一个 page 而对应的物理内存却不存在(没有被加载到物理内存中),则会触发一次缺页异常(缺页中断),然后分配物理内存,有需要的话会从磁盘 mmap 读入数据。
|
||
|
||
启动时所需要的代码分布在 VM 的第一页、第二页、第三页...,这样的情况下启动时间会影响较大,所以解决思路就是将应用程序启动刻所需要的代码(二进制优化一下),统一放到某几页,这样就可以避免内存缺页异常,则优化了 App 启动时间。
|
||
|
||
二进制重排提升 App 启动速度是通过「解决内存缺页异常」(内存缺页会有几毫秒的耗时)来提速的。
|
||
|
||
一个 App 发生大量「内存缺页」的时机就是 App 刚启动的时候。所以优化手段就是「将影响 App 启动的方法集中处理,放到某一页或者某几页」(虚拟内存中的页)。
|
||
|
||
Xcode 工程允许开发者指定 「Order File」,可以「按照文件中的方法顺序去加载」。
|
||
|
||
|
||
|
||
核心就是:二进制重排提升 App 启动速度是通过「解决内存缺页异常」(内存缺页会有几毫秒的耗时)来提速的。
|
||
|
||
一个 App 发生大量「内存缺页」的时机就是 App 刚启动的时候。所以优化手段就是「将影响 App 启动的方法集中处理,放到某一页或者某几页」(虚拟内存中的页)。Xcode 工程允许开发者指定 「Order File」,可以「按照文件中的方法顺序去加载」,可以查看 linkMap 文件(需要在 Xcode 中的 「Buiild Settings」中设置 Order File、Write Link Map Files 参数)。
|
||
|
||
|
||
|
||
### 如何获取启动阶段的 Page Fault 次数
|
||
|
||
Instrucments 中的 System Trace 可以查看详细信息。
|
||
|
||
|
||
|
||
### 获取符号顺序
|
||
|
||
可以查看 linkMap 文件(需要在 Xcode 中的 「Buiild Settings」中设置 `Order File`、`Write Link Map File` 参数)。
|
||
|
||
设置 Xcode: `Build Settings` -> `Write Link Map Files` 为 YES。
|
||
|
||
<img src="https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/XcodeEnableLinkMap.png" style="zoom:30%" />
|
||
|
||
分析:
|
||
|
||
- 可以看到符号顺序是根据链接顺序来决定的
|
||
- 符号的链接顺序并非是 App 方法真正的执行顺序。
|
||
|
||
可以调整下 Person 类中2个方法的顺序,也可以看到新生成的 linkMap 符号顺序变了。
|
||
|
||
<img src="https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/LinkMapWithCompileOrder.png" style="zoom:45%" />
|
||
|
||
所以是有空间进行操作的,让符号链接顺序按照 App 启动阶段方法执行顺序来进行,这个抓手就是 `Order File`。
|
||
|
||
|
||
|
||
### 有没有办法将 App 启动需要的方法集中收拢?
|
||
|
||
其实二进制重排 Apple 自己本身就在用,查看 `objc4` 源码的时候就发现了身影
|
||
|
||
<img src="https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/Objc-OrderFile.png" style="zoom:40%" >
|
||
|
||
Xcode 使用的链接器为 `ld`。 ld 有个参数 `-order_file` 。order_file 中的符号会按照顺序排列在对应 section 的开始。
|
||
|
||
Xcode 的 Build Setting GUI 面板也支持配置。
|
||
|
||
1. 在 Xcode 的 Build Settings 中设置 **Order File**,Write Link Map Files 设置为 YES(进行观察)
|
||
|
||
2. 如果你给 Xcode 工程根目录下指定一个 order 文件,比如 `refine.order`,则 Xcode 会按照指定的文件顺序进行二进制数据重排。分析 App 启动阶段,将优先需要加载的函数、方法,集中合并,利用 Order File,减小缺页异常,从而减小启动时间。
|
||
|
||
|
||
|
||
### 实践
|
||
|
||
第一步:编写 `.order` 文件。编写顺序是结合业务逻辑和代码顺序,然后再原始的 linkmap 文件中,将符号复制,写入到新创建的 `.order` 文件中。
|
||
|
||
第二步:Build Settings -> Order File 中设置 `.order` 文件的位置。
|
||
|
||
第三步:编译,查看新的 linkmap 文件,验证符号编译顺序是否和 order file 一致。
|
||
|
||
<img src="https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/LinkOrderWithOrderFile.png" style="zoom:30%" >
|
||
|
||
|
||
|
||
|
||
|
||
### 如何拿到启动时刻所调用的所有方法名称
|
||
|
||
二进制的原理很简答。最核心的、最难的就是如何获取到 App 启动阶段的所有方法。可能有 OC、Swift、C/C++ 的方法。
|
||
|
||
fishhook `objc_msgSend` 只可以拿到所有的 OC 符号。那 c/c++、Swift、block 怎么拿到?
|
||
|
||
Clang 插桩,才可以 hook OC、C/C++、block、Swift 全部的方法调用。
|
||
|
||
|
||
|
||
其实难点是如何拿到启动时刻所调用的所用方法?代码可能是 Swift、block、c、OC,所以hook 肯定不行、fishhook 也不行,用 clang 插桩可行。
|
||
|
||
在 [Clang 10 documentation](https://clang.llvm.org/docs/SanitizerCoverage.html#tracing-pcs) 中可以看到 LLVM 官方对 SanitizerCoverage 的详细介绍,包含了示例代码。
|
||
|
||
简单来说 SanitizerCoverage 是 Clang 内置的一个代码覆盖工具。它把一系列以 `__sanitizer_cov_trace_pc_` 为前缀的函数调用插入到用户定义的函数里,借此实现了全局 AOP 的大杀器。其覆盖之广,包含 Swift/Objective-C/C/C++ 等语言,Method/Function/Block 全支持。
|
||
|
||
也可以看[精准测试最佳实践](./1.108.md)这篇文章,查看详细插桩原理。
|
||
|
||
步骤:
|
||
|
||
- 在 Xcode Build Setting 下搜索 “Other C Flags”,在后面添加 `-fsanitize-coverage=trace-pc-guard`。如果观察包含 Swift 代码,还需要在 “Other Swift Flags” 中加入 `-sanitize-coverage=func` 和 `-sanitize=undefined`。所有链接到 App 中的二进制都需要开启 SanitizerCoverage,这样才能完全覆盖到所有调用
|
||
|
||
如果是 Cocoapods 管理。可以脚本处理
|
||
|
||
```ruby
|
||
post_install do |installer|
|
||
installer.pods_project.targets.each do |target|
|
||
target.build_configurations.each do |config|
|
||
config.build_settings['OTHER_CFLAGS'] = '-fsanitize-coverage=func,trace-pc-guard'
|
||
config.build_settings['OTHER_SWIFT_FLAGS'] = '-sanitize-coverage=func -sanitize=undefined'
|
||
end
|
||
end
|
||
end
|
||
```
|
||
|
||
- 在工程入口文件添加2个方法来解决编译报错问题 `__sanitizer_cov_trace_pc_guard_init`、`__sanitizer_cov_trace_pc_guard`
|
||
|
||
- clang 插桩原理就是给每个(oc、c)方法、block 等方法内部第一行添加 hook 代码,来实现 AOP 效果。所以在 `__sanitizer_cov_trace_pc_guard` 内部将函数的名称打印出来,最后可以统一写入 order 文件
|
||
|
||
<img src="https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/ClangCodeCoverageGenerateOrderFile.png" style="zoom:30%" />
|
||
|
||
- 收集 App 启动过程中的函数调用,生成 `.order` 文件
|
||
|
||
做了个封装,假设我们 App 启动完成的重点是 AppDelegate 的 `didFinishLaunchingWithOptions` 方法。在这里一行调用,便可获得 App 启动阶段的 `.order` 文件。
|
||
|
||
<img src="https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/CollectAppLaunchMethod.png" style="zoom:30%" />
|
||
|
||
- 最后修改 Build Setting 中的 "Order File" 配置项,值为 `.order` 文件的路径信息。
|
||
|
||
完整 Demo 可以查看 [BlogDemos:BinarayOrderExplore](https://github.com/FantasticLBP/BlogDemos/tree/master/BinarayOrderExplore)
|
||
|
||
|
||
|
||
## 总结
|
||
|
||
启动优化思路主要是先监控发现具体的启动时间和启动阶段对应的各个任务,有了具体数据,才可以谈优化。
|
||
|
||
- 删除启动项
|
||
|
||
- 如果不能删除,则延迟启动项。启动结束后找合适的时机预热
|
||
|
||
- 不能延迟的可以使用并发,多线程优势
|
||
|
||
- 启动阶段必须的任务,如果不适合并发,则利用技术手段将代码加速
|
||
|
||
## 参考
|
||
|
||
- [# 抖音研发实践:基于二进制文件重排的解决方案 APP启动速度提升超15%](https://mp.weixin.qq.com/s/Drmmx5JtjG3UtTFksL6Q8Q)
|
||
|
||
- [iOS 启动优化+监控实践](https://www.jianshu.com/p/17f00a237284)
|