实现 iOS App 的冷启动优化

773 阅读7分钟

热启动与冷启动

当用户按下 home 键,iOS App 不会立刻被 kill,而是存活一段时间,这段时间里用户再打开 App,App 基本上不需要做什么,就能还原到退到后台前的状态。我们把 App 进程还在系统中,无需开启新进程的启动过程称为热启动。\

冷启动则是指 App 不在系统进程中,比如设备重启后,或是手动杀死 App 进程,又或是 App 长时间未打开过,用户再点击启动 App 的过程,这时需要创建一个新进程分配给 App。我们可以将冷启动看作一次完整的 App 启动过程,本文讨论的就是冷启动的优化。

整个启动过程如下图所示

3047084-9cebc4207ea12fa2.jpg

Load Dylibs

这一步,指的是动态库加载。在此阶段,dyld 会:

  1. 分析 App 依赖的所有 dylib;
  2. 找到 dylib 对应的 Mach-O 文件;
  3. 打开、读取这些 Mach-O 文件,并验证其有效性;
  4. 在系统内核中注册代码签名;
  5. 对 dylib 的每一个 segment 调用 mmap()

一般情况下,iOS App 需要加载 100-400 个 dylibs。这些动态库包括系统的,也包括开发者手动引入的。其中大部分 dylib 都是系统库,系统已经做了优化,因此开发者更应关心自己手动集成的内嵌 dylib,加载它们时性能开销较大。

App 中依赖的 dylib 越少越好,Apple 官方建议尽量将内嵌 dylib 的个数维持在6个以内。

优化方案

  • 尽量不使用内嵌 dylib;
  • 合并已有内嵌 dylib;
  • 检查 framework 的 optionalrequired 设置,如果 framework 在当前的 App 支持的 iOS 系统版本中都存在,就设为 required,因为设为 optional 会有额外的检查;
  • 使用静态库作为代替;(不过静态库会在编译期被打进可执行文件,造成可执行文件体积增大,两者各有利弊,开发者自行权衡。)
  • 懒加载 dylib。(但使用 dlopen() 对性能会产生影响,因为 App 启动时是原本是单线程运行,系统会取消加锁,但 dlopen() 开启了多线程,系统不得不加锁,这样不仅会使性能降低,可能还会造成死锁及未知的后果,不是很推荐这种做法。)

Rebase/Binding

这一步,做的是指针重定位

在 dylib 的加载过程中,系统为了安全考虑,引入了 ASLR(Address Space Layout Randomization)技术和代码签名。由于 ASLR 的存在,镜像会在新的随机地址(actual_address)上加载,和之前指针指向的地址(preferred_address)会有一个偏差(slide,slide=actual_address-preferred_address),因此 dyld 需要修正这个偏差,指向正确的地址。具体通过这两步实现:

第一步:Rebase,在 image 内部调整指针的指向。将 image 读入内存,并以 page 为单位进行加密验证,保证不会被篡改,性能消耗主要在 IO。

第二步:Binding,符号绑定。将指针指向 image 外部的内容。查询符号表,设置指向镜像外部的指针,性能消耗主要在 CPU 计算。 通过 LC_DYLD_INFO_ONLY 可以查看各种信息的偏移量和大小。如果想要更方便直观地查看,推荐使用 MachOView 工具。

指针数量越少,指针修复的耗时也就越少。所以,优化该阶段的关键就是减少 __DATA 段中的指针数量。

优化方案

  • 减少 ObjC 类(class)、方法(selector)、分类(category)的数量,比如合并一些功能,删除无效的类、方法和分类等(可以借助 AppCode 的 Inspect Code 功能进行代码瘦身);
  • 减少 C++ 虚函数;(虚函数会创建 vtable,这也会在 __DATA 段中创建结构。)
  • 多用 Swift Structs。(因为 Swift Structs 是静态分发的,它的结构内部做了优化,符号数量更少。)

ObjC Setup

完成 Rebase 和 Bind 之后,通知 runtime 去做一些代码运行时需要做的事情:

  • dyld 会注册所有声明过的 ObjC 类;
  • 将分类插入到类的方法列表中;
  • 检查每个 selector 的唯一性。

