iOS 字节:启动时间优化

原创:知识点总结性文章
创作不易,请珍惜,之后会持续更新,不断完善
个人比较喜欢做笔记和写总结,毕竟好记性不如烂笔头哈哈,这些文章记录了我的IOS成长历程,希望能与大家一起进步
温馨提示:由于简书不支持目录跳转,大家可通过command + F 输入目录标题后迅速寻找到你所需要的内容

目录

  • 一、启动原理
    • 1、启动概念
    • 2、IPA 构建
    • 3、dyld3 启动流程
    • 4、UIKit Init
    • 5、总结
  • 二、启动优化实践
    • 1、监控
    • 2、工具
    • 3、最佳实践
  • 参考文献

1、启动概念

启动是 App 给用户的第一印象,启动越慢用户流失的概率就越高,良好的启动速度是用户体验不可缺少的一环。启动优化涉及到的知识点非常多面也很广,一篇文章难以包含全部,所以拆分成两部分:原理和实战。

本文从基础知识出发,先回顾一些核心概念,为后续章节做铺垫;接下来介绍 IPA 构建的基本流程,以及这个流程里可用于启动优化的点;最后大篇幅讲解 dyld3 的启动 pipeline,因为启动优化的重点还在运行时。

启动最佳时间是 400ms 以内,因为启动动画时长是 400ms。起点:进程创建的时间。终点:第一个CA::Transaction::commit()CATransactionCore Animation 提供的一种事务机制,把一组 UI 上的修改打包,一起发给 Render Server渲染。

启动的种类

根据场景的不同,启动可以分为三种:冷启动,热启动和回前台。

  • 冷启动:系统里没有任何进程的缓存信息,典型的是重启手机后直接启动 App
  • 热启动:如果把 App 进程杀了,然后立刻重新启动,这次启动就是热启动,因为进程缓存还在
  • 回后台:大多数时候不会被定义为启动,因为此时 App 仍然活着,只不过处于 suspended 状态

那么,线上用户的冷启动多还是热启动多呢?答案是和产品形态有关系,打开频次越高,热启动比例就越高。

Mach-O

Mach-O是 iOS 可执行文件的格式,典型的 Mach-O 是主二进制和动态库。Mach-O 可以分为三部分:HeaderLoad CommandsData

Header 的最开始是 Magic Number,表示这是一个 Mach-O 文件,除此之外还包含一些 Flags,这些flags会影响 Mach-O 的解析。Load Commands 存储 Mach-O 的布局信息,比如Segment commandData中的 Segment/Section 是一一对应的。除了布局信息之外,还包含了依赖的动态库等启动 App 需要的信息。Data 部分包含了实际的代码和数据,Data 被分割成很多个 Segment,每个 Segment 又被划分成很多个 Section,分别存放不同类型的数据。

标准的三个 SegmentTEXTDATALINKEDIT,也支持自定义:

  • TEXT:代码段,只读可执行,存储函数的二进制代码(__text),常量字符串(__cstring),Objective C 的类/方法名等信息
  • DATA:数据段,读写,存储 Objective C 的字符串(__cfstring),以及运行时的元数据:class/protocol/method…
  • LINKEDIT:启动 App 需要的信息,如 bind & rebase 的地址,代码签名,符号表…
dyld

dyld 是启动的辅助程序,是 in-process 的,即启动的时候会把 dyld 加载到进程的地址空间里,然后把后续的启动过程交给 dyld

虚拟内存

内存可以分为虚拟内存和物理内存,其中物理内存是实际占用的内存,虚拟内存是在物理内存之上建立的一层逻辑地址,保证内存访问安全的同时为应用提供了连续的地址空间。

物理内存和虚拟内存以页为单位映射,但这个映射关系不是一一对应的:一页物理内存可能对应多页虚拟内存;一页虚拟内存也可能不占用物理内存。

mmap

mmap 的全称是memory map,是一种内存映射技术,可以把文件映射到虚拟内存的地址空间里,这样就可以像直接操作内存那样来读写文件。当读取虚拟内存,其对应的文件内容在物理内存中不存在的时候,会触发一个事件:File Backed Page In,把对应的文件内容读入物理内存。

启动的时候,Mach-O 就是通过 mmap 映射到虚拟内存里的(如下图)。下图中部分页被标记为 zero fill,是因为全局变量的初始值往往都是 0,那么这些 0 就没必要存储在二进制里,增加文件大小。操作系统会识别出这些页,在 Page In 之后对其置为 0,这个行为叫做 zero fill

Page In

启动的路径上会触发很多次 Page In,其实也比较容易理解,因为启动的会读写二进制中的很多内容。Page In 会占去启动耗时的很大一部分,我们来看看单个 Page In 的过程:

  • MMU 找到空闲的物理内存页面
  • 触发磁盘 IO,把数据读入物理内存
  • 如果是 TEXT 段的页,要进行解密
  • 对解密后的页,进行签名验证

其中解密是大头,IO 其次。为什么要解密呢?因为 iTunes Connect 会对上传Mach-OTEXT 段进行加密,防止 IPA 下载下来就直接可以看到代码。这也就是为什么逆向里会有个概念叫做“砸壳”,砸的就是这一层TEXT段加密。iOS 13 对这个过程进行了优化,Page In 的时候不需要解密了。

二进制重排

既然 Page In耗时,有没有什么办法优化呢?启动具有局部性特征,即只有少部分函数在启动的时候用到,这些函数在二进制中的分布是零散的,所以 Page In 读入的数据利用率并不高。如果我们可以把启动用到的函数排列到二进制的连续区间,那么就可以减少Page In的次数,从而优化启动时间:以下图为例,方法 1 和方法 3 是启动的时候用到的,为了执行对应的代码,就需要两次 Page In。假如我们把方法 1 和 3 排列到一起,那么只需要一次 Page In,从而提升启动速度。链接器ld有个参数-order_file 支持按照符号的方式排列二进制。获取启动时候用到的符号的有很多种方式,感兴趣的同学可以看看抖音之前的文章


