iOS 的启动优化

1,983 阅读8分钟

本篇主要介绍笔者在项目中所使用过的优化策略,如果有更好的优化策略大家可以在留言中分享。

本项目98%以上代码用Swift开发,当前版本为Swift5,并且用cocoapod管理。

编译加速

在开发中,不管是iOS还是其他,编译时间是一个很头疼的问题。

虽然确实能在编译的时候做其他事(比如摸鱼),但是毕竟还是会影响开发效率。

怎么让编译时间更快呢?

Xcode 配置

Compile
调整编译优化等级

编译优化等级的基本原理是牺牲编译时性能,追求运行时的性能,在编译时删除无用代码,保留调试信息,函数内联等,因此提升打包速度的秘诀就是反其道而行之,牺牲运行时性能来换取编译时性能。对于日常测试打包,可以将 Optimize level 设置为 -O0, 表示不做任何优化, 以提升编译器的编译速度。 同时也不需要使用Whole Module模式进行编译,使用Incrememter即可。

Compile
关闭 Bitcode

BitCode 是由 LLVM 引入的一种中间代码,当这个属性设置为 YES 的时候,Xcode 在打包的时候,会将项目编译成很多个设备对应的安装包,这样在编译打包的时候就比较耗时,但对于内部打包不需要上传 AppStore, 因此我们可以关闭此特性以减少编译打包时间。

关闭 dSYM 生成

一般只有提交到AppStore的时候才需要dSYM生成。

Swift编译优化

Swift语言的类型推断的功能非常强大,但是稍不注意就会写出复制、或者不清晰的表达式,使得编译速度凭白增加不少。

怎么去检查编译时间的长度?

这里我使用了一个叫BuildTimeAnalyzer的工具检测。 GitHub - RobertGummesson/BuildTimeAnalyzer-for-Xcode: Build Time Analyzer for Swift

Compile
需要配置指令 -Xfrontend -debug-time-function-bodies -Xfrontend: 如果编译或类型检查时耗时多长,则在Xcode中输出警告。 -debug-time-function-bodies:输出每个函数的编译时长。

编译完成后点开log文件,可以看到各个文件中函数的编译时长排序:

Compile

通常而言,编译时间过长主要有以下几个原因:

  1. 类型推断。在复制的表达式中,类型推断时间过长。
let a = A + B - C + get(d)

// 可以改成
let a: Element = A + B - C + get(d)
---
let a = array.map( { e in
   return A + B - C + get(e)
})
// 可以改成
let a = array.map( { e -> Element in
   return A + B - C + get(e)
})
  1. 复杂的表达式。对于复杂的表达式应该拆开写。(类型推断的双刃剑?)
// 比如刚刚提到的let a = A + B - C + get(d) 可以改为
var a = A + B
a -= C
a += get(d)
---
// 或者
let a = array.map($0).filter($0).flatMap($0)
// 可以改为
var a = array.map($0)
a = a.filter($0)
a = a.flatMap($0)

如果不想用这个工具,也能配置其他命令,最后的300是指编译时间超过300ms的函数或者表达式,可以自定义。

-Xfrontend -warn-long-function-bodies=300

-Xfrontend -warn-long-expression-type-checking=300

代码瘦身

代码瘦身,最简单直接的方式就是把无用的代码删除。

网上找到了一个WHC_Scan的库,检查无效代码。 WHC_Scan

Compile
但是这个库已经不维护了,需要自己手动改为Swift4或者以上的版本之后才能编译运行。 同时也需要对检测出的类,进行二次查看,防止检查出错。

代码优化

对于OC文件: 用 @import 来替代 #import。 尽量使用@class 声明引用。

对于Swift文件: 尽可能的缩小访问控制范围, 对于类、方法、属性,尽可能的添加final、private等限定修饰符,这样可以使用 Static dispatch 代替 Dynamic dispatch。

对于库: 更多的使用静态库,减少第三方库的编译时间。

模块化代码,以库的方式导入。

执行main函数之前

启动App到执行main函数之前做了什么?

BMain1

通过在Scheme中进行配置,DYLD_PRINT_STATISTICSDYLD_PRINT_STATISTICS_DETAILS,可以查看在Main函数执行之前的启动时间。

BMain2

BMain3

Load dylibs

Dyld从主执行文件的header获取到需要加载的所依赖动态库列表,然后它需要找到每个 dylib,而应用所依赖的 dylib 文件可能会再依赖其他 dylib,所以所需要加载的是动态库列表一个递归依赖的集合。

