iOS启动优化

前言

随着互联网的高速发展,用户对手机应用的要求越来越高,应用启动时间作为一项重要的参考指标,直接影响着用户的使用体验。QQ阅读App的启动流程包含了大量的业务模块,并且涉及了很多第三方库的初始化,这势必会增加应用的启动时间,因此非常有必要对App的启动进行优化。

Mach-O文件

在优化之前,先来了解下什么是Mach-O文件。UNIX标准制定了一个通用的可移植的二进制格式文件,叫ELF,然而OSX却维护了一个自己独有的二进制格式:Mach-Object(Mach-O)

Mach-O文件类型

对于OSX和iOS来说,Mach-O是其可执行文件的格式,主要包括以下几种文件类型:

  • Executable:应用的主要二进制
  • Dylib:动态链接库
  • Bundle:不能被链接,只能在运行时使用dlopen加载
  • Image:包含Executable、Dylib和Bundle
  • Framework:包含Dylib、资源文件和头文件的文件夹

Mach-O镜像文件格式


Mach-O镜像文件主要包含以下3部分:

  • Mach64 Header
  • Load Commands
  • Section64

看一个真实的可执行文件的格式:
镜像文件格式
几乎所有的Mach-O文件都包含3个段:_TEXT_DATA_LINKEDIT

  • __TEXT 包含 Mach header,被执行的代码和只读常量(如C 字符串)。只读可执行(r-x)。
  • __DATA 包含全局变量,静态变量等。可读写(rw-)。
  • __LINKEDIT 包含了加载程序的『元数据』,比如函数的名称和地址。只读(r–)

通用二进制(胖二进制)

通用二进制格式由多种架构的Mach-O文件合并而成,通过Fat Header来记录不同架构在文件中的偏移量,QQReader的可执行文件就是一个胖二进制:

1
2
3
4
lingyun@sairyouifus-MBP Desktop$ file QQReaderUI
QQReaderUI: Mach-O universal binary with 2 architectures: [arm_v7:Mach-O executable arm_v7] [arm64]
QQReaderUI (for architecture armv7): Mach-O executable arm_v7
QQReaderUI (for architecture arm64): Mach-O 64-bit executable arm64

iOS应用启动流程

可执行文件的内核流程

如图,当启动一个应用程序时,系统最后会根据你的行为调用两个函数,fork和execve。fork功能创建一个进程;execve功能加载和运行程序。这里有多个不同的功能,比如execl,execv和exect,每个功能提供了不同传参和环境变量的方法到程序中。在OSX中,每个这些其他的exec路径最终调用了内核路径execve。

  • 1、执行exec系统调用,一般都是这样,用fork()函数新建立一个进程,然后让进程去执行exec调用。我们知道,在fork()建立新进程之后,父进各与子进程共享代码段,但数据空间是分开的,但父进程会把自己数据空间的内容copy到子进程中去,还有上下文也会copy到子进程中去。

  • 2、为了提高效率,采用一种写时copy的策略,即创建子进程的时候,并不copy父进程的地址空间,父子进程拥有共同的地址空间,只有当子进程需要写入数据时(如向缓冲区写入数据),这时候会复制地址空间,复制缓冲区到子进程中去。从而父子进程拥有独立的地址空间。而对于fork()之后执行exec后,这种策略能够很好的提高效率,如果一开始就copy,那么exec之后,子进程的数据会被放弃,被新的进程所代替

App启动流程的关键节点

根据Apple官方的《WWDC Optimizing App Startup Time》,iOS应用的启动可分为pre-main阶段和main两个阶段,所以App总启动时间 = pre-main耗时 + main耗时

阶段 pre-main main
流程 系统dylib(动态链接库)和自身App可执行文件的加载 main方法执行之后到AppDelegate类中的didFinishLaunchingWithOptions方法执行结束前这段时间,主要是构建第一个界面,并完成渲染展示

盗用两张经典的图:

pre-main

main

QQReader启动的pre-main耗时测量

添加DYLD_PRINT_STATISTICS选项

测量结果

冷启动时间:

热启动时间:

动态链接库dyld

什么是dyld?

动态链接库的加载过程主要由dyld来完成,dyld是苹果的动态链接器
系统先读取App的可执行文件(Mach-O文件),从里面获得dyld的路径,然后加载dyld,dyld去初始化运行环境,开启缓存策略,加载程序相关依赖库(其中也包含我们的可执行文件),并对这些库进行链接,最后调用每个依赖库的初始化方法,在这一步,runtime被初始化。当所有依赖库的初始化后,轮到最后一位(程序可执行文件)进行初始化,在这时runtime会对项目中所有类进行类结构初始化,然后调用所有的load方法。最后dyld返回main函数地址,main函数被调用,我们便来到了熟悉的程序入口。

dyld共享库缓存

