性能优化——App启动速度优化

启动介绍

启动类型

一般 App 启动分为两种:

  • 冷启动: App 点击启动前,它的进程不在系统里,需要系统新创建一个进程分配给它启动的情况。这是一次完整的启动过程。
  • 热启动:App 在冷启动后用户将 App 退后台,在 App 的进程还在系统里的情况下,用户重新启动进入 App 的过程,这个过程做的事情非常少。

一般所谓的启动速度优化都是针对冷启动。(下面文章提到的启动无特指情况都指的是冷启动。)

优化原则

对于冷启动来说,启动时间是 指从用户点击 APP 那一刻开始到用户看到第一个界面这中间的时间

对于启动时间优化其实就是遵循一个原则: 尽早让用户看到首页内容 。根据这一原则将一些非必须的操作尽量往后移,通常是移到首页显示后执行,同时对于无法往后移的操作,尽可能不占用主线程,主线程尽量只做 UI 操作,将其他操作移到子线程。

启动过程

根据启动时间的定义,可以将 App 的启动分为以下三个阶段:

  1. main() 函数执行前;
  2. main() 函数执行后;
  3. 首屏渲染完成后。

main() 函数执行前

pre-main() 阶段,可以在 Xcode 中 Edit Scheme -> Run -> Argument -> Environment Variables 中,添加 DYLD_PRINT_STATISTICS 环境变量,并将其 Value 设置为 1,则开始在 App 启动时看到这个阶段的具体耗时。

详细过程:

根据 Apple WWDC Optimizing App Startup Time 上的介绍,dyld 的加载主要分为 4 步:

  • 加载 dylibs

这一阶段 dyld 会分析应用依赖的 dylib(大部分是 iOS 系统的),找到其 mach-o 文件,打开和读取这些文件并验证其有效性,接着会找到代码签名注册到内核,最后对 dylib 的每一个 segment 调用 mmap()。

优化方式

  1. 减少动态库加载。每个库本身都有依赖关系,苹果公司建议使用更少的动态库,并且建议在使用动态库的数量较多时,尽量将多个动态库进行合并。数量上,苹果公司最多可以支持 6 个非系统动态库合并为一个。

  2. 懒加载 dylib,但是要注意 dlopen() 可能造成一些问题,且实际上懒加载做的工作会更多。

  3. 减少加载启动后不会去使用的类或者方法。

  • Rebase/Bind

这一阶段系统主要注册 Objc 类。Rebase 在前,Bind 在后,Rebase 做的是将镜像读入内存,修正镜像内部的指针,性能消耗主要在 IO。Bind 做的是查询符号表,设置指向镜像外部的指针,性能消耗主要在 CPU 计算。所以,指针数量越少越好。

优化方式

  1. 减少 ObjC 类(class)、方法(selector)、分类(category)的数量。

  2. 减少 C++ 虚函数的的数量(创建虚函数表有开销)。

  3. 减少 C++ 全局变量的数量。

  • Objc setup

OC 的 runtime 需要维护一张类名与类的方法列表的全局表。
dyld 做了如下操作:

  1. 对所有声明过的 OC 类,将其注册到这个全局表中(class registration)。
  2. 将 category 的方法插入到类的方法列表中(category registration)。
  3. 检查每个 selector 的唯一性(selectoruniquing)。

优化方式

在这一步倒没什么优化可做的,Rebase/Bind 阶段优化好了,这一步的耗时也会减少。

  • Initializers

这一阶段,dyld 开始运行程序的初始化函数,调用每个 Objc 类和分类的 +load 方法,调用 C/C++ 中的构造器函数。initializer 阶段执行完后,dyld 开始调用 main() 函数。

优化方式

  1. 少在类的 +load 方法里做事情,尽量把这些事情推迟到 +initiailize,若必须使用 +load 时,也不要在方法内做耗时操作。

  2. 减少构造器函数 attribute((constructor)) 个数,在构造器函数里少做些事情。

  3. 减少 C++ 全局变量的个数。

做了什么

  • 加载可执行文件(App 的.o 文件的集合);
  • 加载动态链接库,进行 rebase 指针调整和 bind 符号绑定;
  • Objc 运行时的初始处理,包括 Objc 相关类的注册、category 注册、selector 唯一性检查等;
  • 初始化,包括了执行 +load() 方法、attribute((constructor)) 修饰的函数的调用、创建 C++ 静态全局变量。

