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

前言

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

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

基本概念启动的定义

启动有两种定义:

以抖音为例,用户感受到的启动时间:


这是从用户感知维度定义启动,那么代码上如何定义启动呢?Apple在MetricKit中给出了官方计算方式:

起点:进程创建的时间

终点:第一个CA::Transaction::commit()

启动的种类

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

冷启动:系统里没有任何进程的缓存信息,典型的是重启手机后直接启动App

热启动:如果把App进程杀了,然后立刻重新启动,这次启动就是热启动,因为进程缓存还在

回前台:大多数时候不会被定义为启动,因为此时App仍然活着,只不过处于susped状态

那么,线上用户的冷启动多还是热启动多呢?

答案是和产品形态有关系,打开频次越高,热启动比例就越高。

Mach-O

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

Header

LoadCommands

Data


Header的最开始是MagicNumber,表示这是一个Mach-O文件,除此之外还包含一些Flags,这些flags会影响Mach-O的解析。

LoadCommands存储Mach-O的布局信息,比如Segmentcommand和Data中的Segment/Section是一一对应的。除了布局信息之外,还包含了依赖的动态库等启动App需要的信息。

Data部分包含了实际的代码和数据,Data被分割成很多个Segment,每个Segment又被划分成很多个Section,分别存放不同类型的数据。

标准的三个Segment是TEXT,DATA,LINKEDIT,也支持自定义:

TEXT,代码段,只读可执行,存储函数的二进制代码(__text),常量字符串(__cstring),ObjectiveC的类/方法名等信息

DATA,数据段,读写,存储ObjectiveC的字符串(__cfstring),以及运行时的元数据:class/protocol/method…

LINKEDIT,启动App需要的信息,如bindrebase的地址,代码签名,符号表…

dyld

dyld是启动的辅助程序,是in-process的,即启动的时候会把dyld加载到进程的地址空间里,然后把后续的启动过程交给dyld。dyld主要有两个版本:dyld2和dyld3。

dyld2是从引入,一直持续到iOS12。dyld2有个比较大的优化是dyldsharedcache,什么是sharedcache呢?

sharedcache就是把系统库(UIKit等)合成一个大的文件,提高加载性能的缓存文件。

iOS13开始Apple对三方App启用了dyld3,dyld3的最重要的特性就是启动闭包,闭包里包含了启动所需要的缓存信息,从而提高启动速度。

虚拟内存

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

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


iPhone6s开始,物理内存的Page大小是16K,6和之前的设备都是4K,这是iPhone6相比6s启动速度断崖式下降的原因之一。

mmap

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

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


PageIn

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


MMU找到空闲的物理内存页面

触发磁盘IO,把数据读入物理内存

如果是TEXT段的页,要进行解密

对解密后的页,进行签名验证

其中解密是大头,IO其次。

为什么要解密呢?因为iTunesConnect会对上传Mach-O的TEXT段进行加密,防止IPA下载下来就直接可以看到代码。这也就是为什么逆向里会有个概念叫做“砸壳”,砸的就是这一层TEXT段加密。iOS13对这个过程进行了优化,PageIn的时候不需要解密了。

二进制重排

既然PageIn耗时,有没有什么办法优化呢?启动具有局部性特征,即只有少部分函数在启动的时候用到,这些函数在二进制中的分布是零散的,所以PageIn读入的数据利用率并不高。如果我们可以把启动用到的函数排列到二进制的连续区间,那么就可以减少PageIn的次数,从而优化启动时间:

以下图为例,方法1和方法3是启动的时候用到的,为了执行对应的代码,就需要两次PageIn。假如我们把方法1和3排列到一起,那么只需要一次PageIn,从而提升启动速度。


链接器ld有个参数-order_file支持按照符号的方式排列二进制。获取启动时候用到的符号的有很多种方式,感兴趣的同学可以看看抖音之前的文章:基于二进制文件重排的解决方案APP启动速度提升超15%。

IPA构建pipeline

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

BuildPhase:以Target为维度定义了构建的流程。可以在BuildPhase中插入脚本,来做一些定制化的构建,比如CocoaPod的拷贝资源就是通过脚本的方式完成的。

BuildSettings:配置编译和链接相关的参数。特别要提到的是otherlinkflags和othercflags,因为编译和链接的参数非常多,有些需要手动在这里配置。很多项目用的CocoaPod做的组件化,这时候编译选项在对应的.xcconfig文件里。

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

