抖音品格建设 - iOS启动优化之原理篇

打印 上一主题 下一主题

主题 809|帖子 809|积分 2427

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-O 的 TEXT 段进行加密,防止 IPA 下载下来就直接可以看到代码。这也就是为什么逆向里会有个概念叫做“砸壳”,砸的就是这一层 TEXT 段加密。iOS 13 对这个过程进行了优化,Page In 的时候不必要解密了
二进制重排

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

pipeline

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


  • Build Phase:以 Target 为维度定义了构建的流程。可以在 Build Phase 中插入脚本,来做一些定制化的构建,好比 CocoaPod 的拷贝资源就是通过脚本的方式完成的。
  • Build Settings:设置编译和链接相关的参数。特殊要提到的是 other link flags 和 other c flags,因为编译和链接的参数非常多,有些必要手动在这里设置。许多项目用的 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[2]的 feature 就是实现了雷同的插桩。
链接

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



  • tbd 的全称是 text-based stub library,是因为链接的过程中只必要符号就可以了,所以 Xcode 6 开始,像 UIKit 等系统库就不提供完整的 Mach-O,而是提供一个只包含符号等信息的 tbd 文件。
举一个基于链接优化启动速度的例子:
最开始教学 Page In 的时候,我们提到 TEXT 段的页解密很耗时,有没有办法优化呢?
可以通过 ld 的-rename_p,把 TEXT 段中的内容,好比字符串移动到其他的段(启动路径上不免会读许多字符串),从而规避这个解密的耗时
抖音的重定名方案:
“-Wl,-rename_p,__TEXT,__cstring,__RODATA,__cstring”,
“-Wl,-rename_p,__TEXT,__const,__RODATA,__const”,
“-Wl,-rename_p,__TEXT,__gcc_except_tab,__RODATA,__gcc_except_tab”,
“-Wl,-rename_p,__TEXT,__objc_methname,__RODATA,__objc_methname”,
“-Wl,-rename_p,__TEXT,__objc_classname,__RODATA,__objc_classname”,
“-Wl,-rename_p,__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 优化了加密过程,不会对包大小和启动耗时有影响)**。
dyld3 启动流程

Apple 在 iOS 13 上对第三方 App 启用了 dyld3,官方数据[3]显示,已往四年新发布的设备中有 93%的设备是 iOS 13,所以我们重点看下 dyld3 的启动流程。
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-O 做 fixup,包括 Rebase 和 Bind。


  • Rebase:修复内部指针。这是因为 Mach-O 在 mmap 到虚拟内存的时候,起始地址会有一个随机的偏移量 slide,必要把内部的指针指向加上这个 slide。
  • Bind:修复外部指针。这个比力好理解,因为像 printf 等外部函数,只有运行时才知道它的地址是什么,bind 就是把指针指向这个地址。
举个例子:一个 Objective C 字符串@“1234”,编译到末了的二进制的时候是会存储在两个 p 里的


  • __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 之后,首先会执行 LibSystem 的 Initializer,做一些最基本的初始化:


  • 初始化 libdispatch
  • 初始化 objc runtime,注册 sel,加载 category
注意这里没有初始化 objc 的类方法等信息,是因为启动闭包的缓存数据已经包含了 optimizeObjc。
Load & Static Initializer
接下来会进行 main 函数之前的一些初始化,主要包括+load 和 static initializer。这两类初始化函数都有个特点:调用顺序不确定,和对应文件的链接顺序有关系。那么就会存在一个隐藏的坑:有些注册逻辑在+load 里,对应会有一些地方读取这些注册的数据,假如在+load 中读取,很有可能读取的时候还没有注册。
那么,怎样找到代码里有哪些 load 和 static initializer 呢?
在 Build Settings 里可以设置 write linkmap,如许在生成的 linkmap 文件里就可以找到有哪些文件里包含 load 大概 static initializer:


  • __mod_init_func,static initializer
  • __objc_nlclslist,实现+load 的类
  • __objc_nlcatlist,实现+load 的 Category
load 举例
假如+load 方法里的内容很简单,会影响启动时间么?好比如许的一个+load 方法?
+ (void)load { printf(“1234”); }
编译完了之后,这个函数会在二进制中的 TEXT 两个段存在:__text存函数二进制,cstring存储字符串 1234。为了执行函数,首先要访问__text触发一次 Page In 读入物理内存,为了打印字符串,要访问__cstring,还会触发一次 Page In。


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

