iOS 性能调优之启动时间优化

目的

从点击 App 图标到加载 App 闪屏之间会有个动画,我们希望 App 启动速度比这个动画更快。需要注意的是启动时间一旦超过 20s,系统会认为发生了死循环并杀掉 App 进程。当然启动时间最好以 App 所支持的最低配置设备为准。

启动时间的划分

启动时间可以拆分为两个部分

  • 第一部分是点击App后到main函数执行之前的时间
    这一部分主要是在处理系统dylib(动态链接库)和自身App可执行文件的加载.
  • 第二部分是main函数执行之后到AppDelegate中didFinishLaunchingWithOptions方法执行结束的时间
    这一部分主要是构建第一个界面,并完成渲染展示.
    我们把第一部分时间叫做t1第二部分时间叫做t2

t1

  • t1在iOS中被称作pre-main time
  • Xcode提供了显示t1各阶段时间的方法
    1. Product -> Scheme -> Edit Scheme
  1. Run -> Arguments -> Enviroment Variables -> 添加字段{DYLD_PRINT_STATISTICS:Yes}
  1. 重新运行项目,会在控制台看到项目的per-main time

1. dylib loading time

这一阶段主要在load dylibs image(这里的image表示镜像)
在每一个动态库加载过程中dyld需要做下面几步

  • 分析所依赖的动态库
  • 找到动态库的mach-o文件
  • 打开文件
  • 验证文件
  • 在系统核心注册文件签名
  • 对动态库的每一个segment调用mmap()

通常,一个App需要加载100到400个dylibs, 但是其中的系统库被优化,可以很快的加载.针对这一步骤的优化有:

  • 减少非系统库的依赖
  • check framework应当设为optional和required,如果该framework在当前App支持的所有iOS系统版本都存在,那么就设为required,否则就设为optional,因为optional会有些额外的检查
  • 合并非系统库
  • 使用静态资源,比如把代码加入主程序

2. rebase/binding time

由于ASLR(address space layout randomization)的存在,可执行文件和动态链接库在虚拟内存中的加载地址每次启动都不固定,所以需要这2步来修复镜像中的资源指针,来指向正确的地址. rebase修复的是指向当前镜像内部的资源指针, 而bind指向的是镜像外部的资源指针.
rebase步骤先进行,需要把镜像读入内存,并以page为单位进行加密验证,保证不会被篡改,所以这一步的瓶颈在IO.bind在其后进行,由于要查询符号表,来指向跨镜像的资源,加上在rebase阶段,镜像已被读入和加密验证,所以这一步的瓶颈在于CPU计算.
通过下面方法查看相关的资源指针

  • 打开Products下.app文件所在的文件夹
  • cd 到此目录下
  • 在命令行中输入以下命令
xcrun dyldinfo -rebase -bind -lazy_bind xxx.app/xxx
  • 这时会输出所有的资源指针

该阶段的优化关键在于减少__DATA指针的数量
可优化的操作有以下几点

  • 减少class(类),selector(选择子)以及category(分类)这类元数据的数量(下面是使用AppCode分析未使用的代码,可以看出有大量优化空间)
  • 减少C++虚函数数量
  • 使用swift stuct(其实本质上就是为了减少符号的数量)

3. Objc setup time

这一步主要做了以下操作

  • 注册Objc类 (class registration)
  • 把category的定义插入方法列表 (category registration)
  • 保证每一个selector唯一 (selctor uniquing)

由于前面两步的处理,这里已经没有什么可以优化的了

4. initializer time

以上三步属于静态调整(fix-up),都是在修改__DATA segment中的内容,而这里则开始动态调整,开始在堆和堆栈中写入内容。 在这里的工作有以下几点:

  • 用+ initialize方法代替+load方法
  • 使用 dispatch_one() pthread_once() std::once() 代替 C/C++ __ atribute__((constructor))(__ attribute__((constructor))用法解析)
  • 减少静态构造函数
  • 不要在初始化方法中调用 dlopen(),对性能有影响。因为 dyld 在 App 开始前运行,由于此时是单线程运行所以系统会取消加锁,但 dlopen() 开启了多线程,系统不得不加锁,这就严重影响了性能,还可能会造成死锁以及产生未知的后果。所以也不要在初始化器中创建线程。

针对per-main time优化的总结

  • 减少非系统的framework依赖,如果framework 在当前 App 支持的所有 iOS 系统版本中都存在则设为 required,否则设置为 optional,optional 会有额外检查合并非系统库
  • 删除无用的 class/selector/category
  • 删除无用的方法调用、静态变量等减少 C++ 虚函数(减少创建虚函数表时间)
  • 使用 Swift 的 struct (从而减少符号数量)
  • 为不需要写的属性添加 readonly减少 +load() 方法,尽量使用 +initialize() 代替使用
  • dispatch_one() pthread_once() std::once() 代替 C/C++ __ attribute((constructor))__
  • 减少静态构造函数
  • 初始化方法中不要使用 dlopen()

