iOS App冷启动优化

佐笾· 2020-02-18

冷启动

定义

从用户点击App图标开始到appDelegate didFinishLaunching方法执行完成为止。

分为两个阶段:
T1pre-main阶段,即main()函数之前,即操作系统加载App可执行文件到内存,然后执行一系列的加载&链接等工作,最后执行至App的main()函数;
T2main()函数之后,即从main()开始,到appDelegatedidFinishLaunchingWithOptions方法执行完毕前这段时间,主要是构建第一个界面,并完成渲染。

从用户点击App图标开始到用户能看到App主界面内容为止这个过程,即T1+T2。

main()函数阶段的优化

思路: 在main()函数之后的didFinishLaunchingWithOptions方法里执行了各种业务,有很多业务不是一定要在这里执行,我们可以延迟加载,防止影响启动时间。

didFinishLaunchingWithOptions方法里我们一般做一下逻辑:

  • 初始化第三方sdk
  • 配置App运行需要的环境
  • 自己的一些工具类的初始化 等等

main阶段的优化大致有以下几点:

  • 减少启动初始化的流程,能懒加载的懒加载,能放后台初始化的放后台,能延迟初始化的延迟,不要卡主线程的启动时间;
  • 优化代码逻辑,去除一些非必要的逻辑和代码,减少每个流程所消耗的时间;
  • 启动阶段能使用多线程来进行初始化,就使用多线程;
  • 使用纯代码而不是xib或者storyboard来进行UI框架的搭建,尤其是主UI框架比如TabBarController这种,尽量避免使用xib或者storyboard,因为它们也还是要解析成代码才去渲染页面,多了一些步骤;

上面这些优化点,都是前人们总结出来的,在自己的项目实际优化的过程中,还是需要结合业务逻辑来处理。

笔者在实际操作的过程中,先是通过工具检测出这个过程中,找出所有方法的耗时时长,然后根据具体的业务逻辑去优化的。

耗时方法的检测

其实这阶段的优化很明显,只要我们找出耗时操作,然后对其进行相应的分析做处理,该延迟调用的延迟,该懒加载的懒加载,便能缩短启动时间。

可以通过instrumentTime profile工具来分析耗时。如下是我实践的过程。
首先对Xcode进行配置:
步骤一

步骤二

步骤三: 对项目进行command + shit + k清除操作,然后command + R运行,最后进行instrument工具的唤起,利用快捷键command + i即可。 选择Time Profiler,点击Choose即可。

为了能够更加直观的观察,我们可以进行下面的配置

然后点击左上角的红色圆圈便可进行耗时检测,如下图:

从上面能够非常直观的看到每个方法以及对应的耗时,从这里,我们便能够找到哪些是我们所要优化的。

首页的页面渲染

如果你在上一步的检测过程中,发现TabBarControllerviewDidLoad耗时较长,那么就要进行下面的检测。

首页的viewDidLoad以及viewWillAppear方法中尽量去尝试少做,晚做,或者采取异步的方式去做。

如下一段代码:

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    NSLog(@"didFinishLaunchingWithOptions 开始执行");
    self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
    ISTabBarController *tabBarVc = [[ISTabBarController alloc]init];
    self.window.rootViewController = tabBarVc;
    [self.window makeKeyAndVisible];
    NSLog(@"didFinishLaunchingWithOptions 跑完了");
    return YES;
}

然后来到ISTabBarControllerviewDidLoad方法里进行他的viewControllers的设置,然后再进入到每个viewControllerviewDidLoad方法里进行更多的初始化操作。

2020-02-17 11:17:17.024481+0800 InnotechShop[3776:1477241] didFinishLaunchingWithOptions 开始执行
2020-02-17 11:17:17.034835+0800 InnotechShop[3776:1477241] 开始加载 ISTabBarController 的 viewDidLoad
2020-02-17 11:17:17.034934+0800 InnotechShop[3776:1477241] didFinishLaunchingWithOptions 跑完了
2020-02-17 11:17:17.034965+0800 InnotechShop[3776:1477241] 开始加载 ISViewController 的 viewDidLoad, 然后执行一堆初始化的操作

这种情况是能保证我们不在ISTabBarController中操作ISViewControllerview, 如果我们在ISTabBarController中操作ISViewControllerview的话,那么调用顺序将会是下面这样:

2020-02-17 11:23:42.018824+0800 InnotechShop[3796:1480231] didFinishLaunchingWithOptions 开始执行
2020-02-17 11:23:42.018883+0800 InnotechShop[3796:1480231] 开始加载 ISTabBarController 的 viewDidLoad
2020-02-17 11:23:42.018957+0800 InnotechShop[3796:1480231] 开始加载 ISViewController 的 viewDidLoad, 然后执行一堆初始化的操作
2020-02-17 11:23:42.019020+0800 InnotechShop[3796:1480231] didFinishLaunchingWithOptions 跑完了

这样的话,我们就把界面的初始化、网络请求、数据解析、视图渲染等操作都放在了viewDidLoad方法里,那么每次启动App的时候,在用户看到第一个页面之前,我们都要把这些事情全部处理完成,才会进入到视图渲染阶段。

由于笔者项目的业务逻辑并不是那么的复杂,所以在实践中大概做了一下几点:

  • 把一些没有必要在didFinishLaunchingWithOptions进行初始化的操作,延迟到首页渲染完成以后调用
  • 友盟的分享服务,没有必要在启动的时候去初始化,初始化任务丢到异步线程解决,大概节省几百毫秒;
  • 主UI框架tabBarControllerviewDidLoad函数里,去掉一些不必要的函数调用;

优化前后耗时对比:

小结

对于didFinishLaunchingWithOptions,这里面的初始化是必须执行的,但是我们可以适当的根据功能的不同对应的适当延迟启动的时机。对于我们项目,我将初始化分为三个类型:

  • 日志、统计等必须在 APP 一启动就最先配置的事件
  • 项目配置、环境配置、用户信息的初始化 、推送、IM等事件
  • 其他 SDK 和配置事件

对于第一类,由于这类事件的特殊性,所以必须第一时间启动,仍然把它留在didFinishLaunchingWithOptions 里启动。第二类事件,这些功能在用户进入APP主体的之前是必须要加载完的,所以我们可以把它放在第二批,也就是用户已经看到广告页面,再进行广告倒计时的时候再启动。第三类事件,由于不是必须的,所以我们可以放在第一个界面渲染完成以后的viewDidAppear方法里,这里完全不会影响到启动时间。

闪屏优化

现在许多App在启动时并不直接进入首页,而是会向用户展示一个持续一小段时间的闪屏页,如果使用恰当,这个闪屏页就能帮我们节省一些启动时间。 下面看两组闪屏的流程对比即可发现好处:
未优化的闪屏流程:

优化的闪屏流程: 具体可以参考这里

pre-main 阶段优化

以下为iPhone 7p正常启动消耗的pre-main时间(苹果提供了内建的测量方法,在 Xcode 中 Edit scheme -> Run -> Auguments -> Environment Variables点击+添加环境变量 DYLD_PRINT_STATISTICS 设为 1):

Total pre-main time: 608.72 milliseconds (100.0%)
         dylib loading time: 308.40 milliseconds (50.6%)
        rebase/binding time:  28.92 milliseconds (4.7%)
            ObjC setup time:  22.50 milliseconds (3.6%)
           initializer time: 248.89 milliseconds (40.8%)
           slowest intializers :
             libSystem.B.dylib :   3.75 milliseconds (0.6%)
    libMainThreadChecker.dylib :  31.74 milliseconds (5.2%)
          libglInterpose.dylib : 135.63 milliseconds (22.2%)
                  HelpDeskLite :  21.11 milliseconds (3.4%)
                     InnoAVKit :  20.81 milliseconds (3.4%)
                  InnotechShop :  29.62 milliseconds (4.8%)

解读:
1、main()函数之前总共用时608.72ms
2、在608.72ms中,加载动态库使用了308.4ms,指针重定位用了28.92ms,ObjC类初始化使用了22.50ms,各种初始化使用了248.89ms
3、在初始化用时的248.89ms中,用时较多的几个初始化是libglInterpose.dylib、ibMainThreadChecker.dylib、InnotechShop、InnoAVKit等

pre-main阶段的原理

main()函数之前,基本上所有的工作都是系统完成的,开发者能够处理的地方不多,所以想要对这部分进行优化,那么就需要了解一下这一过程系统都做了哪些事情,(原理部分的内容基本上都是网上摘录的)。 这部分比较晦涩难懂,需要细品

pre-main

可执行文件的内核流程

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