2、IPA 构建

pipeline

既然要构建,那么必然会有一些地方去定义如何构建,对应 Xcode 中的两个配置项:

  • Build Phase:Target 为维度定义了构建的流程。可以在 Build Phase 中插入脚本,来做一些定制化的构建,比如 CocoaPod 的拷贝资源就是通过脚本的方式完成的。
  • Build Settings:配置编译和链接相关的参数。特别要提到的是 other link flagsother c flags,因为编译和链接的参数非常多,有些需要手动在这里配置。很多项目用的 CocoaPod 做的组件化,这时候编译选项在对应的.xcconfig 文件里。

以单 Target 为例,我们来看下构建流程:

  • 源文件(.m/.c/.swift 等)是单独编译的,输出对应的目标文件(.o)
  • 目标文件和静态库/动态库一起,链接出最后的 Mach-O
  • Mach-O 会被裁剪,去掉一些不必要的信息
  • 资源文件如 storyboardasset 也会编译,编译后加载速度会变快
  • Mach-O 和资源文件一起,打包出最后的.app
  • .app签名,防篡改
编译

编译器可以分为两大部分:前端和后端,二者以 IR(中间代码)作为媒介。这样前后端分离,使得前后端可以独立的变化,互不影响。C 语言家族的前端是 clang,swift 的前端是 swiftc,二者的后端都是 llvm。前端负责预处理,词法语法分析,生成IR。后端基于 IR 做优化,生成机器码。那么如何利用编译优化启动速度呢?

代码数量会影响启动速度,为了提升启动速度,我们可以把一些无用代码下掉。那怎么统计哪些代码没有用到呢?可以利用 LLVM 插桩来实现。LLVM 的代码优化流程是一个一个 Pass,由于LLVM是开源的,我们可以添加一个自定义的 Pass,在函数的头部插入一些代码,这些代码会记录这个函数被调用了,然后把统计到的数据上传分析,就可以知道哪些代码是用不到的了 。

链接

经过编译后,我们有很多个目标文件,接着这些目标文件会和静态库,动态库一起,链接出一个 Mach-O。链接的过程并不产生新的代码,只会做一些移动和补丁。

tbd 的全称是 text-based stub library,是因为链接的过程中只需要符号就可以了,所以 像 UIKit 等系统库就不提供完整的 Mach-O,而是提供一个只包含符号等信息的 tbd 文件。

举一个基于链接优化启动速度的例子:最开始讲解 Page In 的时候,我们提到 TEXT 段的页解密很耗时,有没有办法优化呢?可以通过ld-rename_section,把 TEXT 段中的内容,比如字符串移动到其他的段(启动路径上难免会读很多字符串),从而规避这个解密的耗时。抖音的重命名方案:

"-Wl,-rename_section,__TEXT,__cstring,__RODATA,__cstring",
"-Wl,-rename_section,__TEXT,__const,__RODATA,__const", 
"-Wl,-rename_section,__TEXT,__gcc_except_tab,__RODATA,__gcc_except_tab", 
"-Wl,-rename_section,__TEXT,__objc_methname,__RODATA,__objc_methname", 
"-Wl,-rename_section,__TEXT,__objc_classname,__RODATA,__objc_classname",
"-Wl,-rename_section,__TEXT,__objc_methtype,__RODATA,__objc_methtype"
裁剪

编译完 Mach-O 之后会进行裁剪(strip),是因为里面有些信息,如调试符号,是不需要带到线上去的。裁剪有多种级别,一般的配置如下:

  • All Symbols,主二进制
  • Non-Global Symbols,动态库
  • Debugging Symbols,二方静态库

为什么二方库在出静态库的时候要选择Debugging Symbols 呢?是因为像 order_file 等链接期间的优化是基于符号的,如果把符号裁剪掉,那么这些优化也就不会生效了。

签名 & 上传

裁剪完二进制后,会和编译好的资源文件一起打包成.app 文件,接着对这个文件进行签名。签名的作用是保证文件内容不多不少,没有被篡改过。接着会把包上传到 iTunes Connect,上传后会对__TEXT段加密,加密会减弱 IPA 的压缩效果,增加包大小,也会降低启动速度(iOS 13 优化了加密过程,不会对包大小和启动耗时有影响)。


3、dyld3 启动流程

dyld2

dyld2dyld3 的主要区别就是没有启动闭包,就导致每次启动都要:

  • 解析动态库的依赖关系
  • 解析 LINKEDIT,找到bind & rebase 的指针地址,找到 bind 符号的地址
  • 注册objcClass/Method 等元数据,对大型工程来说,这部分耗时会很长
Before dyld

用户点击图标之后,会发送一个系统调用 execve 到内核,内核创建进程。接着会把主二进制 mmap 进来,读取 load command 中的 LC_LOAD_DYLINKER,找到 dyld 的的路径。然后 mmap dyld 到虚拟内存,找到 dyld 的入口函数_dyld_start,把 PC 寄存器设置成_dyld_start,接下来启动流程交给了 dyld。注意这个过程都是在内核态完成的,这里提到了 PC 寄存器,PC 寄存器存储了下一条指令的地址,程序的执行就是不断修改和读取 PC 寄存器来完成的。

dyld

创建启动闭包

dyld 会首先创建启动闭包,闭包是一个缓存,用来提升启动速度的。既然是缓存,那么必然不是每次启动都创建的,只有在重启手机或者更新/下载 App 的第一次启动才会创建。闭包存储在沙盒的 tmp/com.apple.dyld 目录,清理缓存的时候切记不要清理这个目录。闭包是怎么提升启动速度的呢?我们先来看一下闭包里都有什么内容:

  • dependends:依赖动态库列表
  • fixup:bind & rebase 的地址
  • initializer-order:初始化调用顺序
  • optimizeObjc:Objective C 的元数据
  • 其他:main entry, uuid…