优化方案

Rebase/Binding 阶段优化好了,这一步的耗时也会相应减少。

Initializers

Rebase 和 Binding 属于静态调整(fix-up),修改的是 __DATA 段中的内容,而这里则开始动态调整,往堆和栈中写入内容。具体工作有:

  • 调用每个 Objc 类和分类中的 +load 方法;
  • 调用 C/C++ 中的构造器函数(用 attribute((constructor)) 修饰的函数);
  • 创建非基本类型的 C++ 静态全局变量。

优化方案

  • 尽量避免在类的 +load 方法中初始化,可以推迟到 +initiailize 中进行;(因为在一个 +load 方法中进行运行时方法替换操作会带来 4ms 的消耗)
  • 避免使用 __atribute__((constructor)) 将方法显式标记为初始化器,而是让初始化方法调用时再执行。比如用 dispatch_once()pthread_once()std::once(),相当于在第一次使用时才初始化,推迟了一部分工作耗时。:
  • 减少非基本类型的 C++ 静态全局变量的个数。(因为这类全局变量通常是类或者结构体,如果在构造函数中有繁重的工作,就会拖慢启动速度)

总结一下 pre-main 阶段可行的优化方案:

  • 重新梳理架构,减少不必要的内置动态库数量
  • 进行代码瘦身,合并或删除无效的ObjC类、Category、方法、C++ 静态全局变量等
  • 将不必须在 +load 方法中执行的任务延迟到 +initialize
  • 减少 C++ 虚函数

main() 阶段

对于 main() 阶段,主要测量的就是从 main() 函数开始执行到 didFinishLaunchingWithOptions 方法执行结束的耗时。

查看阶段耗时

这里介绍两种查看 main() 阶段耗时的方法。

方法一:手动插入代码,进行耗时计算。
方法二:借助 Instruments 的 Time Profiler 工具查看耗时。
打开方式为:Xcode → Open Developer Tool → Instruments → Time Profiler

启动优化

main() 被调用之后,didFinishLaunchingWithOptions 阶段,App 会进行必要的初始化操作,而 viewDidAppear 执行结束之前则是做了首页内容的加载和显示。

关于 App 的初始化,除了统计、日志这种须要在 App 一启动就配置的事件,有一些配置也可以考虑延迟加载。如果你在 didFinishLaunchingWithOptions 中同时也涉及到了首屏的加载,那么可以考虑从这些角度优化:

  • 用纯代码的方式,而不是 xib/Storyboard,来加载首页视图
  • 延迟暂时不需要的二方/三方库加载;
  • 延迟执行部分业务逻辑和 UI 配置;
  • 延迟加载/懒加载部分视图;
  • 避免首屏加载时大量的本地/网络数据读取;
  • 在 release 包中移除 NSLog 打印;
  • 在视觉可接受的范围内,压缩页面中的图片大小;
  • ……

如果首屏为 H5 页面,针对它的优化,参考 VasSonic 的原理,可以从这几个角度入手:

终端耗时

webView 预加载:在 App 启动时期预先加载了一次 webView,通过创建空的 webView,预先启动 Web 线程,完成一些全局性的初始化工作,对二次创建 webView 能有数百毫秒的提升。

页面耗时(静态页面)

静态直出:服务端拉取数据后通过 Node.js 进行渲染,生成包含首屏数据的 HTML 文件,发布到 CDN 上,webView 直接从 CDN 上获取; 离线预推:使用离线包。

页面耗时(经常需要动态更新的页面)

并行加载:WebView 的打开和资源的请求并行;
动态缓存:动态页面缓存在客户端,用户下次打开的时候先打开缓存页面,然后再刷新;
动静分离:将页面分为静态模板和动态数据,根据不同的启动场景进行不同的刷新方案;
预加载:提前拉取需要的增量更新数据。

小结

随着业务的增长,App 中的模块越来越多,冷启动的时间也必不可少地增加。冷启动本就是一个比较复杂的流程,它的优化没有固定的公式,我们需要结合业务,配合一些性能分析工具和线上监控日志,有耐心、多维度地进行分析和解决。