iOS面试

如何实现 iOS App 的冷启动优化

2020-03-09  本文已影响0人  FiTeen

欢迎访问我的博客原文

当 App 中的业务模块越来越多、越来越复杂,集成了更多的三方库,App 启动也会越来越慢,因此我们希望能在业务扩张的同时,保持较优的启动速度,给用户带来良好的使用体验。

热启动与冷启动

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

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

冷启动概要

WWDC 2016 中首次出现了 App 启动优化的话题,其中提到:

冷启动的整个过程是指从用户唤起 App 开始到 AppDelegate 中的 didFinishLaunchingWithOptions 方法执行完毕为止,并以执行 main() 函数的时机为分界点,分为 pre-mainmain() 两个阶段。

也有一种说法是将整个冷启动阶段以主 UI 框架的 viewDidAppear 函数执行完毕才算结束。这两种说法都可以,前者的界定范围是 App 启动和初始化完毕,后者的界定范围是用户视角的启动完毕,也就是首屏已经被加载出来。

注意:这里很多文章都会把第二个阶段描述为 main 函数之后,个人认为这种说法不是很好,容易让人误解。要知道 main 函数在 App 运行过程中是不会退出的,无论是 AppDelegate 中的 didFinishLaunchingWithOptions 方法还是 ViewController 中的viewDidAppear 方法,都还是在 main 函数内部执行的。

pre-main 阶段

pre-main 阶段指的是从用户唤起 App 到 main() 函数执行之前的过程。

查看阶段耗时

我们可以在 Xcode 中配置环境变量 DYLD_PRINT_STATISTICS 为 1(Edit Scheme → Run → Arguments → Environment Variables → +)。

设置环境变量

这时在 iOS 10 以上系统中运行一个 TestDemo,pre-main 阶段的启动时间会在控制台中打印出来。

Total pre-main time: 354.21 milliseconds (100.0%)
         dylib loading time:  25.52 milliseconds (7.2%)
        rebase/binding time:  12.70 milliseconds (3.5%)
            ObjC setup time: 152.74 milliseconds (43.1%)
           initializer time: 163.24 milliseconds (46.0%)
           slowest intializers :
             libSystem.B.dylib :   7.98 milliseconds (2.2%)
   libBacktraceRecording.dylib :  13.53 milliseconds (3.8%)
    libMainThreadChecker.dylib :  41.11 milliseconds (11.6%)
                      TestDemo :  88.76 milliseconds (25.0%)

如果要更详细的信息,就设置 DYLD_PRINT_STATISTICS_DETAILS 为 1。

  total time: 1.6 seconds (100.0%)
  total images loaded:  388 (381 from dyld shared cache)
  total segments mapped: 23, into 413 pages
  total images loading time: 805.78 milliseconds (48.6%)
  total load time in ObjC: 152.74 milliseconds (9.2%)
  total debugger pause time: 780.26 milliseconds (47.1%)
  total dtrace DOF registration time:   0.00 milliseconds (0.0%)
  total rebase fixups:  54,265
  total rebase fixups time:  20.77 milliseconds (1.2%)
  total binding fixups: 527,211
  total binding fixups time: 513.54 milliseconds (31.0%)
  total weak binding fixups time:   0.31 milliseconds (0.0%)
  total redo shared cached bindings time: 521.93 milliseconds (31.5%)
  total bindings lazily fixed up: 0 of 0
  total time in initializers and ObjC +load: 163.24 milliseconds (9.8%)
                         libSystem.B.dylib :   7.98 milliseconds (0.4%)
               libBacktraceRecording.dylib :  13.53 milliseconds (0.8%)
                libMainThreadChecker.dylib :  41.11 milliseconds (2.4%)
              libViewDebuggerSupport.dylib :   6.68 milliseconds (0.4%)
                                  TestDemo :  88.76 milliseconds (5.3%)
total symbol trie searches:    1306942
total symbol table binary searches:    0
total images defining weak symbols:  41
total images using weak symbols:  105

这里统计到的启动耗时出现一定波动是正常的,无须过分在意。

理论知识

为了更准确地了解 App 启动的流程,我们先熟悉一下几个概念。

Mach-O

Mach-O(Mach Object File Format)是一种用于记录可执行文件、对象代码、共享库、动态加载代码和内存转储的文件格式。App 编译生成的二进制可执行文件就是 Mach-O 格式的,iOS 工程所有的类编译后会生成对应的目标文件 .o 文件,而这个可执行文件就是这些 .o 文件的集合。

在 Xcode 的控制台输入以下命令,可以打印出运行时所有加载进应用程序的 Mach-O 文件。

image list -o -f

Mach-O 文件主要由三部分组成:

dylib