1、执行exec系统调用,一般都是这样,用fork()函数新建立一个进程,然后让进程去执行exec调用。我们知道,在fork()建立新进程之后,父进程与子进程共享代码段,但数据空间是分开的,但父进程会把自己数据空间的内容copy到子进程中去,还有上下文也会copy到子进程中去。
2、为了提高效率,采用一种写时copy的策略,即创建子进程的时候,并不copy父进程的地址空间,父子进程拥有共同的地址空间,只有当子进程需要写入数据时(如向缓冲区写入数据),这时候会复制地址空间,复制缓冲区到子进程中去。从而父子进程拥有独立的地址空间。而对于fork()之后执行exec后,这种策略能够很好的提高效率,如果一开始就copy,那么exec之后,子进程的数据会被放弃,被新的进程所代替

动态链接库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的加载过程主要分为下面几个步骤:

1、Load dylibs image

在每个动态库的加载过程中,dyld需要做下面工作:

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

针对这一步的优化:

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

看下笔者项目依赖的共享动态库
输入命令:otool -L XXXX

2、Rebase/Bind image

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

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

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

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

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

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_initdyld绑定了3个回调函数,分别是map_2_images,load_images和unmap_image

1、dyld在binding操作结束之后,会发出dyld_image_state_bound通知,然后与之绑定的回调函数map_2_images就会被调用,它主要做以下几件事来完成Objc Setup

  • 读取二进制文件的 DATA 段内容,找到与 objc 相关的信息
  • 注册 Objc 类
  • 确保 selector 的唯一性
  • 读取 protocol 以及 category 的信息

2、load_images函数作用就是调用Objc的load方法,它监听dyld_image_state_dependents_initialize通知
3、unmap_image可以理解为map_2_images的逆向操作

由于之前2步骤的优化,这一步实际上没有什么可做的。几乎都靠 Rebasing 和 Binding 步骤中减少所需 fix-up 内容。因为前面的工作也会使得这步耗时减少。

4、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接手后调用map images做解析和处理,接下来load images中调用 callloadmethods方法,遍历所有加载进来的Class,按继承层级依次调用Class+load方法和其 Category+load方法

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

这一步可做的优化有:

  • 使用+initialize来代替+load
  • 不要使用atribute((constructor)) 将方法显式标记为初始化器,而是让初始化方法调用时才执行。比如使用 dispatch_once()、pthread_once() 或 std::once()。也就是在第一次使用时才初始化,推迟了一部分工作耗时。也尽量不要用到C++的静态对象。

pre-main阶段具体优化

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

  • 可以使用AppCode对工程进行扫描,删除无用代码
  • 删减一些无用的静态变量
  • 删减没有被调用或者已经废弃的方法

2、+load方法处理

+load()方法,用于在App启动执行一些操作,+load()方法在Initializers阶段被执行,但过多的+load()方法则会拖慢启动速度。 分析+load()方法,看是否可以延迟到App冷启动后的某个时间节点。

笔者在处理这个问题的过程中遇到一个坑,项目里有防crash的类,里面有大量的系统类的load方法,针对系统的load方法,我们不用去优化,因为在启动的过程中,有可能initialize方法也会被调用,并起不到优化的作用,反而还是出现各种各样的问题;另外一点需要注意的问题是initialize的重复调用问题,能用dispatch_once()来完成的,就尽量不要用到load方法

3、针对减少不必要的库

统计了各个库所占的size(安装包size优化的脚本),基本上一个公共库越大,类越多,启动时在pre-main阶段所需的时间也越多。 统计结果如下:

pod有源码的库(静态库):

第三方framework(其实也是静态库,只是脚本分开统计):

笔者项目中使用cocoapods并没有设置use_frameworks,所以pod管理的有源码的第三方库都是静态库的形式,而framework形式的静态库基本都是第三方公司提供的服务

这个过程并没有做任何优化,对库进行了逐一排查,均为正在使用,顾这个环节没有优化,只是记录了一下。

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

由于Category的实现原理,和ObjC的动态绑定有很强的关系,所以实际上类的扩展是比较占用启动时间的。尽量合并一些扩展,会对启动有一定的优化作用。不过个人认为也不能因为它占用启动时间而去逃避使用扩展,毕竟程序员的时间比CPU的时间值钱,这里只是强调要合并一些在工程、架构上没有太大意义的扩展。

5、压缩资源图片

压缩图片为什么能加快启动速度呢?因为启动的时候大大小小的图片加载个十来二十个是很正常的,图片小了,IO操作量就小了,启动当然就会快了。

以上内容就是本次在做启动时间优化所涉及的内容,理论知识都是从网上查询得知,具体实践,笔者都一一尝试,作为记录。

参考资料:
美团外卖iOS App冷启动治理
iOS启动优化-凌云的博客
iOS启动时间优化-第七章
iOS启动时间优化-PerTerbin
iOS App 启动性能优化