IOS App 启动优化
技术调研
启动时间计算公式
App总启动时间 = t1(main()之前的加载时间) + t2(main()之后的加载时间)。
t1 = 系统dylib(动态链接库)和自身App可执行文件的加载;
t2 = main方法执行之后到AppDelegate类中的- (BOOL)Application:(UIApplication *)Application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions方法执行结束前这段时间,主要是构建第一个界面,并完成渲染展示。
启动流程
main()调用之前加载过程
exec() 是一个系统调用。系统内核把应用映射到新的地址空间,且每次起始位置都是随机的(因为使用了 ASLR)。并将起始位置到 0x000000 这段范围的进程权限都标记为不可读写不可执行。如果是 32 位进程,这个范围至少是 4KB;对于 64 位进程则至少是 4GB。NULL 指针引用和指针截断错误都会被它捕获。
dylib 加载
从主执行文件的 header 中获取到需要加载的依赖动态库列表,而 header 早已经被内核映射。接下来需要找到每个 dylib,然后打开文件读取文件起始位置,确保它是 Mach-O 文件。接着会找到代码签名并将其注册到内核。然后对 dylib 文件的每个 segment 调用 mmap()。应用可能还会依赖于其他 dylib,因此 dyld 需要加载的是一个包含递归依赖的 dylib 列表。一般应用会加载 100 到 400 个 dylib 文件,但大部分都是系统 dylib,它们会被预先计算和缓存起来,加载速度很快。
重定位/绑定
由于 ASLR(地址空间布局随机化)的存在,可执行文件和动态链接库在虚拟内存中的加载地址每次启动都不固定,因此需要以下两步来修复镜像中的资源指针,使其指向正确的地址。重定位操作修复的是指向当前镜像内部的资源指针;而绑定操作指向的是镜像外部的资源指针。重定位操作先进行,需要将镜像读入内存,并以页面为单位进行加密验证,确保不会被篡改,因此这一步的瓶颈在于 IO。绑定操作随后进行,由于需要查询符号表,指向跨镜像的资源,并且由于在重定位阶段,镜像已被读入和加密验证,因此这一步的瓶颈在于 CPU 计算。可以通过命令行查看相关的资源指针
xcrun dyldinfo -rebase -bind -lazy_bind myApp.App/myApp
优化该阶段的关键在于减少 __DATA segment 中的指针数量。我们可以优化的点有:
- 减少 Objc 类数量,减少 selector 数量
- 减少 C++ 虚函数数量
- 转而使用 Swift struct(实际上本质上就是为了减少符号的数量)
Objc 运行时
这一步的主要工作包括:
- 注册 Objc 类 (类注册)
- 将 category 的定义插入方法列表 (category 注册)
- 确保每个 selector 的唯一性 (selector 去重)
由于之前的优化步骤,实际上这一步操作没有太多可优化的空间。
初始化器
以上三步属于静态调整(修复),都是在修改 __DATA segment 的内容,而这里则开始动态调整,开始在堆和栈中写入内容。这里的工作包括:
- Objc 的 +load() 函数,使用 +initialize 来替代 +load
- C++ 的构造函数属性函数,形式如 attribute((constructor)) void DoSomeInitializationWork()
- 非基本类型的 C++ 静态全局变量的创建(通常是类或结构体)(非平凡初始化)例如一个全局静态结构体的构建,如果在构造函数中有繁重的工作,那么会减慢启动速度
Objc 的 load 函数和 C++ 的静态构造函数采取自下而上的执行方式,以保证每个执行的方法都可以找到所依赖的动态库。
main() 调用之后的加载时间
在 main() 被调用之后,App 的主要工作是初始化必要的服务,显示首页内容等。我们的优化也是围绕如何快速展示首页来展开。App通常在 AppDelegate 类中的 - (BOOL)Application:(UIApplication *)Application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions 方法中创建首页需要展示的 view,然后在当前 runloop 的末尾,主动调用 CA::Transaction::commit 完成视图的渲染。视图的渲染主要涉及三个阶段:
准备阶段 这里主要是图片的解码
布局阶段 首页所有 UIView 的 - (void)layoutSubViews() 运行
绘制阶段 首页所有 UIView 的 - (void)drawRect:(CGRect)rect 运行
再加上启动之后必要服务的启动、必要数据的创建和读取,这些就是我们可以尝试优化的地方
因此,在 main() 函数调用之前我们可以优化的点有:
- 不使用 xib,直接用代码加载首页视图。
- NSUserDefaults 实际上是在 Library 文件夹下会生成一个 plist 文件,如果文件太大的话,一次性读入内存可能会很耗时,这个影响需要评估,如果耗时很大的话,需要拆分(需考虑老版本覆盖安装兼容问题)。
- 每次使用 NSLog 方式打印会隐式地创建一个 Calendar,仅针对内测版输出 log。
- 梳理应用启动时发送的所有网络请求,统一在异步线程请求。
- 并行初始化各个业务。
### 优化犯案
main()调用之前,加载过程优化内容
- 减少框架引用
- 删除无用类,无用函数
- 减少+load 函数使用
main()调用之后,优化内容
#### 思路
- 将需要执行的处理放入不同的block内,并发到不同的queue中进行。
- 提供串行队列,执行有依赖的逻辑
- 提供group,对于彼此依赖不明确但需要整体执行完成后进行处理的业务,提供dispatch_group功能满足需求。
- 对于需要 在MainThread执行的 业务,提供mainThread 支持。
提供四个type选项执行启动block
- WTAppLauncherType_WTGroupQueue 自定义group
- WTAppLauncherType_MainThread 主线程async 执行 block
- WTAppLauncherType_GlobalQueue global queue 执行block
- WTAppLauncherType_SerialQueue sync 执行 block
WTAppLauncher Code
typedef NS_ENUM(NSUInteger, WTAppLauncherType) {
WTAppLauncherType_WTGroupQueue,
WTAppLauncherType_MainThread,
WTAppLauncherType_GlobalQueue,
WTAppLauncherType_SerialQueue // 串行队列,放入有执行顺序的block
};
- (void)addLauncherWithType:(WTAppLauncherType )type block:(dispatch_block_t) block;
/**
add Group Queue notification
添加group notification 监听group 之前的block 执行完成。
如果有业务需要依赖之前的block 执行完, 可以调用这个api 进行处理。
@param block run block
*/
- (void)addNotificationGroupQueue:(dispatch_block_t) block;
/**
结束初始化调用函数,必须被调用,确保之前加入的block,在didFinishLaunching函数结束前,全部被执行完。
*/
- (void)endLanuchingWithTimeout:(float)timeout;