dylib 也是一种 Mach-O 格式的文件,后缀名为 .dylib 的文件就是动态库(也叫动态链接库)。动态库是运行时加载的,可以被多个 App 的进程共用。

如果想知道 TestDemo 中依赖的所有动态库,可以通过下面的指令实现:

otool -L /TestDemo.app/TestDemo

动态链接库分为系统 dylib内嵌 dylib(embed dylib,即开发者手动引入的动态库)。系统 dylib 有:

dyld

dyld(Dynamic Link Editor):动态链接器,其本质也是 Mach-O 文件,一个专门用来加载 dylib 文件的库。 dyld 位于 /usr/lib/dyld,可以在 mac 和越狱机中找到。dyld 会将 App 依赖的动态库和 App 文件加载到内存后执行。

dyld shared cache

dyld shared cache 就是动态库共享缓存。当需要加载的动态库非常多时,相互依赖的符号也更多了,为了节省解析处理符号的时间,OS X 和 iOS 上的动态链接器使用了共享缓存。OS X 的共享缓存位于 /private/var/db/dyld/,iOS 的则在 /System/Library/Caches/com.apple.dyld/

当加载一个 Mach-O 文件时,dyld 首先会检查是否存在于共享缓存,存在就直接取出使用。每一个进程都会把这个共享缓存映射到了自己的地址空间中。这种方法大大优化了 OS X 和 iOS 上程序的启动时间。

images

images 在这里不是指图片,而是镜像。每个 App 都是以 images 为单位进行加载的。images 类型包括:

framework

framework 可以是动态库,也是静态库,是一个包含 dylib、bundle 和头文件的文件夹。

启动过程分析与优化

启动一个应用时,系统会通过 fork() 方法来新创建一个进程,然后执行镜像通过 exec() 来替换为另一个可执行程序,然后执行如下操作:

  1. 把可执行文件加载到内存空间,从可执行文件中能够分析出 dyld 的路径;
  2. 把 dyld 加载到内存;
  3. dyld 从可执行文件的依赖开始,递归加载所有的依赖动态链接库 dylib 并进行相应的初始化操作。

结合上面 pre-main 打印的结果,我们可以大致了解整个启动过程如下图所示:

pre-main 阶段启动过程

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个以内。

优化方案

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 计算。

通过以下命令可以查看 rebase 和 bind 等信息:

xcrun dyldinfo -rebase -bind -lazy_bind TestDemo.app/TestDemo

通过 LC_DYLD_INFO_ONLY 可以查看各种信息的偏移量和大小。如果想要更方便直观地查看,推荐使用 MachOView 工具。

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

优化方案

ObjC Setup

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

优化方案

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

Initializers

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

优化方案

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

main() 阶段

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

查看阶段耗时

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

方法一:手动插入代码,进行耗时计算。

// 第一步:在 main() 函数里用变量 MainStartTime 记录当前时间
CFAbsoluteTime MainStartTime;
int main(int argc, char * argv[]) {
    MainStartTime = CFAbsoluteTimeGetCurrent();
    @autoreleasepool {
        return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
    }
}

// 第二步:在 AppDelegate.m 文件中用 extern 声明全局变量 MainStartTime
extern CFAbsoluteTime MainStartTime;

// 第三步:在 didFinishLaunchingWithOptions 方法结束前,再获取一下当前时间,与 MainStartTime 的差值就是 main() 函数阶段的耗时
double mainLaunchTime = (CFAbsoluteTimeGetCurrent() - MainStartTime);
NSLog(@"main() 阶段耗时:%.2fms", mainLaunchTime * 1000);

方法二:借助 Instruments 的 Time Profiler 工具查看耗时。

打开方式为:Xcode → Open Developer Tool → Instruments → Time Profiler

Time Profiler

操作步骤:

  1. 配置 Scheme。点击 Edit Scheme 找到 Profile 下的 Build Configuration,设置为 Debug

  2. 配置 PROJECT。点击 PROJECT,在 Build Settings 中找到 Build Options 选项里的 Debug Information Format,把 Debug 对应的值改为 DWARF with dSYM File

  3. 启动 Time Profiler,点击左上角红色圆形按钮开始检测,然后就可以看到执行代码的完整路径和对应的耗时。

为了方面查看应用程序中实际代码的执行耗时和代码路径实际所在的位置,可以勾选上 Call Tree 中的 Separate ThreadHide System Libraries

查看启动耗时

启动优化

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

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

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

小结

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


参考链接:

WWDC2016: Optimizing App Startup Time
WWDC2017: App Startup Time: Past, Present, and Future
优化 App 的启动时间
今日头条 iOS 客户端启动速度优化
VasSonic 源码

上一篇 下一篇

猜你喜欢

热点阅读