迟到但不缺席的 iOS 启动过程以及启动优化

现在都 0202 年了,如果还是只知道程序是从 main 开始执行,就有点说不过去了。本文会将 iOS 的启动过程做一个梳理,同时也基于启动的原理,对某 App 的启动优化进行了一次实操。

App 启动时的蛛丝马迹

既然有程序从 main 开始执行的认知基础,那就先从 main 函数打个断点开始吧。

1
2
0 main
1 start ======> libdyld.dylib`start:

很明显,startmain 之前执行。

再从 +load 方法中断点试试看,因为 iOS 开发都知道 +load 先于 main 函数执行,哈哈。

1
2
3
0 +[ViewController load]
1 load_images
12 _dyld_start ======> dyldbootstrap::start(dyld3::MachOLoaded const*, int, char const**, dyld3::MachOLoaded const*, unsigned long*)

引出关键字 Mach-O 以及 dyld(aka dynamic loader,苹果官方已开源)。

Mach-O

Mach-O 是 Mach object 的缩写,是 Mac、iOS 上用于存储程序、库的标准格式。

常见类型 动态链接编辑器 备注
MH_OBJECT .o/.a 目标文件/静态库文件
MH_EXECUTE .app/xxx 可执行文件
MH_DYLIB .dylib/.framework/xxx 动态库文件
MH_DYLINKER /usr/lib/dyld 动态链接编辑器
MH_DSYM .dSYM/Contents/Resources/DWARF/xxx 二进制文件符号信息(常用于分析APP的崩溃信息)

它内部结构是怎么样的呢?整合了官方描述和一些已知的段。

常见类型 描述
Header 指定了该 Mach-O 文件的文件类型、架构、位数、指令数量
Load commands 指定了该文件的逻辑结构以及载入虚拟内存后的布局
Raw segment data Load commands 中定义了的段的具体数据
__TEXT 只读数据段,记录了头文件、代码、只读常量等
__DATA 读写数据段,全局变量、静态变量等可读写内容的起始位置和偏移量等
__LINKEDIT 保存着如何加载该文件的元数据,dyld 需要其中的信息

注:所有段(segments)都是页(page)的整数倍大小(arm6416KB,其它4KB

早前,arm64 刚出时,苹果推出了一种 Mach-O Universal Files 的文件格式。这种文件格式的设计,完美的诠释了 indirection (戏称为“包一层”)。

如下两行 FAT Header 只为方便画图,实为同一个页
FAT Header
↙ ↓ FAT Header
↘ ↓ Header(arm64)
Load commands
__TEXT
__DATA
__LINKEDIT
Header(armv7)
Load commands
__TEXT
__DATA
__LINKEDIT

注:Fat Header 一个页的大小;且内部列出了所有 Mach-O 文件中的架构和偏移量。

dyld 加载过程

准备贴两张图作为索引,加载过程就能了然于胸了。一张是 WWDC17-413 中讲到的 dyld 优化,如下图。

dyld 3 主要优化为 进程外解析/编译 Mach-O进程内读取前阶段结果运行基于缓存的服务 三个环节。特别是 进程外解析/编译 Mach-O 这个环节是提前执行的,可以加快后续环节的速度,从而加快整个程序的启动。

dyld优化

再就是 dyld 加载过程的三个阶段(pre-main -> Runtime Initializer -> main)示意图,可以从每个阶段继续延伸开去很多知识点。

dyld过程

比如:

  1. +load 方法加载顺序
  2. 针对 __attribute__((constructor)) 我们能做些什么
  3. 最关键的,我们怎么优化启动的耗时

启动优化实操

看过 WWDC16-406 的同学肯定知道以下几个优化的要点(是要点,不是必要点):

检测启动耗时

  1. 对于 pre-main 阶段

    Apple 提供了一种测量方法,在 Xcode 中 Edit scheme -> Run -> Auguments 将环境变量 DYLD_PRINT_STATISTICS 设为 1 或者 DYLD_PRINT_STATISTICS_DETAILS 设为 1 (后者展示的内容更细)。

  2. 对于 main 阶段

    主要是测量 main() 函数开始执行到 didFinishLaunchingWithOptions 执行结束的耗时

    1
    2
    3
    4
    5
    6
    7
    CFAbsoluteTime beginTime;
    // main() ...

    // Appdelegate.m
    // didFinishLaunchingWithOptions ...

    double launchTime = (CFAbsoluteTimeGetCurrent() - StartTime);

优化方案之 pre-main

Parse Image/Map Image

  1. 尽量不使用内嵌(embedded)的 dylib,加载内嵌 dylib 性能开销较大
  2. 合并已有的 dylib 和使用静态库(static archives),减少 dylib 的使用个数
  3. 懒加载 dylib,但是要注意 dlopen() 可能造成一些问题,且实际上懒加载做的工作会更多

Rebase Image/Bind Image

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

Runtime Initializers

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

优化方案之 main

  1. 梳理各个二方/三方库,找到可以延迟加载的库,做延迟加载处理,比如放到首页控制器的 viewDidAppear 方法里。
  2. 梳理业务逻辑,把可以延迟执行的逻辑,做延迟执行处理。比如检查新版本、注册推送通知等逻辑。
  3. 避免复杂/多余的计算。
  4. 避免在首页控制器的 viewDidLoadviewWillAppear 做太多事情,这 2 个方法执行完,首页控制器才能显示,部分可以延迟创建的视图应做延迟创建/懒加载处理。
  5. 采用性能更好的 API。
  6. 首页控制器用纯代码方式来构建。

实践结果

pre-main阶段

  1. 删除无用的文件和库:移除原生的首页 LoanViewController 与设置页 MineViewController、移除视频爬虫的 MMWormholeVideoCrawler
  2. 合并重复的分类 Category:RPToast
  3. +load 方法延迟到 +initiailize(除了主工程,还涉及不少二方、三方库)

成绩单:850ms -> 760ms

main 阶段

  1. 合规相关的变更,刚好可以与启动优化的延迟加载(地理位置、三方登录和数据收集等 SDK)契合
  2. 其余与业务方沟(扯)通(皮)后确认新的先后顺序就不列了

成绩单:0.41s -> 0.30s

后续规划

  1. 替代部分庞大的库,采用更轻量级的解决方案。
  2. 整理代码,去除重复的实现,避免出现功能重复的类&分类&方法。
  3. 监控好灰度版本启动速度的变化趋势,尽早发现&解决拖慢启动速度的问题。
  4. 将启动优化与用户体验挂钩(活跃、激活和留存等核心指标的角度出发进行衡量)
文章目录
  1. 1. App 启动时的蛛丝马迹
  2. 2. Mach-O
  3. 3. dyld 加载过程
  4. 4. 启动优化实操
    1. 4.1. 检测启动耗时
    2. 4.2. 优化方案之 pre-main
      1. 4.2.1. Parse Image/Map Image
      2. 4.2.2. Rebase Image/Bind Image
      3. 4.2.3. Runtime Initializers
    3. 4.3. 优化方案之 main
    4. 4.4. 实践结果
      1. 4.4.1. pre-main阶段
      2. 4.4.2. main 阶段
    5. 4.5. 后续规划