当你构建一个真正的程序时,将会链接各种各样的库。它们又会依赖其他一些framework和动态库。需要加载的动态库会非常多。而对于相互依赖的符号就更多了。可能将会有上千个符号需要解析处理,这将花费很长的时间
为了缩短这个处理过程所花费时间,OS X 和 iOS 上的动态链接器使用了共享缓存,OS X的共享缓存位于/private/var/db/dyld/,iOS的则在/System/Library/Caches/com.apple.dyle/
对于每一种架构,操作系统都有一个单独的文件,文件中包含了绝大多数的动态库,这些库都已经链接为一个文件,并且已经处理好了它们之间的符号关系。当加载一个 Mach-O 文件 (一个可执行文件或者一个库) 时,动态链接器首先会检查共享缓存看看是否存在其中,如果存在,那么就直接从共享缓存中拿出来使用。每一个进程都把这个共享缓存映射到了自己的地址空间中。这个方法大大优化了 OS X 和 iOS 上程序的启动时间

dyld加载过程

dyld加载过程主要包含以下几个步骤:

Load dylibs image

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

1.  分析所依赖的动态库
2.  找到动态库的mach-o文件
3.  打开文件
4.  验证文件
5.  在系统核心注册文件签名
6.  对动态库的每一个segment调用mmap()

通常的,一个App需要加载很多个dylibs, 但是其中的系统库被优化,可以很快的加载。应用所依赖的dylib文件可能会再依赖其他 dylib,所以dyld所需要加载的是动态库列表一个递归依赖的集合。

针对这一步骤的优化有:

1.  减少非系统库的依赖
2.  合并非系统库

来看一下QQReader依赖的共享动态库
输入命令:otool -L QQReaderUI

Rebase/Bind image

由于ASLR(address space layout randomization)的存在,可执行文件和动态链接库在虚拟内存中的加载地址每次启动都不固定,所以需要这2步来修复镜像中的资源指针,来指向正确的地址。 rebase修复的是指向当前镜像内部的资源指针; 而bind指向的是镜像外部的资源指针。
rebase步骤先进行,需要把镜像读入内存,并以page为单位进行加密验证,保证不会被篡改,所以这一步的瓶颈在IO。bind在其后进行,由于要查询符号表,来指向跨镜像的资源,加上在rebase阶段,镜像已被读入和加密验证,所以这一步的瓶颈在于CPU计算。
优化该阶段的关键在于减少__DATA segment中的指针数量。我们可以优化的点有:

1.  减少Objc类数量, 减少selector数量
2.  减少C++虚函数数量

这里主要解决几个疑惑:

  • 1、ASLR(地址空间布局随机化)
    1. 传统方式下,进程每次启动采用的都是固定可预见的方式,这意味着一个给定的程序在给定的架构上的进程初始虚拟内存都是基本一致的,而且在进程正常运行的生命周期中,内存中的地址分布具有非常强的可预测性,这给了黑客很大的施展空间(代码注入,重写内存);
    2. 如果采用ASLR,进程每次启动,地址空间都会被简单地随机化,但是只是偏移,不是搅乱。大体布局——程序文本、数据和库是一样的,但是具体的地址都不同了,可以阻挡黑客对地址的猜测 。
  • 2、rebase:针对mach-o在加载到内存中不是固定的首地址 这一现象做数据修正的过程;
  • 3、binding就是将这个二进制调用的外部符号进行绑定的过程。比如我们objc代码中需要使用到NSObject, 即符号_OBJC_CLASS_$_NSObject,但是这个符号又不在我们的二进制中,在系统库 Foundation.framework中,因此就需要binding这个操作将对应关系绑定到一起;
  • 4、lazyBinding就是在加载动态库的时候不会立即binding, 当时当第一次调用这个方法的时候再实施binding。 做到的方法也很简单: 通过dyld_stub_binder这个符号来做。lazyBinding的方法第一次会调用到dyld_stub_binder, 然后dyld_stub_binder负责找到真实的方法,并且将地址bind到桩上,下一次就不用再bind了。

Objc setup

Objc setup主要是在objc_init完成的,objc_init是在libsystem中的一个initialize方法libsystem_initializer中初始化了libdispatch,然后libdispatch_init调用了_os_object_int, 最终调用了_objc_init

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void _objc_init(void)
{
static bool initialized = false;
if (initialized) return;
initialized = true;

environ_init();
tls_init();
static_init();
lock_init();
exception_init();

_dyld_objc_notify_register(&map_2_images, load_images, unmap_image);
}

