iOS App冷启动优化

冷启动

定义

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

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

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

main()函数阶段的优化

思路: 在main()函数以后的didFinishLaunchingWithOptions方法里执行了各类业务,有不少业务不是必定要在这里执行,咱们能够延迟加载,防止影响启动时间。程序员

didFinishLaunchingWithOptions方法里咱们通常作一下逻辑:github

  • 初始化第三方sdk
  • 配置App运行须要的环境
  • 本身的一些工具类的初始化 等等

main阶段的优化大体有如下几点:shell

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

上面这些优化点,都是前人们总结出来的,在本身的项目实际优化的过程当中,仍是须要结合业务逻辑来处理。缓存

笔者在实际操做的过程当中,先是经过工具检测出这个过程当中,找出全部方法的耗时时长,而后根据具体的业务逻辑去优化的。性能优化

耗时方法的检测

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

能够经过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%)
复制代码

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

pre-main阶段的原理

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

pre-main

可执行文件的内核流程

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

一、执行exec系统调用,通常都是这样,用fork()函数新创建一个进程,而后让进程去执行exec调用。咱们知道,在fork()创建新进程以后,父进程与子进程共享代码段,但数据空间是分开的,但父进程会把本身数据空间的内容copy到子进程中去,还有上下文也会copy到子进程中去。
二、为了提升效率,采用一种写时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的加载过程主要分为下面几个步骤:

一、Load dylibs image

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

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

针对这一步的优化:

  1. 减小非系统库的依赖
  2. 合并不是系统库

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

二、Rebase/Bind image

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

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

优化该阶段的关键在于减小__DATA segment中的指针数量。咱们能够优化的点有:

  1. 减小Objc类数量, 减小selector数量
  2. 减小C++虚函数数量
三、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

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

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

二、load_images函数做用就是调用Objc的load方法,它监听dyld_image_state_dependents_initialize通知
三、unmap_image能够理解为map_2_images的逆向操做

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

四、initializers

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

一、 Objc的+load()函数
二、 C++的构造函数属性函数 形如attribute((constructor)) void DoSomeInitializationWork()
三、 非基本类型的C++静态全局变量的建立(一般是类或结构体)(non-trivial initializer) 好比一个全局静态结构体的构建,若是在构造函数中有繁重的工做,那么会拖慢启动速度

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

一、 dyld开始将程序二进制文件初始化
二、 交由ImageLoader读取image,其中包含了咱们的类、方法等各类符号
三、 因为runtime向dyld绑定了回调,当image加载到内存后,dyld会通知runtime进行处理
四、 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阶段具体优化

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

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

二、+load方法处理

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

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

三、针对减小没必要要的库

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

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

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

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

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

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

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

五、压缩资源图片

压缩图片为何能加快启动速度呢?由于启动的时候大大小小的图片加载个十来二十个是很正常的,图片小了,IO操做量就小了,启动固然就会快了。

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

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

相关文章
相关标签/搜索