动态库的依赖是树状的结构,初始化的调用顺序是先调用树的叶子结点,然后一层层向上,最先调用的是libSystem,因为他是所有依赖的源头。

为什么闭包能提高启动速度呢?因为这些信息是每次启动都需要的,把信息存储到一个缓存文件就能避免每次都解析,尤其是 Objective C 的运行时数据(Class/Method…)解析非常慢。

fixup

有了闭包之后,就可以用闭包启动 App 了。这时候很多动态库还没有加载进来,会首先对这些动态库mmap 加载到虚拟内存里。接着会对每个 Mach-Ofixup,包括 RebaseBind

Rebase:修复内部指针。这是因为 Mach-Ommap 到虚拟内存的时候,起始地址会有一个随机的偏移量 slide,需要把内部的指针指向加上这个 slide

Bind:修复外部指针。这个比较好理解,因为像 printf 等外部函数,只有运行时才知道它的地址是什么,bind 就是把指针指向这个地址。

举个例子:一个 Objective C 字符串@"1234",编译到最后的二进制的时候是会存储在两个 section 里的

__TEXT:__cstring,存储实际的字符串"1234"

__DATA:__cfstring,存储 Objective C 字符串的元数据,每个元数据占用 32Byte,里面有两个指针:内部指针,指向__TEXT__cstring中字符串的位置;外部指针isa,指向类对象的,这就是为什么可以对 Objective C 的字符串字面量发消息的原因。

如下图,编译的时候,字符串 1234 在__cstring的 0x10 处,所以 DATA 段的指针指向 0x10。但是 mmap 之后有一个偏移量slide=0x1000,这时候字符串在运行时的地址就是 0x1010,那么 DATA段的指针指向就不对了。Rebase 的过程就是把指针从 0x10,加上 slide 变成 0x1010。运行时类对象的地址已经知道了,bind 就是把 isa 指向实际的内存地址。

LibSystem Initializer

Bind & Rebase 之后,首先会执行 LibSystemInitializer,做一些最基本的初始化:

  • 初始化 libdispatch
  • 初始化 objc runtime,注册 sel,加载 category

注意这里没有初始化 objc 的类方法等信息,是因为启动闭包的缓存数据已经包含了 optimizeObjc

Load & Static Initializer

接下来会进行 main 函数之前的一些初始化,主要包括+loadstatic initializer。这两类初始化函数都有个特点:调用顺序不确定,和对应文件的链接顺序有关系。那么就会存在一个隐藏的坑:有些注册逻辑在+load 里,对应会有一些地方读取这些注册的数据,如果在+load中读取,很有可能读取的时候还没有注册。

那么,如何找到代码里有哪些 loadstatic initializer 呢?在 Build Settings 里可以配置 write linkmap,这样在生成的linkmap文件里就可以找到有哪些文件里包含 load 或者 static initializer

  • __mod_init_func,static initializer
  • __objc_nlclslist,实现+load 的类
  • __objc_nlcatlist,实现+load 的 Category
load 举例

如果+load 方法里的内容很简单,会影响启动时间么?比如这样的一个+load 方法?编译完了之后,这个函数会在二进制中的 TEXT 两个段存在:__text存函数二进制,cstring存储字符串 1234。为了执行函数,首先要访问__text触发一次 Page In 读入物理内存,为了打印字符串,要访问__cstring,还会触发一次 Page In

+ (void)load { printf("1234"); }

为了执行这个简单的函数,系统要额外付出两次 Page In 的代价,所以 load 函数多了,page in 会成为启动性能的瓶颈。

static initializer 产生的条件

静态初始化是从哪来的呢?以下几种代码会导致静态初始化

  • attribute((constructor))
  • static class object
  • static object in global namespace

注意,并不是所有的 static 变量都会产生静态初始化,编译器很智能,对于在编译期间就能确定的变量是会直接 inlinestd::string 会合成 static initializer是因为初始化的时候必须执行构造函数,这时候编译器就不知道怎么做了,只能延迟到运行时~

//会产生静态初始化
class Demo{
    static const std::string var_1;
};
const std::string var_2 = "1234";
static Logger logger;

//不会产生静态初始化
static const int var_3 = 4;
static const char * var_4 = "1234";

4、UIKit Init

+loadstatic initializer 执行完毕之后,dyld 会把启动流程交给 App,开始执行 main 函数。main 函数里要做的最重要的事情就是初始化 UIKitUIKit 主要会做两个大的初始化:

  • 初始化 UIApplication
  • 启动主线程的 Runloop

由于主线程的dispatch_async 是基于 runloop 的,所以在+load 里如果调用了 dispatch_async 会在这个阶段执行。

Runloop

线程在执行完代码就会退出,很明显主线程是不能退出的,那么就需要一种机制:事件来的时候执行任务,否则让线程休眠,Runloop 就是实现这个功能的。Runloop 本质上是一个While 循环,在图中橙色部分的 mach_msg_trap 就是触发一个系统调用,让线程休眠,等待事件到来,唤醒 Runloop,继续执行这个 while循环。Runloop 主要处理几种任务:Source0Source1TimerGCD MainQueueBlock。在循环的合适时机,会以 Observer的方式通知外部执行到了哪里。

那么,Runloop 与启动又有什么关系呢?App 的 LifeCycle 方法是基于 RunloopSource0 的。首帧渲染是基于 Runloop Block 的。

Runloop 在启动上主要有几点应用:

  • 精准统计启动时间
  • 找到一个时机,在启动结束去执行一些预热任务
  • 利用 Runloop 打散耗时的启动预热任务
AppLifeCycle

UIKit 初始化之后,就进入了我们熟悉的 UIApplicationDelegate 回调了,在这些回调里去做一些业务上的初始化:

  • willFinishLaunch
  • didFinishLaunch
  • didFinishLaunchNotification

