APP启动优化

APP启动是从哪里开始的?

app的启动并非是从main函数开始的,在mian函数之前系统会做很多准备工作,包括创建内存空间和进程,加载可执行文件和动态库等。如果需要提升APP的打开速度,可以从这方面入手

有人说是从main函数开始的,因为main函数是程序的代理AppDelegate的创建入口,自然也是整个应用的入口,其实从宏观角度上来说是这样的,但认真起来却不是这样的

首先创建一个工程,然后在ViewController.m 中添加一个方法。

+ (void)load {
    NSLog(@"load");
}

然后再main函数的地方,也就是main.m中,顺便也加一个NSLog:

int main(int argc, char * argv[]) {
    NSString * appDelegateClassName;
    @autoreleasepool {
        
        NSLog(@"main");
    }
    return UIApplicationMain(argc, argv, nil, appDelegateClassName);
}

然后运行,可以看到打印结果

 load
 main

不是说main函数是程序的入口吗?其实并不是这样的,在main函数调用之前,系统还有很多事情需要处理。

然后现在在ViewControlle的+ (void)load方法中打上断点,然后重新运行,
当工程执行到断点处时,可以在调试栏看到当前主线程的方法调用列表。

在这里插入图片描述

调用顺序

dyld 启动之后,通过调用 load_images 函数来调用所有实现了的+load方法,

那么dyld是什么?load_images 函数是否是dyld调用的呢?

dyld 是动态连接器

动态链接器的作用是什么?

