iOS App 启动性能优化

应用启动时间,直接影响用户对一款应用的判断和使用体验。ZAKER新闻本身就包含非常多并且复杂度高的业务模块(如新闻、视频等),也接入了很多第三方的插件,这势必会拖慢应用的启动时间,本着精益求精的态度和对用户体验的追求,我们希望在业务扩张的同时最大程度的优化启动时间。

启动时间

总时间 = T1 + T2

T1

加载系统dylib可执行文件的时间。

T2

mainapplicationWillFinishLaunching结束的时间。

App启动过程

App启动过程

1)解析Info.plist

  • 加载相关信息,例如如闪屏
  • 沙箱建立、权限检查

2)Mach-O加载

  • 如果是胖二进制文件,寻找合适当前CPU类别的部分
  • 加载所有依赖的Mach-O文件(递归调用Mach-O加载的方法)
  • 定位内部、外部指针引用,例如字符串、函数等
  • 执行声明为__attribute__((constructor))的C函数
  • 加载类扩展(Category)中的方法
  • C++静态对象加载、调用ObjC的 +load 函数

3)程序执行

  • 调用main()
  • 调用UIApplicationMain()
  • 调用applicationWillFinishLaunching

Mach-O

Mach-O 是针对不同运行时可执行文件的文件类型。

Mach-O

文件类型:

Executable: 应用的主要二进制

Dylib: 动态链接库(又称 DSO 或 DLL)

Bundle: 不能被链接的 Dylib,只能在运行时使用 dlopen() 加载,可当做 macOS 的插件。

Image: executable,dylib 或 bundle

Framework: 包含 Dylib 以及资源文件和头文件的文件夹

Mach-O 镜像文件

Mach-O 被划分成一些 segement,每个 segement 又被划分成一些 section。

segment 的名字都是大写的,且空间大小为页的整数。页的大小跟硬件有关,在 arm64 架构一页是 16KB,其余为 4KB。

section 虽然没有整数倍页大小的限制,但是 section 之间不会有重叠。

几乎所有 Mach-O 都包含这三个段(segment): __TEXT,__DATA__LINKEDIT

  • __TEXT 包含 Mach header,被执行的代码和只读常量(如C 字符串)。只读可执行(r-x)。

  • __DATA 包含全局变量,静态变量等。可读写(rw-)。

  • __LINKEDIT 包含了加载程序的『元数据』,比如函数的名称和地址。只读(r–)。

Mach-O Universal 文件

FAT 二进制文件,将多种架构的 Mach-O 文件合并而成。它通过 Fat Header 来记录不同架构在文件中的偏移量,Fat Header 占一页的空间。

按分页来存储这些 segement 和 header 会浪费空间,但这有利于虚拟内存的实现。

什么是image

1.executable可执行文件 比如.o文件。

2.dylib 动态链接库 framework就是动态链接库和相应资源包含在一起的一个文件夹结构。

3.bundle 资源文件 只能用dlopen加载,不推荐使用这种方式加载。

除了我们App本身的可行性文件,系统中所有的framework比如UIKit、Foundation等都是以动态链接库的方式集成进App中的。

什么是ImageLoader

image 表示一个二进制文件(可执行文件或 so 文件),里面是被编译过的符号、代码等,所以 ImageLoader 作用是将这些文件加载进内存,且每一个文件对应一个ImageLoader实例来负责加载。

两步走:在程序运行时它先将动态链接的 image 递归加载 (也就是上面测试栈中一串的递归调用的时刻)。 再从可执行文件 image 递归加载所有符号。

冷启动和热启动

冷启动

应用首次启动。即后台线程中未有当前打开的应用,所有的资源都需要加载并初始化。

热启动

应用非首次启动。即后台线程中保留有当前应用,应用的资源在内存中有保存。

启动时间分析

1)开启时间分析功能

在Xcode的菜单中选择ProjectSchemeEdit Scheme...,然后找到 RunEnvironment Variables+,添加nameDYLD_PRINT_STATISTICSvalue1的环境变量。

开启时间分析功能

