mirror of
https://github.com/NohamR/knowledge-kit.git
synced 2026-05-24 20:00:37 +00:00
docs: refine
This commit is contained in:
@@ -1,16 +1,382 @@
|
||||
# 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 函数 for 出一个新的进程,然后通过 `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 只是打包方式,动态、静态库都可以支持。甚至可以存放资源文件。
|
||||
|
||||
|
||||
|
||||
## 二进制重排
|
||||
|
||||
### 虚拟内存、物理内存、内存分页
|
||||
|
||||
早期计算机内部都是物理内存。物理内存一个问题就是安全问题,通过地址访问到别的应用程序的数据。进程如果能直接访问物理内存无疑是很不安全的,(诞生了解决方案:虚拟内存)所以操作系统在物理内存的上又建立了一层虚拟内存。
|
||||
|
||||
一个应用程序在使用的时候并不是全部使用的,往往是使用了一个应用程序的某个或者某几个功能。
|
||||
|
||||
所以早期工程师的第一个解决方案是将一个应用程序分块,按需加载功能模块的内存地址数据。
|
||||
|
||||
虚拟内存是间接访问了内存条。
|
||||
|
||||
内存分页?iOS 一页就是16KB。
|
||||
|
||||
物理内存如果满了,则将最活跃的内存数据,覆盖掉,最不活跃的内存数据;
|
||||
|
||||
ASLR?为了安全问题诞生。
|
||||
|
||||
自己的代码中有 NSLog 代码,可是 NSLog 函数的代码实现在 Foundation 动态库中。
|
||||
|
||||
App 启动则用 dyld 去加载库,共享缓存库。
|
||||
|
||||
虚拟地址:偏移是编译后就能确定的。
|
||||
|
||||
内存缺页异常:在使用中,访问虚拟内存的一个 page 而对应的物理内存缺不存在(没有被加载到物理内存中),则发生缺页异常。影响耗时,在几毫秒之内。
|
||||
|
||||
什么时候发生大量的缺页异常?一个应用程序刚启动的时候。
|
||||
|
||||
启动时所需要的代码分布在第一页、第二页、第三页...第200页。这样的情况下启动时间会影响较大,所以解决思路就是将应用程序启动刻所需要的代码(二进制优化一下),统一放到某几页,这样就可以避免内存缺页异常,则优化了 App 启动时间。
|
||||
|
||||
dylib loading time:
|
||||
|
||||
rebase/binding time: 修正符号是由于 ASLR 导致。binding time 是动态库去 bind lazy table、non-lazy table 所占用的时间。rebase 地址偏移,ASLR。 rebase 的时间如何缩小?Mach-O 文件大小变小。 binding time 变小,则需要动态库变小。2者优化手段冲突
|
||||
|
||||
Objc setup time:Swift 这部分占优势
|
||||
|
||||
initializer time:load 方法耗时。
|
||||
|
||||
slowest intializers:
|
||||
|
||||
libS
|
||||
|
||||
libMain
|
||||
|
||||
查看 LinkMap。发现方法展示顺序是按照,写代码的顺序展示的。
|
||||
|
||||

|
||||
|
||||
### 有没有办法将 App 启动需要的方法集中收拢?
|
||||
|
||||
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 方法。可以实现这个方法去执行一些类特定的行为。
|
||||
> 当一个类或者它的分类被加载到 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 会执行多次。
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user