结论

  1. 动态库加载越多,启动越慢;
  2. ObjC 类越多,启动越慢;
  3. C 的 constructor 函数越多,启动越慢;
  4. C++ 静态对象越多,启动越慢;
  5. ObjC 的 +load 越多,启动越慢。

针对 pre-main 阶段的具体优化方法就是根据自己项目的实际情况,再针对这几点结论一点点的排查优化。

main() 函数执行后

main() 函数执行后的阶段,指的是从 main() 函数执行开始,到 appDelegate 的 didFinishLaunchingWithOptions 方法里首屏渲染相关方法执行完成。

首页的业务代码都是要在这个阶段,也就是首屏渲染前执行的,主要包括了:

  • 首屏初始化所需配置文件的读写操作;
  • 首屏列表大数据的读取;
  • 首屏渲染的大量计算等。

优化方向

main() 函数开始执行后到首屏渲染完成前只处理首屏相关的业务,其他非首屏业务的初始化、监听注册、配置文件读取等都放到首屏渲染完成后去做。

  1. 梳理各个三方库,找到可以延迟加载的库,做延迟加载处理,比如放到首页控制器的 viewDidAppear 方法里或者用到此功能的时候再去加载。
  2. 梳理业务逻辑,把可以延迟执行的逻辑,做延迟执行处理。比如检查新版本、注册推送通知等逻辑。
  3. 复杂的计算(例如 UI 控件的位置信息及 mode 的解析)放到子线程中去处理。
  4. 避免在首页控制器的 viewDidLoad 和 viewWillAppear 做太多事情,部分可以延迟创建的视图应做延迟创建/懒加载处理。
  5. 首页控制器用纯代码方式来构建,xib 及 storyboard 创建的界面第一次加载的时候相对来说要比纯代码加载速度稍慢。
  6. 使用合适的 API,例如 imageWithContentofFile 替代 imageNamed。

首屏渲染时,数据是来自网络,需要考虑到网络连接时的耗时,我们需要在网络数据未接收到时,在首屏展示一些缺省的样式,让用户以最快的速度看到首页的内容(无数据时),当接收到数据时,再根据数据渲染首页即可。

首屏渲染完成后

首屏渲染后的这个阶段,主要完成的是,非首屏其他业务服务模块的初始化、监听的注册、配置文件的读取等。从函数上来看,这个阶段指的就是截止到 didFinishLaunchingWithOptions 方法作用域内执行首屏渲染之后的所有方法执行完成。简单说的话,这个阶段就是从渲染完成时开始,到 didFinishLaunchingWithOptions 方法作用域结束时结束。

这个阶段用户已经能够看到 App 的首页信息了,所以优化的优先级排在最后。但是,那些会卡住主线程的方法还是需要最优先处理的,不然还是会影响到用户后面的交互操作。

优化目标

当用户点击了一个 App 的图标时,iOS 做动画到闪屏图出现的时长是 400ms,所以我们的优化目标应该是:

  • 在 400ms 内完成 main() 函数之前的加载。
  • 整体过程耗时不能超过 20 秒,否则系统会 kill 掉进程,App 启动失败。

优化实践

  1. main()函数执行前

    • 梳理项目及使用到的第三方,删除无用的文件及第三方,将一些简单的三方库删除并自己重写其功能,将一些功能重复的分类进行合并。
    • 梳理各个类的的 +load() 方法,将多个类中的 +load() 方法内容延迟到首屏渲染完成之后执行,或者延迟到 +initialize() 方法内执行。因为,在一个 +load() 方法里,进行运行时方法替换操作会带来 4 毫秒的消耗。不要小看这 4 毫秒,积少成多,执行 +load() 方法对启动速度的影响会越来越大。
  2. main()函数执行后

    • 首页显示内容部分制作缺省图,第一时间展示。
    • 懒加载部分视图。
    • 部分三方库注册延迟到首屏渲染完成。
    • 部分业务延迟执行。比如:新版本检测,首页广告弹窗检测等。

经过上面一些优化操作之后,我相信项目的启动时间应该都会有所减少。

启动时间线上监控方法:在最先加载的 dylib 中加入 start 的埋点代码。

可参考:如何精确度量 iOS App 的启动时间