mirror of
https://github.com/NohamR/knowledge-kit.git
synced 2026-05-24 20:00:37 +00:00
400 lines
19 KiB
Markdown
400 lines
19 KiB
Markdown
# App 启动时间优化与二进制重排
|
||
|
||
## 启动分类
|
||
|
||
- 冷启动(Cold Launch):点击 App 图标启动前,进程不在系统中。需要系统新创建一个进程并加载 Mach-O 文件,dyld 从 Mach-O 头信息中读取依赖(undefined的动态库库),从动态库共享缓存中读取并链接,经历一次完整的启动过程。
|
||
- 热启动(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 所调用
|
||
|
||
### 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
|
||
|
||
- 减少动态库加载,合并一些动态库。(定期清理不必要的动态库,iOS 规定开发者写的动态库不能超过6个)
|
||
|
||
#### 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 初始化遵循规范
|
||
- 任务启动器
|
||
- 二进制重排
|
||
- 方法耗时统计(time profiler、os_signpost)
|
||
|
||
AppDelegate 中 `application:didFinishLaunchingWithOptions` 函数阶段一般有很多业务代码介入,大多数启动时间问题都是在此阶段造成的。
|
||
|
||
- 比如二方、三方 SDK 的注册、初始化。这其中有些 SDK 比如 APM 是有必要放在很早的阶段。有些则不需要,比如日志 SDK 的初始化
|
||
|
||
### 第三阶段
|
||
|
||
很多时候,需要梳理出那些功能是首屏渲染所需要的初始化功能,那些是非首页需要的功能,按照业务场景梳理并治理。
|
||
|
||
QA:
|
||
|
||
静态库、动态库?
|
||
|
||
静态库:.o文件集合。静态库编译、链接后就不存在了,变为可执行文件了
|
||
|
||
动态库:一个已经链接完全的镜像。已经被静态链接过
|
||
|
||
动态库不可以变为静态库。静态库可以变为动态库。
|
||
|
||
静态库缺点:产物体积比较大,影响包大小(大)。链接到 App 之后,App 体积会比较小(??)静态库 strip
|
||
|
||
动态库缺点:除了系统动态库之外,没有真正意义上的动态库(不会放到系统的共享缓冲区)
|
||
|
||
适用场景:
|
||
|
||
静态库不影响启动时间、动态库代码保密性好。
|
||
|
||
Framework 只是打包方式,动态、静态库都可以支持。甚至可以存放资源文件。
|
||
|
||
## 二进制重排
|
||
|
||
### 虚拟内存、物理内存、内存分页
|
||
|
||
应用程序的可执行文件是存储在磁盘上的,启动应用,则需要将可执行文件加载进内存,早期计算机是没有虚拟内存的,一旦加载就会全部加载到内存中,并且进程都是按照顺序排列的,这样存在两个问题:
|
||
|
||
- 上一个进程只需要加一些地址就能访问到下一个进程,安全性很低
|
||
|
||
- 当前软件越来越大,开启多个软件时会直接全部加载到内存中,导致内存不够用。而且使用软件的时候大多数情况只使用部分功能。如果软件一打开就全部加载到内存中,会造成内存的严重浪费。
|
||
|
||
基于上述2个问题,诞生了虚拟内存技术。
|
||
|
||
|
||
|
||
### 内存缺页异常
|
||
|
||
每个进程在创建加载时,会被分配一个大小大概为1~2倍真实地内存的连续虚拟地址空间,让当前软件认为自己拥有一块很大内存空间。实际上是把磁盘的一小部分作为假想内存来使用。
|
||
|
||
CPU 不直接和物理内存打交道,而是通过 MMU(Memory Manage Unit,内存管理单元),MMU 是一种硬件电路,速度很快,主要工作是内存管理,地址转换是功能之一。
|
||
|
||
每个进程都会有自己的页表 `Page Table` ,页表存储了进程中虚拟地址到物理地址的映射关系,所以就相当于地图。MMU 收到 CPU 的虚拟地址之后就开始查询页表,确定是否存在映射以及读写权限是否正常。
|
||
|
||
iOS 程序在进行加载时,会根据一 page 大小16kb 将程序分割为多页,启动时部分的页加载进真实内存,部分页还在磁盘中,中间的调度记录在一张表(Page Table),这个表用来调度磁盘和内存两者之间的数据交换。
|
||
|
||

|
||
|
||
如上图,App 运行时执行某个任务时,会先访问虚拟页表,如果页表的标记为1,则说明该页面数据已经存在于内存中,可以直接访问。如果页表为0,则说明数据未在物理内存中,这时候系统会阻塞进程,叫做缺页中断(page fault),进程会从用户态切换到内核态,并将缺页中断交给内核的 page Fault Handler 处理。等将对应的 page 从磁盘加载到内存之后再进行访问,这个过程叫做 page in。
|
||
|
||
因为磁盘访问速度较慢,所以 page in 比较还是,而且 iOS 不仅仅是将数据加载到内存中,还要多这页做签名认证,所以 iOS 耗时更长
|
||
|
||
等到程序运行时用到了才去内存中寻找虚拟地址对应的页帧,找不到才进行分配,这就是内存的惰性(延时)分配机制。
|
||
|
||
|
||
|
||

|
||
|
||
|
||
|
||
为了提高效率和方便管理,对虚拟内存和物理内存进行分页(Page)管。进程在访问虚拟内存的一个 page 而对应的物理内存却不存在(没有被加载到物理内存中),则会触发一次缺页异常(缺页中断),然后分配物理内存,有需要的话会从磁盘 mmap 读入数据。
|
||
|
||
|
||
|
||
启动时所需要的代码分布在 VM 的第一页、第二页、第三页...,这样的情况下启动时间会影响较大,所以解决思路就是将应用程序启动刻所需要的代码(二进制优化一下),统一放到某几页,这样就可以避免内存缺页异常,则优化了 App 启动时间。
|
||
|
||
二进制重排提升 App 启动速度是通过「解决内存缺页异常」(内存缺页会有几毫秒的耗时)来提速的。
|
||
|
||
一个 App 发生大量「内存缺页」的时机就是 App 刚启动的时候。所以优化手段就是「将影响 App 启动的方法集中处理,放到某一页或者某几页」(虚拟内存中的页)。Xcode 工程允许开发者指定 「Order File」,可以「按照文件中的方法顺序去加载」,可以查看 linkMap 文件(需要在 Xcode 中的 「Buiild Settings」中设置 Order File、Write Link Map Files 参数)。
|
||
|
||
|
||
|
||
### 如何获取启动阶段的 Page Fault 次数
|
||
|
||
Instrucments 中的 System Trace 可以查看详细信息。
|
||
|
||
### 如何验证重排是否成功
|
||
|
||
查看 LinkMap。发现方法展示顺序是按照,写代码的顺序展示的。
|
||
|
||

|
||
|
||
### 有没有办法将 App 启动需要的方法集中收拢?
|
||
|
||
其实二进制重排 Apple 自己本身就在用,查看 `objc4` 源码的时候就发现了身影
|
||
|
||

|
||
|
||
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,减小缺页异常,从而减小启动时间。
|
||
|
||
### 如何拿到启动时刻所调用的所有方法名称
|
||
|
||
clang 插桩,才可以 hook OC、C、block、Swift 全部。LLVM 官方文档。
|
||
|
||
二进制重排提升 App 启动速度是通过「解决内存缺页异常」(内存缺页会有几毫秒的耗时)来提速的。
|
||
|
||
一个 App 发生大量「内存缺页」的时机就是 App 刚启动的时候。所以优化手段就是「将影响 App 启动的方法集中处理,放到某一页或者某几页」(虚拟内存中的页)。Xcode 工程允许开发者指定 「Order File」,可以「按照文件中的方法顺序去加载」,可以查看 linkMap 文件(需要在 Xcode 中的 「Buiild Settings」中设置 Order File、Write Link Map Files 参数)。
|
||
|
||
其实难点是如何拿到启动时刻所调用的所用方法?代码可能是 Swift、block、c、OC,所以hook 肯定不行、fishhook 也不行,用 clang 插桩可行
|
||
|
||
- https://mp.weixin.qq.com/s/SUHaGD1T2Vce4Ag-qgxtgg
|
||
|
||
在看 App 启动时间优化之前先看2个方法: **load** 和 **initialize**。
|
||
|
||
load
|
||
|
||
> Invoked whenever a class or category is added to the Objective-C runtime; implement this method to perform class-specific behavior upon loading.
|
||
> 当一个类或者它的分类被加载到 Objective-c 的 runtime 中的时候会出发 load 方法。可以实现这个方法去执行一些类特定的行为。
|
||
|
||
initialize
|
||
|
||
> Initializes the class before it receives its first message.
|
||
> 当一个类接收到第一条消息的时候会初始化。
|
||
|
||
load 方法会在类被加载到 runtime 的时候调用。且父类的 load 方法比子类先执行。load 方法只会执行1次。
|
||
initialize 方法会在第一次收到消息的时候调用。父类的 initialize 方法比子类先执行。假如有 Person 类,还有一个子类 children。子类第一次收到消息的时候会先调用父类的 initialize,然后调用子类的 initialize,如果子类没有实现 initialize 那么父类的 initialize 会执行多次。
|
||
|
||
总结:
|
||
|
||
启动优化思路主要是先监控发现具体的启动时间和启动阶段对应的各个任务,有了具体数据,才可以谈优化。
|
||
|
||
- 删除启动项
|
||
|
||
- 如果不能删除,则延迟启动项。启动结束后找合适的时机预热
|
||
|
||
- 不能延迟的可以使用并发,多线程优势
|
||
|
||
- 启动阶段必须的任务,如果不适合并发,则利用技术手段将代码加速
|
||
|
||
|
||
|
||
## 参考
|
||
|
||
- [# 抖音研发实践:基于二进制文件重排的解决方案 APP启动速度提升超15%](https://mp.weixin.qq.com/s/Drmmx5JtjG3UtTFksL6Q8Q)
|
||
|
||
- [iOS 启动优化+监控实践](https://www.jianshu.com/p/17f00a237284)
|