源文件(.m/.c/.swift等)是单独编译的,输出对应的目标文件(.o)

目标文件和静态库/动态库一起,链接出最后的Mach-O

Mach-O会被裁剪,去掉一些不必要的信息

资源文件如storyboard,asset也会编译,编译后加载速度会变快

Mach-O和资源文件一起,打包出最后的.app

对.app签名,防篡改


编译

编译器可以分为两大部分:前端和后端,二者以IR(中间代码)作为媒介。这样前后端分离,使得前后端可以独立的变化,互不影响。C语言家族的前端是clang,swift的前端是swiftc,二者的后端都是llvm。

前端负责预处理,词法语法分析,生成IR

后端基于IR做优化,生成机器码


那么如何利用编译优化启动速度呢?

代码数量会影响启动速度,为了提升启动速度,我们可以把一些无用代码下掉。那怎么统计哪些代码没有用到呢?可以利用LLVM插桩来实现。

LLVM的代码优化流程是一个一个Pass,由于LLVM是开源的,我们可以添加一个自定义的Pass,在函数的头部插入一些代码,这些代码会记录这个函数被调用了,然后把统计到的数据上传分析,就可以知道哪些代码是用不到的了。

Facebook给LLVM提的order_file的feature就是实现了类似的插桩。

链接

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


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

举一个基于链接优化启动速度的例子:

最开始讲解PageIn的时候,我们提到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),是因为里面有些信息,如调试符号,是不需要带到线上去的。裁剪有多种级别,一般的配置如下:

AllSymbols,主二进制

Non-GlobalSymbols,动态库

DebuggingSymbols,二方静态库

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

签名上传

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

dyld3启动流程

Apple在iOS13上对第三方App启用了dyld3,官方数据显示,过去四年新发布的设备中有93%的设备是iOS13,所以我们重点看下dyld3的启动流程。

Beforedyld

注意这个过程都是在内核态完成的,这里提到了PC寄存器,PC寄存器存储了下一条指令的地址,程序的执行就是不断修改和读取PC寄存器来完成的。

dyld创建启动闭包

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

闭包是怎么提升启动速度的呢?我们先来看一下闭包里都有什么内容:

deps,依赖动态库列表

fixup:bindrebase的地址

initializer-order:初始化调用顺序

optimizeObjc:ObjectiveC的元数据

其他:mainentry,uuid…

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


为什么闭包能提高启动速度呢?

因为这些信息是每次启动都需要的,把信息存储到一个缓存文件就能避免每次都解析,尤其是ObjectiveC的运行时数据(Class/Method)解析非常慢。

fixup

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

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

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

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

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

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

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


LibSystemInitializer

BindRebase之后,首先会执行LibSystem的Initializer,做一些最基本的初始化:

初始化libdispatch

初始化objcruntime,注册sel,加载category

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

LoadStaticInitializer

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

那么,如何找到代码里有哪些load和staticinitializer呢?

在BuildSettings里可以配置writelinkmap,这样在生成的linkmap文件里就可以找到有哪些文件里包含load或者staticinitializer:

__mod_init_func,staticinitializer

__objc_nlclslist,实现+load的类

__objc_nlcatlist,实现+load的Category

load举例

如果+load方法里的内容很简单,会影响启动时间么?比如这样的一个+load方法?

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

编译完了之后,这个函数会在二进制中的TEXT两个段存在:__text存函数二进制,cstring存储字符串1234。为了执行函数,首先要访问__text触发一次PageIn读入物理内存,为了打印字符串,要访问__cstring,还会触发一次PageIn。

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


staticinitializer产生的条件

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

__attribute__((constructor))

staticclassobject

staticobjectinglobalnamespace

注意,并不是所有的static变量都会产生静态初始化,编译器很智能,对于在编译期间就能确定的变量是会直接inline。

//会产生静态初始化classDemo{staticconststd::stringvar_1;};conststd::stringvar_2="1234";staticLoggerlogger;//不会产生静态初始化staticconstintvar_3=4;staticconstchar*var_4="1234";

std::string会合成staticinitializer是因为初始化的时候必须执行构造函数,这时候编译器就不知道怎么做了,只能延迟到运行时~