要特别提一下 didFinishLaunchNotification,是因为大家在埋点的时候通常会忽略还有这个通知的存在,导致把这部分时间算到 UI 渲染里。

First Frame Render

一般会用 Root ControllerviewDidApper 作为渲染的终点,但其实这时候首帧已经渲染完成一小段时间了,Apple 在 MetricsKit 里对启动终点定义是第一个CA::Transaction::commit()。什么是CATransaction呢?我们先来看一下渲染的大致流程。

iOS 的渲染是在一个单独的进程RenderServer做的,App 会把 Render Tree 编码打包给 RenderServerRenderServer 再调用渲染框架(Metal/OpenGL ES)来生成 bitmap,放到帧缓冲区里,硬件根据时钟信号读取帧缓冲区内容,完成屏幕刷新。CATransaction 就是把一组 UI 上的修改,合并成一个事务,通过 commit 提交。

渲染可以分为四个步骤

  • Layout(布局):源头是 Root Layer 调用[CALayer layoutSubLayers],这时候 UIViewControllerviewDidLoadLayoutSubViews 会调用,autolayout 也是在这一步生效
  • Display(绘制):源头是 Root Layer 调用[CALayer display],如果 View 实现了 drawRect 方法,会在这个阶段调用
  • Prepare(准备):这个过程中会完成图片的解码
  • Commit(提交):打包 Render Tree 通过 XPC 的方式发给 Render Server

5、总结

启动 Pipeline

详细回顾下整个启动过程,以及各个阶段耗时的影响因素:

  1. 点击图标,创建进程
  2. mmap 主二进制,找到 dyld 的路径
  3. mmap dyld,把入口地址设为_dyld_start
  4. 重启手机/更新/下载 App 的第一次启动,会创建启动闭包
  5. 把没有加载的动态库 mmap 进来,动态库的数量会影响这个阶段
  6. 对每个二进制做 bindrebase,主要耗时在 Page In,影响 Page In 数量的是 objc的元数据
  7. 初始化objcruntime,由于闭包已经初始化了大部分,这里只会注册 sel 和装载 category
  8. +load 和静态初始化被调用,除了方法本身耗时,这里还会引起大量 Page In
  9. 初始化 UIApplication,启动 Main Runloop
  10. 执行 will/didFinishLaunch,这里主要是业务代码耗时
  11. Layout:viewDidLoadLayoutsubviews 会在这里调用,Autolayout太多会影响这部分时间
  12. Display:drawRect 会调用
  13. Prepare:图片解码发生在这一步
  14. Commit:首帧渲染数据打包发给 RenderServer,启动结束

本文回顾了 Mach-O,虚拟内存,mmapPage InRunloop 等基础概念,接下来介绍了 IPA 的构建流程,以及两个典型的利用编译器来优化启动的方案,最后详细的讲解了 dyld3 的启动 pipeline。之所以花这么大篇幅讲原理,是因为任何优化都一样,只有深入理解系统运作的原理,才能找到性能的瓶颈。


二、启动优化实践

文章的正式内容开始之前,大家可以思考下,如果自己去做启动优化的,会如何去开展?这其实是一个比较大的问题,遇到类似情况,我们都可以去把大问题拆解成几个小的问题:

  • 线上用户究竟启动状况如何?
  • 如何去找到可以优化的点?
  • 做完优化之后,如何保持?
  • 有没有一些成熟的经验可以借鉴,业界都是怎么做的?

对应着本文的三大模块:监控,工具和最佳实践。

1、监控

启动埋点

既然要监控,那么就要能够在代码里获取到启动时长。启动的起点大家采用的方案都一样:进程创建的时间。启动的终点对应用户感知到的 Launch Image 消失的第一帧,抖音采用的方案如下:iOS 12 及以下:root viewControllerviewDidAppear。iOS 13+:applicationDidBecomeActive。Apple 官方的统计方式是第一个 CA::Transaction::commit,但对应的实现在系统框架内部,抖音的方式已经非常接近这个点了。

分阶段

只有一个启动耗时的埋点在排查线上问题的时候显然是不够的,可以通过分阶段和单点埋点结合,下面是这是目前抖音的监控方案:+loadinitializer的调用顺序和链接顺序有关,链接顺序默认按照 CocoaPod 的 Pod 命名升序排列,所以取一个命名为 AAA 开头既可以让某个+loadinitializer 第一个被执行。

无侵入监控

公司的 APM 团队提供了一种无侵入的启动监控方案,方案将启动流程拆分成几个粒度比较粗的与业务无关的阶段:进程创建,最早的 +loaddidFinishLuanching 开始和首屏首次绘制完成。

前三个时间点无侵入获取较为简单

  • 进程创建:通过sysctl系统调用拿到进程创建的时间戳
  • 最早的 +load:和上面的分阶段监控一样,通过 AAA 为前缀命名 Pod,让 +load 第一个被执行
  • didFinishLaunching:监控 SDK 初始化一般在启动的很早期,用监控 SDK 的初始化时间作为 didFinishLaunching 的时间

首屏渲染完成时间我们希望和 MetricKit 对齐,即获取到 CA::Transaction::commit()方法被调用的时间。通过 Runloop 源码分析和线下调试,我们发现 CA::Transaction::commit()CFRunLoopPerformBlockkCFRunLoopBeforeTimers 这三个时机的顺序从早到晚依次是:

可以通过在 didFinishLaunch 中向 Runloop 注册block或者BeforeTimerObserver来获取上图中两个时间点的回调,代码如下:

//注册block
CFRunLoopRef mainRunloop = [[NSRunLoop mainRunLoop] getCFRunLoop];
CFRunLoopPerformBlock(mainRunloop,NSDefaultRunLoopMode,^(){
    NSTimeInterval stamp = [[NSDate date] timeIntervalSince1970];
    NSLog(@"runloop block launch end:%f",stamp);
});
//注册kCFRunLoopBeforeTimers回调
CFRunLoopRef mainRunloop = [[NSRunLoop mainRunLoop] getCFRunLoop];
CFRunLoopActivity activities = kCFRunLoopAllActivities;
CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler(kCFAllocatorDefault, activities, YES, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) {
    if (activity == kCFRunLoopBeforeTimers) {
        NSTimeInterval stamp = [[NSDate date] timeIntervalSince1970];
        NSLog(@"runloop beforetimers launch end:%f",stamp);
        CFRunLoopRemoveObserver(mainRunloop, observer, kCFRunLoopCommonModes);
    }
});
CFRunLoopAddObserver(mainRunloop, observer, kCFRunLoopCommonModes);

经过实测,我们最后选择的无侵入获取首屏渲染方案是:iOS13(含)以上的系统采用 runloop 中注册一个kCFRunLoopBeforeTimers 的回调获取到的 App 首屏渲染完成的时机更准确。iOS13 以下的系统采用 CFRunLoopPerformBlock 方法注入 block 获取到的 App 首屏渲染完成的时机更准确。

监控周期

App 的生命周期可以分为三个阶段:研发,灰度和线上,不同阶段监控的目的和方式都不一样。

研发阶段

研发阶段的监控主要目的是防止劣化,对应着会有线下的自动化监控,通过实际的启动性能测试来尽早地发现和解决问题,抖音的线下自动化监控流程图如下。由定时任务触发,先 release 模式下打包,接着跑一次自动化测试,测试完毕后会上报测试结果,方便通过看板来跟踪整体的变化趋势。如果发现有劣化,会先发一条报警信息,接着通过二分查找的方式找到对应的劣化 MR,然后自动跑火焰图和 Instrument 来辅助定位问题。

那么如何保证测试的结果是稳定可靠的呢?答案就是控制变量:

  • 关闭 iCloud & 不登录 AppleID & 飞行模式
  • 风扇降温,且用 MFI 认证数据线
  • 重启手机和开始下一次测试之前静置一段时间
  • 多次测量取平均值 & 计算方差
  • Mock 启动过程中的 AB 变量

除了自动化测试,在研发流程上还可以加一些准入,来防止启动劣化,这些准入包括如下。不建议做细粒度的 Code Review,除非对相关业务很了解,否则一般肉眼看不出会不会有劣化。

  • 新增动态库
  • 新增 +load 和静态初始化
  • 新增启动任务 Code Review
线上 & 灰度

灰度和线上的策略是相似的,主要看的是大盘数据和配置报警,大盘监控和报警和公司的基建有很大关系,如果没有对应基建 Xcode MetricKit 本身也可以看到启动耗时:打开 Xcode -> Window -> Origanizer -> Launch Time

大盘数据本身是统计学的,会有些统计学的规律:

  • 发版本的前几天启动速度比较慢,这是因为 iOS 13 后更新 App 的第一次启动要创建启动闭包,这个过程是比较慢的。
  • 新版本发布会导致老版本 pct50 变慢,因为性能差的设备升级速度慢,导致老版本性能差设备比例变高
  • 采样率调整会影响 pct50,比如某些地区的 iPhone 6 比例较高,如果这些地区采样率提高会导致大盘性能差的设备比例提高。

基于这些背景,我们一般会通过控制变量的方式:拆地区,机型,版本,有时候甚至要根据时间看启动耗时的趋势。


2、工具

完成了监控之后,我们需要找到一些可以优化的点,就需要用到工具。主要包括两大类:Instrument和自研。

Time Profiler

Time Profiler 是大家日常性能分析中用的比较多的工具,通常会选择一个时间段,然后聚合分析调用栈的耗时。但Time Profiler其实只适合粗粒度的分析,为什么这么说呢?我们来看下它的实现原理:默认 Time Profiler 会 1ms 采样一次,只采集在运行线程的调用栈,最后以统计学的方式汇总。比如下图中的 5 次采样中,method3 都没有采样到,所以最后聚合到的栈里就看不到 method3。所以 Time Profiler 中的看到的时间,并不是代码实际执行的时间,而是栈在采样统计中出现的时间。

Time Profiler 支持一些额外的配置,如果统计出来的时间和实际的时间相差比较多,可以尝试开启:

  • High Frequency:降低采样的时间间隔
  • Record Kernel Callstacks:记录内核的调用栈
  • Record Waiting Thread:记录被 block 的线程
System Trace

既然 Time Profiler 支持粗粒度的分析,那么有没有什么精细化的分析工具呢?答案就是 System Trace。既然要精细化分析,那么我们就需要标记出一小段时间,可以用 Point of interest 来标记。除此之外,System Trace 分析虚拟内存和线程状态都很管用:

  • Virtual Memory:主要关注 Page In这个事件,因为启动路径上有很多次 Page In,且相对耗时
  • Thread State:主要关注挂起和抢占两个状态,记住主线程不是一直在运行的
  • System Load :线程有优先级,高优先级的线程不应该超过系统核心数量
os_signpost

os_signpost 是 iOS 12 推出的用于在 instruments 里标记时间段的 API,性能非常高,可以认为对启动无影响。结合最开始讲的分阶段监控,我们可以在 Instrument 把启动划分成多个阶段,和其他模板一起分析具体问题。结合 swizzleos_signpost 可以发挥出意想不到的效果,比如 hook 所有的load 方法,来分析对应耗时,又比如 hook UIImage 对应方法,来统计启动路径上用到的图片加载耗时。

其他 Instrument 模板
  • Static Initializer:分析 C++ 静态初始化
  • App Launch:Xcode 11 后新出的模板,可以认为是Time Profiler + System Trace
  • Custom Instrument:自定义 Instrument,最简单是用 os_signpost作为模板的数据源,自己做一些简单的定制化展示。
火焰图