runtime在_objc_init向dyld绑定了3个回调函数,分别是map_2_images,load_imagesunmap_image

  • 1、dyld在binding操作结束之后,会发出dyld_image_state_bound通知,然后与之绑定的回调函数map_2_images就会被调用,它主要做以下几件事来完成Objc Setup
    1. 读取二进制文件的 DATA 段内容,找到与 objc 相关的信息
    2. 注册 Objc 类
    3. 确保 selector 的唯一性
    4. 读取 protocol 以及 category 的信息
  • 2、load_images函数作用就是调用Objc的load方法,它监听dyld_image_state_dependents_initialize通知
  • 3、unmap_image可以理解为map_2_images的逆向操作

initializers

以上三步属于静态调整,都是在修改__DATA segment中的内容,而这里则开始动态调整,开始在堆和栈中写入内容。 工作主要有:

  • 1、 Objc的+load()函数
  • 2、 C++的构造函数属性函数 形如attribute((constructor)) void DoSomeInitializationWork()
  • 3、 非基本类型的C++静态全局变量的创建(通常是类或结构体)(non-trivial initializer) 比如一个全局静态结构体的构建,如果在构造函数中有繁重的工作,那么会拖慢启动速度

Objc的load函数和C++的静态构造器采用由底向上的方式执行,来保证每个执行的方法,都可以找到所依赖的动态库

  • 1、 dyld开始将程序二进制文件初始化
  • 2、 交由ImageLoader读取image,其中包含了我们的类、方法等各种符号
  • 3、 由于runtime向dyld绑定了回调,当image加载到内存后,dyld会通知runtime进行处理
  • 4、 runtime接手后调用mapimages做解析和处理,接下来loadimages中调用 callloadmethods方法,遍历所有加载进来的Class,按继承层级依次调用Class的+load方法和其 Category的+load方法

整个事件由dyld主导,完成运行环境的初始化后,配合ImageLoader 将二进制文件按格式加载到内存,动态链接依赖库,并由runtime负责加载成objc 定义的结构,所有初始化工作结束后,dyld调用真正的main函数

这里借用下大神的博客解释下静态构造器
constructor 和 +load 都是在 main 函数执行前调用,但 +load 比 constructor 更加早一丢丢,因为 dyld(动态链接器,程序的最初起点)在加载 image(可以理解成 Mach-O 文件)时会先通知 objc runtime 去加载其中所有的类,每加载一个类时,它的 +load 随之调用,全部加载完成后,dyld 才会调用这个 image 中所有的 constructor 方法。
所以 constructor 是一个干坏事的绝佳时机:

  • 所有 Class 都已经加载完成
  • main 函数还未执行
  • 无需像 +load 还得挂载在一个 Class 中

pre-main阶段优化

1、删除无用代码(未被调用的静态变量、类和方法)

可以使用AppCode对工程进行扫描,删除以下无用代码

  • 1、未使用的本地变量;
  • 2、未使用的参数;
  • 3、未使用的值。

2、抽象重复代码

  • 1、在iOS代码中可能会为同一个类写很多分类方法,由于参与开发同学较多,可能会导致方法重复,但是实际上运行起来只能有一个分类的方法被调用,这取决于哪个分类后被加载,然而编译的二进制代码中,两个方法应该是都存在的,这不仅会增加app体积,也会增加启动时间,所以应该杜绝这样的重复问题;
  • 2、有很多地方可能是名字不同,但是函数的功能相同,这个不容易被发现,需要大家在写代码的过程中注意;
  • 3、又或者两个函数名字比较接近,里面有很多相似的代码,这种情况下可以进行相同的代码的提取。

3、+load方法中做的事情延迟到+initialize中,或者在+load中做的事情不宜花费过多时间

因为load是在启动的时候调用,而initialize是在类首次被使用的时候调用,不过当你把load中的逻辑移到initialize中时候,一定要注意initialize的重复调用问题。

4、减少不必要的framework,或者优化已有的framework

例如QQReaderUI linkmap分析,看下那部分文件或者第三方库占了较大的空间,从而给我们优化提供一定的方向

1
2
3
4
5
6
7
8
9
Path: /Users/lingyun/Library/Developer/Xcode/DerivedData/QQReaderUI-ipad-akzqsuuhhnlhdigisyuqwzvuwrfy/Build/Products/Debug-iphoneos/QQReaderUI.app/QQReaderUI
Arch: arm64
Object files:
[0] linker synthesized
[ 1] /Users/lingyun/Library/Developer/Xcode/DerivedData/QQReaderUI-ipad-akzqsuuhhnlhdigisyuqwzvuwrfy/Build/Intermediates/QQReaderUI-ipad.build/Debug-iphoneos/QQReaderUI.build/Objects-normal/arm64/XXXX1.o
[ 2] /Users/lingyun/Library/Developer/Xcode/DerivedData/QQReaderUI-ipad-akzqsuuhhnlhdigisyuqwzvuwrfy/Build/Intermediates/QQReaderUI-ipad.build/Debug-iphoneos/QQReaderUI.build/Objects-normal/arm64/XXXX2.o
[ 3] /Users/lingyun/Library/Developer/Xcode/DerivedData/QQReaderUI-ipad-akzqsuuhhnlhdigisyuqwzvuwrfy/Build/Intermediates/QQReaderUI-ipad.build/Debug-iphoneos/QQReaderUI.build/Objects-normal/arm64/XXXX3.o
[ 4] /Users/lingyun/Library/Developer/Xcode/DerivedData/QQReaderUI-ipad-akzqsuuhhnlhdigisyuqwzvuwrfy/Build/Intermediates/QQReaderUI-ipad.build/Debug-iphoneos/QQReaderUI.build/Objects-normal/arm64/XXXX4.o
...