UIKitInit

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

初始化UIApplication

启动主线程的Runloop

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

Runloop

线程在执行完代码就会退出,很明显主线程是不能退出的,那么就需要一种机制:事件来的时候执行任务,否则让线程休眠,Runloop就是实现这个功能的。

Runloop本质上是一个While循环,在图中橙色部分的mach_msg_trap就是触发一个系统调用,让线程休眠,等待事件到来,唤醒Runloop,继续执行这个while循环。

Runloop主要处理几种任务:Source0,Source1,Timer,GCDMainQueue,Block。在循环的合适时机,会以Observer的方式通知外部执行到了哪里。


那么,Runloop与启动又有什么关系呢?

App的LifeCycle方法是基于Runloop的Source0的

首帧渲染是基于RunloopBlock的


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

精准统计启动时间

找到一个时机,在启动结束去执行一些预热任务

利用Runloop打散耗时的启动预热任务

AppLifeCycle

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

willFinishLaunch

didFinishLaunch

didFinishLaunchNotification

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

FirstFrameRer

一般会用RootController的viewDidApper作为渲染的终点,但其实这时候首帧已经渲染完成一小段时间了,Apple在MetricsKit里对启动终点定义是第一个CA::Transaction::commit()。

什么是CATransaction呢?我们先来看一下渲染的大致流程


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

渲染可以分为四个步骤

Layout(布局),源头是RootLayer调用[CALayerlayoutSubLayers],这时候UIViewController的viewDidLoad和LayoutSubViews会调用,autolayout也是在这一步生效

Display(绘制),源头是RootLayer调用[CALayerdisplay],如果View实现了drawRect方法,会在这个阶段调用

Prepare(准备),这个过程中会完成图片的解码

Commit(提交),打包RerTree通过XPC的方式发给RerServer



启动Pipeline

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


mmap主二进制,找到dyld的路径

mmapdyld,把入口地址设为_dyld_start

重启手机/更新/下载App的第一次启动,会创建启动闭包

把没有加载的动态库mmap进来,动态库的数量会影响这个阶段

对每个二进制做bind和rebase,主要耗时在PageIn,影响PageIn数量的是objc的元数据

初始化objc的runtime,由于闭包已经初始化了大部分,这里只会注册sel和装载category

+load和静态初始化被调用,除了方法本身耗时,这里还会引起大量PageIn

初始化UIApplication,启动MainRunloop

执行will/didFinishLaunch,这里主要是业务代码耗时

Layout,viewDidLoad和Layoutsubviews会在这里调用,Autolayout太多会影响这部分时间

Display,drawRect会调用

Prepare,图片解码发生在这一步

Commit,首帧渲染数据打包发给RerServer,启动结束

dyld2

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

解析动态库的依赖关系

解析LINKEDIT,找到bindrebase的指针地址,找到bind符号的地址

注册objc的Class/Method等元数据,对大型工程来说,这部分耗时会很长

总结

本文回顾了Mach-O,虚拟内存,mmap,PageIn,Runloop等基础概念,接下来介绍了IPA的构建流程,以及两个典型的利用编译器来优化启动的方案,最后详细的讲解了dyld3的启动pipeline。

之所以花这么大篇幅讲原理,是因为任何优化都一样,只有深入理解系统运作的原理,才能找到性能的瓶颈,下一篇我们会介绍下如何利用这些原理解决实际问题。

加入我们

我们是负责抖音客户端基础能力研发和新技术探索的团队。我们在工程/业务架构,研发工具,编译系统等方向深耕,支撑业务快速迭代的同时,保证超大规模团队的研发效能和工程质量。在性能/稳定性等方面不断探索,努力为全球数亿用户提供最极致的基础体验。

如果你对技术充满热情,欢迎加入抖音基础技术团队,让我们共建亿级全球化App。目前我们在上海、北京、杭州、深圳均有招聘需求,内推可以联系邮箱:tech@;邮件标题:姓名-工作年限-抖音-基础技术-iOS/Android。

更多分享

字节跳动全链路压测(Rhino)的实践

Fastbot:行进中的智能Monkey

简历投递联系邮箱「tech@」

免责声明:本文章如果文章侵权,请联系我们处理,本站仅提供信息存储空间服务如因作品内容、版权和其他问题请于本站联系