火焰图用来分析时间相关的性能瓶颈非常有用,可以直接把业务代码的耗时绘制出来。此外,火焰图可以自动化生成 diff,所以可以用于自动化归因。火焰图有两种常见实现方式:

  • hook objc_msgSend
  • 编译期插桩

本质上都是在方法的开始和末尾打两个点,就知道这个方法的耗时,然后转换成 Chrome 的标准的 json 格式就可以分析了。注意就算用 mmap 来写文件,仍然会有一些误差,所以找到的问题并不一定是问题,需要二次确认。


3、最佳实践

整体思路

优化的整体思路其实就四步:

  • 删掉启动项,最直接
  • 如果不能删除,尝试延迟,延迟包括第一次访问以及启动结束后找个合适的时间预热
  • 不能延迟的可以尝试并发,利用多核多线程
  • 如果并发也不行,可以尝试让代码执行更快

这块会以 Main 函数做分界线,看下 Main 函数前后的优化方案;接着介绍如何优化 Page In;最后讲解一些非常规的优化方案,这些方案对架构的要求比较高。

Main 之前

Main 函数之前的启动流程如下:

  • 加载 dyld
  • 创建启动闭包(更新 App/重启手机需要)
  • 加载动态库
  • Bind & Rebase & Runtime 初始化
  • +load和静态初始化
动态库

减少动态库数量可以减少启动闭包创建和加载动态库阶段的耗时,官方建议动态库数量小于 6 个。推荐的方式是动态库转静态库,因为还能额外减少包大小。另外一个方式是合并动态库,但实践下来可操作性不大。最后一点要提的是,不要链接那些用不到的库(包括系统),因为会拖慢创建闭包的速度。

下线代码

下线代码可以减少 Rebase &Bind & Runtime初始化的耗时。那么如何找到用不到的代码,然后把这些代码下线呢?可以分为静态扫描和线上统计两种方式。

最简单的静态扫描是基于 AppCode,但是项目大了之后 AppCode 的索引速度非常慢,另外的一种静态扫描是基于 Mach-O 的。二者做个差集就知道那些类/sel 用不到,但objc 支持运行时调用,删除之前还要在二次确认。

  • _objc_selrefs_objc_classrefs存储了引用到的 selclass
  • __objc_classlist存储了所有的 selclass

还有一种统计无用代码的方式是用线上的数据统计,主流的方案有三种。前两种是 ROI较高的方案,绝大多数时候 Class 级别的渗透率足够了。

  • ViewController 渗透率:hook 对应的生命周期方法即可统计
  • Class 渗透率:遍历运行时的所有类,通过 Objective C Runtime 的标志位判断类是否被访问
  • 行级渗透率:需要用编译期插桩,对包大小和执行速度均有损。
+load 迁移

+load 除了方法本身的耗时,还会引起大量 Page In,另外 +load 的存在对 App 稳定性也是冲击,因为 Crash 了捕获不到。举个例子,很多 DI 的容器需要把协议绑定到类,所以需要在启动的早期(+load)里注册:

+ (void)load
{
    [DICenter bindClass:IMPClass toProtocol:@protocol(SomeProcotol)]
}

本质上只要知道协议和类的对应关系即可,利用 clang attribute,这个过程可以迁移到编译期。原理很简单:宏提供接口,编译期把类名和协议名写到二进制的指定段里,运行时把这个关系读出来就知道协议是绑定到哪个类了。有同学会注意到有个无用的方法_DI_VALID_METHOD ,这个方法只在 debug 模式下存在,为了让编译器保证类型安全。

typedef struct{
    const char * cls;
    const char * protocol;
}_di_pair;
#if DEBUG
#define DI_SERVICE(PROTOCOL_NAME,CLASS_NAME)\
__used static Class<PROTOCOL_NAME> _DI_VALID_METHOD(void){\
    return [CLASS_NAME class];\
}\
__attribute((used, section(_DI_SEGMENT "," _DI_SECTION ))) static _di_pair _DI_UNIQUE_VAR = \
{\
_TO_STRING(CLASS_NAME),\
_TO_STRING(PROTOCOL_NAME),\
};\
#else
__attribute((used, section(_DI_SEGMENT "," _DI_SECTION ))) static _di_pair _DI_UNIQUE_VAR = \
{\
_TO_STRING(CLASS_NAME),\
_TO_STRING(PROTOCOL_NAME),\
};\
#endif
静态初始化迁移

静态初始化和 +load方法一样也会引起大量 Page In,一般来自 C++代码,比如网络或者特效的库。另外有些静态初始化是通过头文件引入进来的,可以通过预处理来确认。几个典型的迁移思路:

  • std:string 转换成 const char *
  • 静态变量移动到方法内部,因为方法内部的静态变量会在方法第一次调用的时候初始化
//Bad
namespace {
    static const std::string bucket[] = {"apples", "pears", "meerkats"};
}
const std::string GetBucketThing(int i) {
     return bucket[I];
}
std::string GetBucketThing(int i) {
  static const std::string bucket[] = {"apples", "pears", "meerkats"};
  return bucket[I];
}

Main 之后

启动器

启动是需要一个框架来管控的,抖音采用了轻量级的中心式方案:

  • 有个启动任务的配置仓,里面只包含启动任务的顺序和线程
  • 业务仓实现协议 BootTask,表明这是个启动任务

为什么需要启动器呢?

  • 全局并发调度:比如 AB 任务并发,C 任务等待 AB 执行完毕,框架调度还能减少线程数量和控制优先级
  • 延迟执行:提供一些时机,业务可以做预热性质的初始化
  • 精细化监控:所有任务的耗时都能监控到,线下自动化监控也能受益
  • 管控:启动任务的顺序调整,新增/删除都能通过Code Review管控

启动任务的执行流程如下:

三方 SDK