static initializer 产生的条件
静态初始化是从哪来的呢?以下几种代码会导致静态初始化


  • __attribute__((constructor))
  • static class object
  • static object in global namespace
注意,并不是所有的 static 变量都会产生静态初始化,编译器很智能,对于在编译期间就能确定的变量是会直接 inline。
//会产生静态初始化
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”;
std::string 会合成 static initializer 是因为初始化的时候必须执行构造函数,这时候编译器就不知道怎么做了,只能延迟到运行时~
UIKit Init

+load 和 static initializer 执行完毕之后,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,GCD MainQueue,Block。在循环的符合时机,会以 Observer 的方式通知外部执行到了哪里。
那么,Runloop 与启动又有什么关系呢?


  • App 的 LifeCycle 方法是基于 Runloop 的 Source0 的
  • 首帧渲染是基于 Runloop Block 的
Runloop 在启动上主要有几点应用:


  • 精准统计启动时间
  • 找到一个时机,在启动竣事去执行一些预热任务
  • 利用 Runloop 打散耗时的启动预热任务
   Tips : 会有一些逻辑要在启动之后 delay 一小段时间再回到主线程上执行,对于性能较差的设备,主线程 Runloop 可能不停处于忙的状态,所以这个 delay 的任务并不一定能按时执行。
  AppLifeCycle

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


  • willFinishLaunch
  • didFinishLaunch
  • didFinishLaunchNotification
要特殊提一下 didFinishLaunchNotification,是因为大家在埋点的时候通常会忽略另有这个通知的存在,导致把这部门时间算到 UI 渲染里。
First Frame Render

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

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


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

启动 Pipeline

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


  • 点击图标,创建历程
  • mmap 主二进制,找到 dyld 的路径
  • mmap dyld,把入口地址设为_dyld_start
  • 重启手机/更新/下载 App 的第一次启动,会创建启动闭包
  • 把没有加载的动态库 mmap 进来,动态库的数量会影响这个阶段
  • 对每个二进制做 bind 和 rebase,主要耗时在 Page In,影响 Page In 数量的是 objc 的元数据
  • 初始化 objc 的 runtime,由于闭包已经初始化了大部门,这里只会注册 sel 和装载 category
总结:

面试是一个不停学习、不停自我提升的过程,有时机还是出去面面,至少能想到查漏补缺效果,而且有些知识点,可能你自以为知道,但让你说,并不一定能说得很好。
   有些东西有压力才有动力,而学到的知识点,都是钱(因为技能职员大部门环境是根据你的能力来定级、来发薪水的),技多不压身。
  附上我的面试各大专题整理: 面试指南,满满的都是干货,希望对大家有资助!

《Android学习条记总结+移动架构视频+大厂面试真题+项目实战源码》点击传送门,即可获取!
ng)

  • 点击图标,创建历程
  • mmap 主二进制,找到 dyld 的路径
  • mmap dyld,把入口地址设为_dyld_start
  • 重启手机/更新/下载 App 的第一次启动,会创建启动闭包
  • 把没有加载的动态库 mmap 进来,动态库的数量会影响这个阶段
  • 对每个二进制做 bind 和 rebase,主要耗时在 Page In,影响 Page In 数量的是 objc 的元数据
  • 初始化 objc 的 runtime,由于闭包已经初始化了大部门,这里只会注册 sel 和装载 category
总结:

面试是一个不停学习、不停自我提升的过程,有时机还是出去面面,至少能想到查漏补缺效果,而且有些知识点,可能你自以为知道,但让你说,并不一定能说得很好。
   有些东西有压力才有动力,而学到的知识点,都是钱(因为技能职员大部门环境是根据你的能力来定级、来发薪水的),技多不压身。
  附上我的面试各大专题整理: 面试指南,满满的都是干货,希望对大家有资助!
[外链图片转存中…(img-Wojafow6-1715314132409)]
《Android学习条记总结+移动架构视频+大厂面试真题+项目实战源码》点击传送门,即可获取!

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x
回复

使用道具 举报

0 个回复

倒序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

商道如狼道

金牌会员
这个人很懒什么都没写!

标签云

快速回复 返回顶部 返回列表