启动时间

load dylibs image

在每个动态库的加载过程中, dyld需要:

1.分析所依赖的动态库

2.找到动态库的mach-o文件

3.打开文件

4.验证文件

5.在系统核心注册文件签名

6.对动态库的每一个segment调用mmap()

通常的,一个App需要加载100到400个dylibs, 但是其中的系统库被优化,可以很快的加载。 针对这一步骤的优化有:

1.减少非系统库的依赖

2.合并非系统库

3.使用静态资源,比如把代码加入主程序

rebase/bind

由于ASLR(address space layout randomization)的存在,可执行文件和动态链接库在虚拟内存中的加载地址每次启动都不固定,所以需要这2步来修复镜像中的资源指针,来指向正确的地址。 rebase修复的是指向当前镜像内部的资源指针; 而bind指向的是镜像外部的资源指针。

rebase步骤先进行,需要把镜像读入内存,并以page为单位进行加密验证,保证不会被篡改,所以这一步的瓶颈在IO。bind在其后进行,由于要查询符号表,来指向跨镜像的资源,加上在rebase阶段,镜像已被读入和加密验证,所以这一步的瓶颈在于CPU计算。

优化该阶段的关键在于减少__DATA segment中的指针数量。我们可以优化的点有:

1.减少Objc类数量, 减少selector数量

2.减少C++虚函数数量

3.转而使用swift stuct(其实本质上就是为了减少符号的数量)

解读

  • main()函数之前总共使用了506.48ms
  • 在506.48ms中,加载动态库用了46.35ms,指针重定位使用了137.72ms,ObjC类初始化使用了95.39ms,各种初始化使用了226.92ms。
  • 在初始化耗费的226.92ms中,用时最多的几个初始化是libSystem.B.dyliblibBacktraceRecording.dyliblibglInterpose.dylib以及libMTLInterpose.dylib

2)使用instruments工作分析具体时间消耗点

耗时的影响因素

1) main()函数之前耗时的影响因素

  • 动态库加载越多,启动越慢。
  • ObjC类越多,启动越慢
  • C的constructor函数越多,启动越慢
  • C++静态对象越多,启动越慢
  • ObjC的+load越多,启动越慢

实验证明,在ObjC类的数目一样多的情况下,需要加载的动态库越多,App启动就越慢。同样的,在动态库一样多的情况下,ObjC的类越多,App的启动也越慢。需要加载的动态库从1个上升到10个的时候,用户几乎感知不到任何分别,但从10个上升到100个的时候就会变得十分明显。同理,100个类和1000个类,可能也很难查察觉得出,但1000个类和10000个类的分别就开始明显起来。

同样的,尽量不要写__attribute__((constructor))的C函数,也尽量不要用到C++的静态对象;至于ObjC的+load方法,似乎大家已经习惯不用它了。任何情况下,能用dispatch_once()来完成的,就尽量不要用到以上的方法。

2) main()函数之后耗时的影响因素

main()函数开始至applicationWillFinishLaunching结束,我们统一称为main()函数之后的部分。

  • 执行main()函数的耗时
  • 执行applicationWillFinishLaunching的耗时
  • rootViewController及其childViewController的加载、view及其subviews的加载

实践

移除不需要用到的类

为了解决这个历史问题,我使用了一个叫做fui(Find Unused Imports)的开源项目,它能很好的分析出不再使用的类,准确率非常高,唯一的问题是它处理不了动态库和静态库里提供的类,也处理不了C++的类模板。

使用方法是在Terminalcd到项目所在的目录,然后执行fui find,然后等上那么几分钟(是的你没有看错,真的需要好几分钟甚至需要更长的时间),就可以得到一个列表了。由于这个工具还不是100%靠谱,可根据这个列表,在Xcode中手动检查并删除不再用到的类。

合并功能类似的类和扩展(Category)

优化application:didFinishLaunchingWithOptions:方法

优化rootViewController加载

问题

1)NSUserDefaults是否是瓶颈

2)还有其他哪些点可以做优化

参考文档:《优化 App 的启动时间》