有些三方 SDK 的启动耗时很高,比如Fabric,抖音下线了 Fabric 后启动速度 pct50 快了 70ms 左右。除了下线,很多 SDK 是可以延迟的,比如分享和登录的 SDK。此外,在接入 SDK 之前可以先评估下对启动性能的影响,如果影响较大是可以反馈给 SDK 的提供方去修改的,尤其是付费的 SDK,他们其实很愿意配合做一些修改。

高频次方法

有些方法的单个耗时不高,但是在启动路径上会调用很多次的,这种累计起来的耗时也不低,比如读Info.plist里面的配置。修改的方式很简单,加一层内存缓存即可,这种问题在 TimeProfiler 里时间段选长一些往往就能看出来。

+ (NSString *)plistChannel
{
    return [[[NSBundle mainBundle] infoDictionary] objectForKey:@"CHANNEL_NAME"];
}

锁之所以会影响启动时间,是因为有时候子线程先持有了锁,主线程就需要等待子线程锁释放。还要警惕系统会有很多隐藏的全局锁,比如 dyldRuntime。举个例子:
下图是 [UIImage imageNamed:] 引起的主线程 block

通过右侧的堆栈能看到,imageNamed 触发了 dlopendlopen 会等待dyld的全局锁。通过 System TraceThread State Event,可以找到线程被 blocked 的下一个事件,这个事件表明了线程重新可以运行,原因就是其他线程释放了锁。接下来通过分析后台线程这个时间在做什么,就知道为什么会持有锁,如何优化了。

线程数量

线程的数量和优先级都会影响启动时间。可以通过设置 QoS 来配置优先级,两个高优的 QoSUser Interactive/Initiated,启动的时候,需要主线程等待的子线程任务都应该设置成高优的。高优的线程数量不应该多于 CPU 核心数量,可以通过 System TraceSystem Load 来分析这种情况。

//GCD
dispatch_queue_attr_t attr = dispatch_queue_attr_make_with_qos_class(DISPATCH_QUEUE_SERIAL, QOS_CLASS_UTILITY, -1);
dispatch_queue_t queue = dispatch_queue_create("com.custom.utility.queue", attr);

//NSOperationQueue
operationQueue.qualityOfService = NSQualityOfServiceUtility

线程的数量也会影响启动时间,但 iOS 中是不太好全局管控线程的,比如二/三方库要起后台线程就不太好管控,不过业务上的线程可以通过启动任务管控。线程多没关系,只要同时并发执行的不多就好,大家可以利用 System Trace 来看看上下文切换耗时,确认线程数量是否是启动的瓶颈。

图片

启动难免会用到很多图,有没有办法优化图片加载的耗时呢?用 Asset 管理图片而不是直接放在 bundle 里。Asset 会在编译期做优化,让加载的时候更快,此外在 Asset 中加载图片是要比 Bundle 快的,因为 UIImage imageNamed 要遍历 Bundle 才能找到图。加载 Asset 中图的耗时主要在第一次张图,因为要建立索引,可以通过把启动的图放到一个小的 Asset 里来减少这部分耗时。

每次创建 UIImage 都需要 IO,在首帧渲染的时候会解码。所以可以通过提前子线程预加载(创建 UIImage)来优化这部分耗时。如下图,启动只有到了比较晚的阶段“RootWindow 创建”和“首帧渲染”才会用到图片,所以可以在启动的早期开预加载的子线程启动任务。

Fishhook

fishhook 是一个用来 hook C 函数的库,但这个库的第一次调用耗时很高,最好不要带到线上。Fishhook 是按照下图的方式遍历 Mach-O 的多个段来找函数指针和函数符号名的映射关系,带来的副作用就是要大量的 Page In,对于大型 App 来说在 iPhone X 冷启耗时 200ms+

如果不得不用 fishhook,请在子线程调用,且不要在在_dyld_register_func_for_add_image直接调用fishhook。因为这个方法会持有 dyld 的一个全局互斥锁,主线程在启动的时候系统库经常会调用 dlsymdlopen,其内部也需要这个锁,造成上文提到的子线程阻塞主线程。

首帧渲染

不同 App 的业务形态不同,首帧渲染优化方式也相差的比较多,几个常见的优化点:

  • LottieView:lottie 是 airbnb 用来做 AE 动画的库,但是加载动画的 json 和读图是比较慢的,可以先显示一帧静态图,启动结束后再开始动画,或者子线程预先把图和 json 设置到lottie cache
  • Lazy 初始化 View:不要先创建设置成 hidden,这是很不好的习惯
  • AutoLayout:AutoLayout 的耗时也是比较高的,但这块往往历史包袱比较重,可以评估 ROI 看看要不要改成 frame
  • Loading 动画:App 一般都会有个 loading 动画表示加载中,这个动画最好不要用 gif,线下测量一个 60 帧的 gif 加载耗时接近 70ms
其他 Tips

启动优化里有一些需要注意的 Tips:不要删除tmp/com.apple.dyld目录,因为这个目录下存储着 iOS 13+的启动闭包,如果删除了下次启动会重新创建,创建闭包的过程是很慢的。接下来是 IO 优化,常见的方式是用 mmap 让 IO 更快一些,也可以在启动的早期预加载数据。

Page In 耗时

启动路径上会触发大量 Page In,有没有办法优化这部分耗时呢?

段重命名

App Store 会对上传的 App 的 TEXT 段加密,在发生 Page In 的时候会解密,解密的过程是很耗时的。既然会 TEXT 段加密,那么直接的思路就是把 TEXT 段中的内容移动到其它段,ld 也有个参数 rename_section 支持重命名:

抖音重命名方案:这个优化方式在 iOS 13 下有效,因为 iOS 13 优化了解密流程,Page In 的时候不需要解密了,这是 iOS 13 启动速度变快的原因之一。