优化方案:

  1. 核心思想是减少dylibs的引用
  2. 合并现有的dylibs(最好是6个以内)
  3. 使用静态库

Rebase、Bind

  • Rebase在Image内部调整指针的指向。在过去,会把动态库加载到指定地址,所有指针和数据对于代码都是对的,而现在地址空间布局是随机化,所以需要在原来的地址根据随机的偏移量做一下修正
  • Bind是把指针正确地指向Image外部的内容。这些指向外部的指针被符号(symbol)名称绑定,dyld需要去符号表里查找,找到symbol对应的实现

优化方案:

  1. 核心思想是减少DATA块内的指针
  2. 减少Object C元数据量,减少Objc类数量,减少实例变量和函数(与面向对象设计思想冲突)
  3. 减少c++虚函数
  4. 多使用Swift结构体(推荐使用swift)

Objc

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

优化方案: 同上

Initializers

  • Objc的+load()函数
  • C++的构造函数属性函数
  • 非基本类型的C++静态全局变量的创建(通常是类或结构体)

优化方案: 尽可能将load函数中所实现的方法延迟到initialize中。

总结

  1. 移除不需要用到的动态库
  2. 移除不需要用到的类
  3. 合并功能类似的类和扩展
  4. 尽量避免在+load方法里执行的操作,可以推迟到+initialize方法中。
  5. 使用swift。
  6. 使用静态库。

实践

简单来说,将use_frameworks!改为use_modular_headers!

为什么一开始会使用use_frameworks!

在之前造成这个问题的原因主要是 Swift 的运行库没有被包含在 iOS 系统中,而是会打包进 App 中,静态库会导致最终的目标程序中包含重复的运行库。

在使用use_frameworks!之后,cocoapods将第三方库打包成一个动态Framework,拷贝到App中。

这种动态库隶属于 Emmbed Framework, 只在沙盒内共享,但可以与 App Extension共享。不要与系统的动态库搞混了。

在程序启动的时候,进行加载。

.framework

use_modular_headers!做了什么?

将各个第三方库打包成一个.a的静态库文件,再通过一个libPods.a的文件将所有的.a文件集合,最后在编译的时候会被直接拷贝一份,复制到目标程序中。

不需要在启动的时候再进行加载。

.a

结果:dylib loading time: 从原先的700+ms降低到了300+ms。包的体积也减少。

执行main函数之后

didFinishLaunchingWithOptions

一般在didFinishLaunchingWithOptions中会有大量的代码:

  1. swizzing
  2. crash监控
  3. 埋点监控
  4. 注册类
  5. 配置信息

如何优化:

  1. 将重要的、优先级高的类放到willFinishLaunchingWithOptions中,如Crash监控、统计上报等,否则会导致信息收集的缺失。(优化初始化顺序)
  2. 将一些类的注册放到懒加载中,调用的时候再进行注册。
  3. 放到子线程中执行。
  4. 通过Time Profile进行代码耗时检测。

主要的操作就是这几种,但注意不是所有的都能优化。比如某些第三方库要求必须放到didFinishLaunchingWithOptions,又必须在主线程中加载。

界面初始化(今日头条方案)

  1. 纯代码方式而不是storyboard加载首页UI。
  2. 对didFinishLaunching里的函数考虑能否挖掘可以延迟加载或者懒加载,需要与各个业务方pm和rd共同check
  3. 对于一些已经下线的业务,删减冗余代码。
  4. 对于一些与UI展示无关的业务,如微博认证过期检查、图片最大缓存空间设置等做延迟加载
  5. 对实现了+load()方法的类进行分析,尽量将load里的代码延后调用。
  6. 对于viewDidLoad以及viewWillAppear方法中尽量去尝试少做,晚做,不做。

最后

在对于完全没有做过性能优化的工程来说,一次简单的优化能提升百分之20以上的速度。但如果已经做过优化,就只能一点一点深入地梳理应用的逻辑,比如这个组件优化40ms,那个请求优化20ms,这边通过将xib用代码重写,又优化10ms,需要一步步的积累,一个大型的app也需要整个团队的努力。

参考:

WWDC 2016 Optimizing App Startup Time

美团外卖iOS App冷启动治理

今日头条iOS客户端启动速度优化

Coupang App 工程提效实践