当用户单击一个APP,在未真正打开之前,系统会做好一系列事务,其中动态连接器会做一些重要的事情。

  1. 首先需要系统的内核为该应用创建一个内存空间,并为此创建一个进程,因此我们经常会说一个应用就是一个进程。
  2. 然后加载解析执行文件,也就是将我们的应用的可执行镜像文件赋值到内存并根据其文件类型选择不同的加载函数,一般来说iOS的APP,我们的文件是mach-O 类型。因此选择加载的方式是通过exec_mach_imgact 来做处理, exec_mach_imgact 会分析mach-O 文件的头信息结构以及imgp等内容然后将其映射到内存中。
  3. 接着会调用load_images ()方法,该函数会调用mach-O中的各项加载各种load commands 命令,我们在编译期经常会遇到load command of found 这样的问题,这二者是相关的,只有会在编译时确定一系列的load command,才能通过函数load_image函数完成加载;紧接着会调用parse_machfile() 方法对加载的命令进行扫描和解析。
  4. 在上面一步,根据mach_loder.c 源码,如果对load commands 扫描三次没有问题后,就会执行load_dylinker(),准备启动动态连接器。
  5. 如果之前load commands 执行成功后,表示已经解析完mach-O文件,我们会得到其二进制的执行入口,但我们的线程还不会立即进入该入口,因为还要通过load_dylinker()加载动态链接器,通过这种方式加载应用的主程序,将load commands中指定的dyld 已静态的方式存放到二进制文件中;load_dylinker(方法中,动态连接器会保存二进制文件的执行入口,并递归调用parse_machfile()方法,之后才设置线程的入口为dyld的入口点;动态连接器的dyld完成加载库的工作后,在将入口点设置回二进制文件的入口点,执行到_dyld_start()
  6. 动态连接器开始工作后,首先创建ImageLoader 实例,且是一个二进制文件对应一个ImageLoader ,负责将二进制文件加载到内存中,包括文件中编译过的符号和代码,其过程是以递归的方式。
  7. 当dyld 加载完二进制文件和所有符号之后(符号包含所有的 class ,Protocol,Selector,IMP等),便会对系统进行初始化操作,其中系统库包含最重要的一个部分就是runtime,系统库的初始化启用了runtime,因此便对runtime的初始化。初始化时通过_objc_init() 方法来实现,调用了_objc_init() 方法,包括call_load_methods ,调用各类的load方法。
  8. main方法调用
    在这里插入图片描述

或许我们不经意单击了一个app ,在1s左右的启动时间内系统竟然做了这么多的事情。除此之外app的启动还包括代码的签名认证,虚存映射,触屏应用加载器等一系列的事务。

mach-O 的文件形式和工作方式

Mach-o文件主要三部分组成
1、header
2、loadcommands
3、data数据区
结构图如下:
在这里插入图片描述

Mach-o header

在这里插入图片描述
相关字段含义如下

magic 魔数,用于类型判断
cputype cpu 类型
cpusubtype 机器标示符
filetype 文件类型
ncmds loadcommands的数量
sizeofcmds loadcommands的总大小
flags 动态连接器标志
reserved 保留

load Command 加载命令:

在mach_header之后加载命令,这些加载命令在Mach-o 文件加载解析时,被内核加载器或者动态连接器调用,指导如何设置对应的二进制数据段,

Mach-o load commands数据结构
在这里插入图片描述

data原始段数据:

它是mach-O文件最大的一部分,包含 load Command所需要的数据以及虚存地址偏移量和大小;一般mach-O文件有多个段,每个段不同的功能

常见的segment有以下几个
1、__TEXT 代码段

2、__PAGEZERO 空指针陷阱

3、DATA 数据段

4、__LINKEDIT 包含需要被动态连接器使用的信息,包括符号表、字符串表、重定位项表等。

启动类型

App 的启动类型分为三类

冷启动

Cold Launch 也就是冷启动,冷启动需要满足以下几个条件:

  • 重启之后
  • App 不在内存中
  • 没有相关的进程存在

热启动

Warm Launch 也就是热启动,热启动需要满足以下几个条件:

  • App 刚被终止
  • App 还没完全从内存中移除
  • 没有相关的进程存在

被挂起的 App

Resume Launch 指的是被挂起的 App 继续的过程,需要满足以下几个条件:

  • App 被挂起
  • App 还全部都在内存中
  • 还存在相关的进程

App 启动阶段

App 启动分为三个阶段

  • 初始化 App 的准备工作
  • 绘制第一帧 App
    的准备工作及绘制(这里的第一帧并不是获取到数据之后的第一帧,可以是一张占位视图),这时候用户与App已经可以交互了,比如 tabbar
    切换
  • 获取到页面的所有数据之后的完整的绘制第一帧页面

在这个地方,苹果再次强调了一下,建议「用户从点击 App 图标到可以再次交互,也就是第二阶段结束」的时间最好在 400ms 以内。目前来看,大部分 App 都没有达到这个目标。

下面,我们把上面三个阶段分成下面这 6 个部分,讲一下这几个阶段做了什么以及有什么可以优化的地方。

请添加图片描述

System Interface

初始化 App 的准备工作,系统主要做了两个事情:Load dylibs 和 libSystem init

在 2017 年苹果介绍过 dyld3 给系统 App 带来了多少优化,今年 dyld3 正式开发给开发者使用,这意味着 iOS 系统会将你热启动的运行时依赖给缓存起来。以达到减少启动时间的目的。这也就是提升 200% 的原因之一。

视频中只说优化了热启动时间,理论上对于 iOS 系统来说 dyld3 应该还可以优化冷启动时间,所以不知道是因为给 iPad 增加了多任务功能的原因,还是没有把所有功能开放的原因,作者只提了热启动这个原因暂时还不太清楚。

除此之外,在 Load dylibs 阶段,开发者还可以做以下优化:

  • 避免链接无用的 frameworks,在 Xcode 中检查一下项目中的「Linked Frameworks and
    Librares」部分是否有无用的链接。
  • 避免在启动时加载动态库,将项目的 Pods 以静态编译的方式打包,尤其是 Swift 项目,这地方的时间损耗是很大的。
  • 硬链接你的依赖项,这里做了缓存优化。

也许有人会困惑是不是使用了 dyld3 了,我们就不需要做 Static Link 了,其实还是需要的,感兴趣的可以看一下 Static linking vs dyld3 这篇文章,里面有一个详细的数据对比。

libSystem init 部分,主要是加载一些优先级比较低的系统组件,这部分时间是一个固定的成本,所以我们开发人员不需要关心。

Static Runtime Initializaiton

这个阶段主要是 Objective-C 和 Swift Runtime 的初始化,会调用所有的 +load 方法,将类的信息注册到 runtime 中

在这个阶段,原则上不建议开发者做任何事情,所以为了避免一些启动时间的损耗,你可以做以下几个事情:

  • 在 framework 开发时,公开专有的初始化 API
  • 减少在 +load 中做的事情
  • 使用 initialize 进行懒加载初始化工作

UIKit Initializaiton

这个阶段主要做了两个事情:

  • 实例化 UIApplication 和 UIApplicationDelegate
  • 开始事件处理和系统集成

所以这个阶段的优化也比较简单,你需要做两个事情:

  • 最大限度的减少 UIApplication 子类初始化时候的工作,更甚至与不子类化 UIApplication
  • 减少 UIApplicationDelegate 的初始化工作

Application Initializaiton

这个阶段主要是生命周期方法的回调,也正是开发者最熟悉的部分。

调用 UIApplicationDelegate 的 App 生命周期方法:

application:willFinishLaunchingWithOptions:
application:didFinishLaunchingWithOptions:

和 UIApplicationDelegate 的 UI 生命周期方法:

applicationDidBecomeActive:

同时,iOS 13 针对 UISceneDelegate 增加了新的回调:

scene:willConnectToSession:options: sceneWillEnterForeground:
sceneDidBecomeActive:

在这个阶段,开发者可以做的优化:

  • 推迟和启动时无关的工作
  • Senens 之间共享资源

Fisrt Frame Render

这个阶段主要做了创建、布局和绘制视图的工作,并把准备好的第一帧提交给渲染层渲染。会频繁调用以下几个函数:

loadView
viewDidLoad
layoutSubviews

在这个阶段,开发者可以做的优化:

  • 减少视图层级,懒加载一些不需要的视图
  • 优化布局,减少约束

Extend

  • 大部分 App 都会通过异步的方式获取数据,并最终呈现给用户。我们把这一部分称为 Extend。
  • 因为这一部分每个 App 的表现都不一样,所以苹果建议开发者使用 os_signpost 进行测量然后慢慢分析慢慢优化。

iOS启动速度优化

iOS应用的启动可分为pre-main阶段和main()阶段,其中系统做的事情依次是:

pre-main阶段优化

测量方法

对于pre-main阶段,Apple提供了一种测量方法,在 Xcode 中 Edit scheme -> Run -> Auguments 将环境变量DYLD_PRINT_STATISTICS 设为1 :
在这里插入图片描述

pre-main阶段启动耗时测量.png

设置好后把程序跑起来,控制台会有如下输出,pre-main阶段各过程的耗时一览无余(用时1.1秒)

在这里插入图片描述

pre-main阶段分析

1. Load dylibs(动态库的耗时)

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

一般情况下,iOS应用会加载100-400个dylibs,其中大部分是系统库,这部分dylib的加载系统已经做了优化。所以,依赖的dylib越少越好。在这一步,我们可以做的优化有:**

Load dylibs阶段优化方案:

1、减少动态库、合并一些动态库(定期清理不必要的动态库)
2、从效率来说尽可能的使用系统的动态库,苹果已经做好了优化
3、官方给的建议最多不要超过6个

2. Rebase/Bind**(Rebase == 偏移修正/ Bind == 符号绑定)
Rebase(偏移修正)

每个应用的二进制文件,我们所有方法、函数调用,编译时在二进制文件中都有他们的偏移地址。以函数func01()为例,假定函数地址为0x0001;
运行时,地址将会发生改变,ASLR会所及生成一个值,插入在二进制文件的开头。假定生成的随机值是0x1000,那么此时,func01()的地址就是:0x1000 + 0x0001 = 0x1001,即方法的真实地址!

总结:

地址偏移值 + 随机值 = 运行时刻的真实地址

即:

ASLR + 文件本地的偏移值 = 修正值,此过程的耗时 --> 偏移修正耗时。

Bind(符号绑定)

绑定耗时。
以NSLog为例,NSLog的地址我们是无法直接知道的,它在Foundation框架中 - 属于外部动态库。

*编译时NSLog的真实地址是拿不到的,so,在MachO文件中会创建一个NSLog的符号(它存在在 MachO文件的数据段中),此时指向一个随机 或 固定的无意义的值。

*运行时会进行符号绑定binding,将符号所指向的地址关联为真正的NSLog的地址。
–> 此关联过程即绑定。在内存中进行绑定的.

MachO文件本身是在磁盘中的,运行时从磁盘加载到内存中,从磁盘到内存的过程像一个copy,就叫做image镜像。即一个可执行文件从磁盘加载到内存便是一个镜像文件,而当镜像文件加载到内存后会进行绑定。此处理由 dyld进行。
简单来说,此过程:dyld加载进程时,根据NSLog的符号所依赖的库以及要使用的是库中的NSLog,找到所在库及库中NSLog的所在地址,然后进行绑定。即:给符号绑定真正地址的过程。

总结

在dylib的加载过程中,系统为了安全考虑,引入了ASLR(Address Space Layout Randomization)技术和代码签名。由于ASLR的存在,镜像(Image,包括可执行文件、dylib和bundle)会在随机的地址上加载,和之前指针指向的地址(preferred_address)会有一个偏差(slide),dyld需要修正这个偏差,来指向正确的地址。

Rebase在前,Bind在后,Rebase做的是将镜像读入内存,修正镜像内部的指针,性能消耗主要在IO。Bind做的是查询符号表,设置指向镜像外部的指针,性能消耗主要在CPU计算。

Rebase/Bind阶段优化的方案:

1.减少ObjC类(class)、方法(selector)、分类(category)的数量
2.减少C++虚函数的的数量(创建虚函数表有开销)
3.使用Swift structs(内部做了优化,符号数量更少)

3. Objc setup

大部分ObjC初始化工作已经在Rebase/Bind阶段做完了,这一步dyld会注册所有声明过的ObjC类,将分类插入到类的方法列表里,再检查每个selector的唯一性。

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

4. Initializers

到了这一阶段,dyld开始运行程序的初始化函数,调用每个Objc类和分类的+load方法,调用C/C++ 中的构造器函数(用attribute((constructor))修饰的函数),和创建非基本类型的C++静态全局变量。Initializers阶段执行完后,dyld开始调用main()函数。

Initializers阶段优化方案:

1.少在类的+load方法里做事情,尽量把这些事情推迟到+initiailize
2.减少构造器函数个数,在构造器函数里少做些事情
3.减少C++静态全局变量的个数

项目中实际优化方式

1.排查无用的dylib,移除不再使用的libicucore.tbd
2. 删除无用文件&库,合并重复文件(多个重复的分类)。移除不再使用的库UMSocial、PSTCollectionView、MCSwipeTableViewCell,移除功能重复的库Mantle
3.梳理各个类的+load方法,将多个类中+load方法做的事延迟到+initiailize里去做。
4.官方建议自定义的动态库最多六个,动态库的合并。
5、减少Objc类、分类的数量,减少Selector数量(定期清理不必要的类个分类)

优化后图片

在这里插入图片描述

main()阶段的优化

测量方法

对于main()阶段,主要是测量main()函数开始执行到didFinishLaunchingWithOptions执行结束的耗时,就需要自己插入代码到工程中了。先在main()函数里用变量StartTime记录当前时间:

CFAbsoluteTime StartTime;
int main(int argc, char * argv[]) {
      StartTime = CFAbsoluteTimeGetCurrent();
再在AppDelegate.m文件中用extern声明全局变量StartTime

```objectivec
	
extern CFAbsoluteTime StartTime;

最后在didFinishLaunchingWithOptions里,再获取一下当前时间,与StartTime的差值即是main()阶段运行耗时。

double launchTime = (CFAbsoluteTimeGetCurrent() - StartTime);

项目中实际优化方式

1.去掉其中100ms的dispatch_after…检查代码发现之前会故意让启动图多显示100ms,不知道是什么逻辑…
2.将多个二方/三方库延迟加载。包括TBCrashReporter、TBAccsSDK、UT、TRemoteDebugger、ATSDK等。
3.将若干系统UI配置、业务逻辑延迟执行。包括注册推送、检查新版本、更新Orange配置等。
4.避免多余的计算。之前会前后两次获取是否要显示广告图,每次获取都需要反序列化Orange中的配置信息,再比较配置中的开始/结束时间,大约耗时20ms。目前的解决方案是第一次计算后,用一个BOOL属性缓存起来,下次直接取用。
5.延迟加载&懒加载部分视图。快捷密码验证页是启动图消失后用户看到的第一个页面,这个页面由于涉及到图片的解码、多个视图的创建&布局,viewDidLoad阶段会耗时100ms左右。目前的解决方案是把其中密码输入框视图延迟到viewDidAppear里加载,对密码错误提示视图做成懒加载,耗时降低到30m左右。
6.针对启动时的必要耗时加载考虑用多线程,在启动时刻可去尽力发挥CPU的性能,这一下下而已其实不必考虑耗时;
7.启动的页面最好不要使用xib,

通过instruments的Time Profiler分析,优化后启动速度有明显提升,didFinishLaunchingWithOptions耗时在75ms左右(iPhone6s iOS10.3.3)

其中目前耗时最多的是快捷密码验证页(PAPasscodeViewController)的创建&布局,其次是DTLaunchViewControlle里对是否要显示广告页的判断代码。可以看到PAPasscodeViewController的viewDidAppear耗时了78ms,但已经没有太大关系,此时用户已经看到了页面,准备去验证指纹/密码了。

总结&后续规划

总结起来,好像启动速度优化就一句话:让系统在启动期间少做一些事。当然我们得先清楚工程里做的哪些事是在启动期间做的、对启动速度的影响有多大,然后case by case地分析工程代码,通过放到子线程、延迟加载、懒加载等方式让系统在启动期间更轻松些。

后续规划

1.替代部分庞大的库,采用更轻量级的解决方案。
2. 整理代码,去除重复的实现,避免出现功能重复的类&分类&方法。
3. 梳理和移除已经下线的业务涉及的类&分类&方法。
4. 监控好灰度版本启动速度的变化趋势,尽早发现&解决拖慢启动速度的问题。

二进制重排

目的

二进制重排(layout)的目的在于将hot code聚合在一起,即使得最经常执行的代码或最需要关键执行的代码(如启动阶段的顺序调用)聚合在一起,形成一个更紧凑的__TEXT段。
经过Layout后的二进制,其高频或关键代码排列会更紧凑,更利于优化startup启动阶段,以及mmap out/in(前后台切换或函数调用)阶段的速度和内存占用。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值