"-Wl,-rename_section,__TEXT,__cstring,__RODATA,__cstring",
"-Wl,-rename_section,__TEXT,__const,__RODATA,__const",
"-Wl,-rename_section,__TEXT,__gcc_except_tab,__RODATA,__gcc_except_tab",
"-Wl,-rename_section,__TEXT,__objc_methname,__RODATA,__objc_methname",
"-Wl,-rename_section,__TEXT,__objc_classname,__RODATA,__objc_classname",
"-Wl,-rename_section,__TEXT,__objc_methtype,__RODATA,__objc_methtype"
二进制重排

既然启动的路径上会触发大量的 Page In,那么有没有什么办法优化呢?启动具有局部性特征,即只有少部分函数在启动的时候用到,这些函数在中的分布是零散的,所以 Page In 读入的数据利用率并不高。如果我们可以把启动用到的函数排列到二进制的连续区间,那么就可以减少 Page In 的次数,从而优化启动时间:以下图为例,方法 1 和方法 3 是启动的时候用到的,为了执行对应的代码,就需要两次 Page In。假如我们把方法 1 和 3 排列到一起,那么只需要一次 Page In,从而提升启动速度。

链接器ld有个参数-order_file 支持按照符号的方式排列二进制。获取启动时候用到的符号主流有两种方式:

  • 抖音方案:静态扫描获取 +loadC++静态初始化,hook objc_msgSend 获取 Objective C 符号。
  • Facebook 方案:LLVM 函数插桩,灰度统计启动路径符号,用大多数用户的符号生成 order_file

FacebookLLVM函数插桩是针对 order_file定制,并且代码也是他们自己给 LLVM 开发的,目前已经合并到 LLVM 主分支了。Facebook 的方案更精细化,生成的 order_file 是最优解,但是工程量很大。抖音的方案不需要源码编译,不需要对现有编译环境和流程改造,侵入性最小,缺点就是只能覆盖 90%左右的符号。 灰度是任何优化都要利用好的一个阶段,因为很多新的优化方案存在不确定性,需要先在灰度上验证。

非常规方案

动态库懒加载

最开始我们提到可以通过删代码的方式来减少代码量,那么有没有什么不减少代码总量,就可以减少启动时候要加载代码数量的方式呢?答案就是动态库懒加载。什么是懒加载的动态库呢?正常动态库都是会被主二进制直接或者间接链接的,那么这些动态库会在启动的时候加载。如果只打包进 App,不参与链接,那么启动的时候就不会自动加载,在运行时需要用到动态库里面的内容的时候,再手动懒加载。懒加载动态库需要在编译期和运行时都进行改造,编译期的架构:

A.framework 等动态库是懒加载的,因为并没有参与主二进制的直接 or 间接链接。动态库之间一定会有一些共同的依赖,把这些依赖打包成 Shared.framework 解决公共依赖的问题。运行时通过-[NSBundle load]来加载,本质上调用的是底层的 dlopen。那么什么时候触发动态库手动加载呢?动态库可以分成两种:业务和功能。业务就是 UI 的入口,可以把动态库加载的逻辑收敛到路由内部,这样外部其实并不知道动态库是懒加载的,也能更好地容错。功能库(比如上图的 QR.framework)会有些不一样,因为没有 UI 等入口,需要功能库自己维护 Wrapper

  • App 对 Wrapper直接依赖,这样外部并不知道这个动态库是懒加载的
  • Wrapper 内部封装了动态调用逻辑,动态调用指的是通过 dlsym 等方式调用

动态库懒加载除了启动加载的代码减少,还能长期防止业务增加代码引起启动劣化,因为业务的初始化在第一次访问的时候完成的。这个方案还有其他优点,比如动态库化后本地编译时间会大幅度降低,对其他性能指标也有好处,缺点是会牺牲一定程度的包大小,但可以用段压缩等方式优化懒加载的动态库来打平这部分损耗。

Background Fetch

Background Fetch 可以隔一段时间把 App 在后台启动,对于时间敏感的 App(比如新闻)可以在后台刷新数据,这样能够提高 Feed 加载的速度,进而提升用户体验。那么,这种类似“后台保活”的机制,为什么能提高启动速度呢?我们来看一个典型的 case

  1. 系统在后台启动 App
  2. 时间长因为内存等原因,后台的 App 被 kill 了
  3. 这时候用户立刻启动 App,那么这次启动就是一次热启动,因为缓存还在
  4. 又一次系统在后台启动 App
  5. 这次用户在 App 在后台的时候点了 App,那么这次启动就是一次后台回前台,因为 App 仍然活着

通过这两个典型的场景,可以看出来为什么 Background Fetch 能提高启动速度了:

  • 提高热启动在冷启动的占比
  • 后台启动回前台被定义为启动,因为用户的角度来说这就是一次启动

后台启动有一些要注意的点,比如日活,广告,甚至是 AB 进组逻辑都会受影响,需要做不少适配。往往需要启动器来支撑,因为正常启动在 didFinishLaunch 执行的任务,在后台启动的时候需要延迟到第一次回前台的时候再执行。

总结

最后提炼出几点我们认为在任何优化中都重要的:

  • 白盒优化,知道为什么慢,优化的是哪部分。
  • 线上数据都是优化的指南针,也是衡量优化效果的唯一方式,建议开 AB 实验,验证对业务上的影响。
  • 不要忽略防劣化的建设,尤其是业务迭代迅速的团队,否则很有可能优化的速度赶不上劣化。
  • 做长期的架构建设,良好的架构会长期为启动这些基础性能保驾护航。

抖音品质建设 - iOS启动优化《原理篇》

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
禁止转载,如需转载请通过简信或评论联系作者。
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,761评论 5 460
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,953评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,998评论 0 320
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,248评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,130评论 4 356
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,145评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,550评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,236评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,510评论 1 291
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,601评论 2 310
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,376评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,247评论 3 313
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,613评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,911评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,191评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,532评论 2 342
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,739评论 2 335

推荐阅读更多精彩内容