WMLinkMapAnalyzer分析下linkmap文件
这个文件可以让你了解整个APP编译后的情况,也许从中可以发现一些异常,还可以用这个文件计算静态链接库在项目里占的大小,有时候我们在项目里链了很多第三方库,导致APP体积变大很多,我们想确切知道每个库占用了多大空间,可以给我们优化提供方向。LinkMap里有了每个目标文件每个方法每个数据的占用大小数据,所以只要写个脚本,就可以统计出每个.o最后的大小,属于一个.a静态链接库的.o加起来,就是这个库在APP里占用的空间大小

各模块体积大小,从大到小排列:

1
2
3
4
5
Core1(xxxx1.o)  256.00M
Core2(xxxx2.o) 208.00M
Core3(xxxx3.o) 64.00M
Core4(xxxx4.o) 20.41M
...

然后就可以根据分析结果决定具体优化模块了

main阶段优化

这一个阶段的时间主要是指:main函数开始到第一个界面渲染完成这段时间,优化出发点就是减少从main函数开始到第一个界面出现的时间,可以从两方面入手:

didFinishLaunchingWithOptions

一般情况下,app在didFinishLaunchingWithOptions这个函数中会做以下工作:

  • 1、日志、统计
  • 2、配置 APP 运行需要的环境
  • 3、第三方SDK集成

如果这些工作里面有的可能是不必要的,有的可以采用懒加载的方式,那么久有必要进行优化。QQReader在didFinishLaunchingWithOptions有将近30多个启动模块,其中耗时最多的前6个模块耗时占比将近86%,对此,我们对这主要的6个模块进行逐个分析,比如字体加载模块、打点上报模块等采用懒加载的方式进行优化。

首次启动渲染的页面优化

  • 1、不使用xib或者storyboard,直接使用代码;
  • 2、对于viewDidLoad以及viewWillAppear方法中尽量去尝试少做,晚做,不做,或者采用异步的方式去做;
  • 3、当首页逻辑比较复杂的时候,建议CD冷却放大招:通过instrumentsTime Profiler分析耗时瓶颈。

写代码注意事项

  • 1、在版本迭代过程中,如果业务发生变化,导致相应的代码也发生变化,一般情况下我们需要把对应的旧代码和旧资源删除掉(旧资源会增加App体积,旧代码会增加执行文件的大小,进而增加Objc类数量或者selector数量);
  • 2、尽量抽象重复,时时重构代码,一方面减少执行文件大小,另一方面方便维护。尽可能地复用UI,在添加某个功能时,先去查查我们的代码中是否已经实现了该功能,减少重复。多重构代码,使用继承或者组合等技术减少代码量;
  • 3、在写与启动相关的业务模块时尤其要注意,看哪些逻辑可以延迟加载或者懒加载;
  • 4、类和方法名不要太长:iOS每个类和方法名都在__cstring段里都存了相应的字符串值,所以类和方法名的长短也是对可执行文件大小是有影响的,原因还是object-c的动态特性,因为需要通过类/方法名反射找到这个类/方法进行调用,object-c对象模型会把类/方法名字符串都保存下来。

总结

本文更多地是从优化的方法理论上去阐述整个优化过程,实战的部分由于涉及QQReader的逻辑不便贴图,但是这并不影响我们对启动优化的理解。共勉!

-------------本文结束 感谢您的阅读-------------

本文标题:iOS启动优化

文章作者:lingyun

发布时间:2018年01月30日 - 21:01

最后更新:2018年12月22日 - 18:12

原始链接:https://tsuijunxi.github.io/2018/01/30/iOS启动优化/

许可协议: 署名-非商业性使用-禁止演绎 4.0 国际 转载请保留原文链接及作者。

坚持原创技术分享,您的支持将鼓励我继续创作!

本文标题:iOS启动优化

文章作者:lingyun

发布时间:2018年01月30日 - 21:01

最后更新:2018年12月22日 - 18:12

原始链接:https://tsuijunxi.github.io/2018/01/30/iOS启动优化/

许可协议: 署名-非商业性使用-禁止演绎 4.0 国际 转载请保留原文链接及作者。