t2

t2中所耗时间基本都是我们自己代码所造成的,所以优化的关键是找出每一个方法耗费的时间。
然后针对耗时的操作进行优化。
我们这里提供4种查看代码耗时的方法。

1. 使用Instruments

使用Instruments App Launch来抓取启动过程中各方法的耗时情况,具体可以参照下面的文章:
使用Instruments - App Launch查看启动问题
缺点:Instruments的功能很强大但是不够灵活。

2. 使用打点计时

此方法的原理为在你需要检测的方法前后做记录,计算前后两次打点间的时间间隔计算出方法耗时。
此方法可以参考这篇文章[贝聊科技]一次立竿见影的启动时间优化
缺点:此方法只能在方法调用的最外层获取到方法耗时,如果方法中调用了多个方法,要精确到具体哪个方法就需要多次设置打点的位置,使用也不是很方便。

3. 定时获取主线程中的调用栈

我们可以设置一个定时器,定时拉取主线程中调用栈信息,通过分析记录调用栈中各方法的入栈出栈时间,可以大致分析出各方法的耗时,这样就解决了方法嵌套时,无法定位耗时方法的问题。
调用栈分析器
调用栈分析器的使用方法可以看这篇文章iOS - 优化App冷启动速度
缺点:此方法存在两个问题,第一是拉去调用栈也会消耗时间,造成时间记录增加。第二就是定时器循环过程中会造成精度问题,例如调用时间小于定时器时间的方法不会被记录,调用时间恰好之比循环时间多了一点点就要增加整个循环时间间隔。

4. hook objc_msgSend 方法

使用fishhook钩住objc_msgSend,并在objc_msgSend前后插入我们的监听方法,为了不影响objc_msgSend的调用速度,插入的监听方法都需要使用汇编实现。
这里hook objc_msgSend的核心代码使用的是戴铭老师的。



使用时通过SMCallTrace类调用就可以了就可以了。
这里我们实现一个demo来验证一下
首先在didFinishLaunchingWithOptions方法中开始记录,并模拟一些耗时操作。

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    [SMCallTrace start];//开始记录
    [self test1];//耗时操作
    self.window = [[UIWindow alloc] initWithFrame:[[UIScreen mainScreen] bounds]];
    self.window.backgroundColor = [UIColor whiteColor];
    ViewController *vc = [ViewController new];
    self.window.rootViewController = vc;
    [self.window makeKeyAndVisible];
    return YES;
}

在第一个显示出来的界面中模拟一个有层级的耗时方法,在viewDidAppear方法中结束记录,并打印个方法耗时。

- (void)viewDidLoad {
    [super viewDidLoad];
    [self test2];
}

- (void)viewDidAppear:(BOOL)animated {
    [super viewDidAppear:animated];
    [SMCallTrace stop];
    [SMCallTrace save];
}


- (void)test2 {
    sleep(0.79);
    [self test3];
}

- (void)test3 {
    sleep(1.77);
}

打印结果

 0| 2001.08|-[AppDelegate test1]
 path[AppDelegate test1]
 0|   1.67|-[UIWindow initWithFrame:]
 path[UIWindow initWithFrame:]
 0|   4.05|-[UIWindow setRootViewController:]
 path[UIWindow setRootViewController:]
 0| 1008.65|-[UIWindow makeKeyAndVisible]
 path[UIWindow makeKeyAndVisible]
 1| 1001.20|  -[ViewController test2]
 path[UIWindow makeKeyAndVisible] - [ViewController test2]
 2| 1001.15|    -[ViewController test3]
 path[UIWindow makeKeyAndVisible] - [ViewController test2] - [ViewController test3]

从打印结果可以看出耗时方法有test1, makeKeyAndVisible, test2, test3.
其中makeKeyAndVisible耗时是因为test2和test3在vc的viewDidLoad方法中调用的。
这样我们分析出具体的耗时方法就可以针对的调整调用时机,或者放到子线程调用了。

关注公众号回复476o7获取demo


参考文章:
https://www.jianshu.com/p/ce36d9efb6b0
https://www.jianshu.com/p/f26c4f16692a
https://www.jianshu.com/p/027a8abca64f
http://www.jianshu.com/p/d280feedbca0
http://www.cocoachina.com/ios/20161102/17931.html

你可能感兴趣的:(iOS 性能调优之启动时间优化)