文章目录
一般情况下,App 的启动分为
冷启动
和
热启动
。
冷启动
是指, App
点击启动前,它的进程不在系统里,需要系统新创建一个进程
分配给它启动的情况。这是一次完整的启动过程。
热启动
是指 ,App
在冷启动
后用户将 App 退后台,在 App 的进程还在系统里的情况下,用户重新启动进入 App 的过程,这个过程做的事情非常少。
main之前
在 main() 函数
执行前,系统主要会做下面几件事情:
1.加载可执行文件(App 的.o
文件的集合)
Mach-O
是针对不同运行时可执行文件的文件类型。对于Fat
文件来说,可以拆分为各个架构的thin
文件,thin
文件再进行解包就是.o
文件,对一个.o
文件进行file
命令我们会看到如下:
prep_cif.o: Mach-O 64-bit object arm64
如果对
Fat
文件,Thin
文件概念比较模糊看 这里
使用MachOView
进行查看,其结构如下
这是一个单个Object
,一般来说我们会直接查看某个架构的.a
文件。
几乎所有 Mach-O
都包含这三个段(segment)
: __TEXT
,__DATA
和 __LINKEDIT
:
-
__TEXT
包含Mach header
,被执行的代码和只读常量(如C 字符串
)。只读可执行(r-x)
。 -
__DATA
包含全局变量,静态变量等。可读写(rw-)
。 -
__LINKEDIT
包含了加载程序的『元数据』
,比如函数的名称和地址。只读(r–)
。
我们可以查看app程序下的二进制文件
Mach-O Universal
文件
FAT 二进制
文件,将多种架构的Mach-O
文件合并而成。它通过Fat Header
来记录不同架构在文件中的偏移量,Fat Header 占一页的空间。
按分页来存储这些segement
和header
会浪费空间,但这有利于虚拟内存
的实现。
2.加载动态链接库,进行 rebase 指针调整和 bind 符号绑定;
下面的步骤构成了 dyld 的时间线:
Load dylibs -> Rebase -> Bind -> ObjC -> Initializers
-
加载
Dylib
从主执行文件的header
获取到需要加载的所依赖动态库列表,而 header 早就被内核映射过。然后它需要找到每个dylib
,然后打开文件读取文件起始位置,确保它是Mach-O
文件。接着会找到代码签名并将其注册到内核。然后在dylib
文件的每个segment
上调用mmap()
。应用所依赖的dylib
文件可能会再依赖其他dylib
,所以 dyld 所需要加载的是动态库列表一个递归依赖的集合。一般应用会加载100
到400
个dylib
文件,但大部分都是系统dylib
,它们会被预先计算和缓存起来,加载速度很快。 -
Fix-ups
在加载所有的动态链接库之后,它们只是处在相互独立的状态,需要将它们绑定起来,这就是Fix-ups
。代码签名使得我们不能修改指令,那样就不能让一个dylib
的调用另一个dylib
。这时需要加很多间接层。现代code-gen
被叫做动态 PIC(Position Independent Code)
,意味着代码可以被加载到间接的地址上。当调用发生时,code-gen
实际上会在__DATA
段中创建一个指向被调用者的指针
,然后加载指针并跳转过去。所以 dyld 做的事情就是修正(fix-up)
指针和数据。
Fix-up
有两种类型,rebasing
和 binding
。
Rebasing
和 Binding
Rebasing
:在镜像内部调整指针的指向
Binding
:将指针指向镜像外部的内容
可以通过MachOView
查看 rebase
和 bind
等信息,下图可以看到我们常用的NSLog
我们使用PIC
的概念和fishhook
就可以进行hook
外部dylib
的函数,例如NSLog
,见 博客
Rebasing
在过去,会把dylib
加载到指定地址,所有指针和数据对于代码来说都是对的,dyld
就无需做任何fix-up
了。如今用了ASLR
后悔将dylib
加载到新的随机地址(actual_address)
,这个随机的地址跟代码和数据指向的旧地址(preferred_address)
会有偏差,dyld
需要修正这个偏差(slide
),做法就是将 dylib 内部的指针地址都加上这个偏移量,偏移量的计算方法如下:
ASLR
(Address Space Layout Randomization):地址空间布局随机化,镜像会在随机的地址上加载。
slide = actual_address - preferred_address
Binding
Binding
是处理那些指向 dylib 外部的指针,它们实际上被符号(symbol)
名称绑定,也就是个字符串。之前提到__LINKEDIT
段中也存储了需要bind
的指针,以及指针需要指向的符号。dyld
需要找到symbol
对应的实现,这需要很多计算,去符号表里查找。找到后会将内容存储到__DATA
段中的那个指针中。Binding
看起来计算量比Rebasing
更大,但其实需要的 I/O 操作很少,因为之前 Rebasing 已经替 Binding 做过了。
3.Objc 运行时的初始处理,包括 Objc 相关类的注册、category 注册、selector 唯一性检查等;
ObjC Runtime
Objective-C
中有很多数据结构都是靠Rebasing
和Binding
来(fix-up)
的,比如 Class 中指向元类的指针
和指向方法的指针
。
ObjC
是个动态语言,可以用类的名字来实例化一个类的对象
。这意味着ObjC Runtime
需要维护一张映射类名与类的全局表
。当加载一个dylib
时,其定义的所有的类
都需要被注册到这个全局表中。
C++
中有个问题叫做易碎的基类(fragile base class)
。ObjC
就没有这个问题,因为会在加载时通过fix-up
动态类中改变实例变量的偏移量。
易碎的基类:如果一个程序员无论何时修改了一个类,无论修改的是公共接口部分还是私有成员的声明部分,他都必须再次编译包含头文件的所有文件,这就是易碎的基类问题。因为其成员的偏移量有改变,而ObjC会进行
fix-up
来修复实例变量的偏移。
在 ObjC
中可以通过定义类别(Category)
的方式改变一个类
的方法。有时你想要添加方法的类在另一个 dylib 中,而不在你的镜像中(也就是对系统或别人的类动刀),这时也需要做些fix-up
。
ObjC
中的 selector
必须是唯一的。
- 去除不必要的类,可以通过
infer
等编译时工具进行检测,去掉不必要的代码,减少库image
的mapped
- 所以减少
category
的数量也是能够一定量上提高app
的运行速度
4.初始化,包括了执行 +load() 方法、attribute((constructor)) 修饰的函数的调用、创建 C++ 静态全局变量。
+load()
会分别执行每个类
和分类
的+load
方法
attribute((constructor))
#include <stdio.h>
#include <stdlib.h>
void static __attribute__((constructor)) before_main()
{
printf("before main\n");
}
void static __attribute__((destructor)) after_main()
{
printf("after main\n");
}
int main(int argc, char** argv)
{
printf("hello world!\n");
}
__attribute__((constructor))
修饰的函数在main
函数之前执行
__attribute__((destructor))
修饰的函数在main
函数之后执行
C++ 静态全局变量
在类加载之前被初始化,限于类中使用。
启动时间优化
Xcode
为我们提供了获取各个dylib
加载时间的方法,在Xcode
中 Edit scheme -> Run -> Auguments
将环境变量 DYLD_PRINT_STATISTICS
设为 1
相应地,这个阶段对于启动速度优化来说,可以做的事情包括:
- 减少动态库加载。每个库本身都有依赖关系,苹果公司建议使用更少的动态库。静态库的加载时间更短,但是当我们的
Extension
和App
需要使用同一部分代码时,我们需要将其封装动态库 (See this blog)。动态库同时也能解决二进制包文件过大的问题,苹果对app包大小判断是不将动态库包大小计算在内的。 check framework
应设为optional
和required
,如果该framework
在当前App
支持的所有iOS
系统版本都存在,那么就设为required
,否则就设为optional
,因为optional
会有些额外的检查;- 减少加载启动后不会去使用的类或者方法。
- 合并或者删减一些
OC
类,关于清理项目中没用到的类,可以借助AppCode
代码检查工具: - 删减一些无用的静态变量
- 删减没有被调用到或者已经废弃的方法
- 尽量不要用
C++
虚函数(创建虚函数表有开销) - 避免使用
attribute((constructor))
,可将要实现的内容放在初始化方法中配合dispatch_once
使用。 +load()
方法里的内容可以放到首屏渲染完成后再执行,或使用+initialize()
方法替换掉。因为,在一个+load()
方法里,进行运行时方法替换操作会带来4 毫秒
的消耗。不要小看这4 毫秒
,积少成多,执行+load()
方法对启动速度的影响会越来越大。- 控制
C++
全局变量的数量。
二进制重排
二进制重排是虚拟内存基于页和段来加载读取的原理,抖音团队对二进制重排有一篇文章进行了讲解 抖音研发实践:基于二进制文件重排的解决方案 APP启动速度提升超15%
参考文章:
https://www.jianshu.com/p/534a37f588f2
https://www.jianshu.com/p/54d842db3f69
https://blog.automatic.com/how-we-cut-our-ios-apps-launch-time-in-half-with-this-one-cool-trick-7aca2011e2ea
就像女人关注你的细节一样😅,细节重要,可不要忘了决定成败的是你本身🙄