iOS 编译全过程,如何提升编译速度以及内存优化

606 阅读10分钟

1.编译过程介绍

1.1.从编译器的角度来看,实际就是将高级语言转换为机器能识别的语言,一般来说,这里需要经历三个大阶段

前端(Frontend)--优化器(Optimizer)--后端(Backend)

其中前端负责分析源代码,可以检查语法级错误,并构建针对该语言的抽象语法树(AST)

抽象语法树可以进一步转换为优化,最终转为新的表示方式, 然后再交给让优化器和后端处理

最终由后端生成可执行的机器码.

1.2.从Xcode的编译流程来看,基本就是如下的流程:

   源代码(source code)-> 预处理器(preprocessor)-> 编译器(compiler)-> 汇编程序(assembler) -> 目标代码(object code)-> 链接器(Linker)-> 可执行文件(executables)

1.3.Xcode就是采用的Clang作为编译器前端,LLVM作为编译器后端的设计

如果需要了解项目具体的编译过程,可以直接编译查看Xcode左侧的tab,下面这篇文章就做了很详细的说明

juejin.cn/post/685992… (好文章)

juejin.cn/post/684490…

juejin.cn/post/704899…  这篇文章主要讲述了编译的原理

blog.csdn.net/Hello_Hwc/a…  深入浅出iOS编译

2.客户端启动时间优化(二进制重排)

www.jianshu.com/p/83975aad3…   戴铭iOS开发高手课启动优化

blog.csdn.net/Hello_Hwc/a…

total(App总时间) = t1(main()之前加载时间) + t2(main()之后的加载时间)

t1 = 系统dylib(动态链接库) + 自身App可执行文件的加载,这里的时间优化主要还是采用二进制重排的方式以及减少一些资源的加载

t2 = main方法执行之后到Appdelegate类中的 didFinishLaunchingWithOptions 执行方法结束前这段时间,这里主要构件第一个页面并且渲染出来,也就是tabbar的初始化和首页渲染,这里主要就是注意一些非必要的逻辑不需要放在这的尽量移除。

这个环节的时间耗损详情,也可以参照上面的文章,说的很详细!

2.1.接下来还是主要针对二进制重排说一下我的想法

    早期的计算机中,并没有虚拟内存的概念,任何应用被从磁盘中加载到运行内存中时,都是完整加载和按序排列的,这样存在两大问题!

    a)安全问题 : 由于在内存条中使用的都是真实物理地址 , 而且内存条中各个应用进程都是按顺序依次排列的 . 那么在 进程1 中通过地址偏移就可以访问到 其他进程 的内存 .

    b)效率问题 : 随着软件的发展 , 一个软件运行时需要占用的内存越来越多 , 但往往用户并不会用到这个应用的所有功能 , 造成很大的内存浪费 , 而后面打开的进程往往需要排队等待.

    所以就诞生了虚拟内存。通过虚拟内存地址找到映射表,然后映射表映射后才可以获得真实的物理地址,从而找到存储的数据,而这个过程也叫做cpu寻址

    这里需要介绍一下ASLR,其原理就是每次虚拟地址在映射真实地址之前,增加一个随机偏移值,用来解决通过虚拟地址按照偏移量计算得到真实的物理地址.

    那么这些和二进制重排有什么关系呢?

    实际上在 iOS 系统中 , 对于生产环境的应用 , 当产生缺页中断进行重新加载时 , iOS 系统还会对其做一次签名验证 . 因此 iOS 生产环境的应用 page fault 所产生的耗时要更多。

    1.原理:假设在启动时期我们需要调用两个函数 method1 与 method4 . 函数编译在 mach-o 中的位置是根据 ld ( Xcode 的链接器) 的编译顺序并非调用顺序来的 . 因此很可能这两个函数分布在不同的内存页上 .那么启动时 , page1 与 page2 则都需要从无到有加载到物理内存中 , 从而触发两次 page fault .

    而二进制重排的做法就是将 method1 与 method4 放到一个内存页中 , 那么启动时则只需要加载 page1 即可 , 也就是只触发一次 page fault , 达到优化目的.而实际项目中的做法就是将启动时需要调用的函数都放在一起,以尽可能减少page fault,达到优化目的,而这种做法就叫做二进制重排

    

     2.如何来实现呢?

     a)首先我们可以设置Write Link Map File,通过观察Link Map这个编译的产物,其中# sysbols:部分就是编译的函数调用顺序。

     b)二进制重排并非只是修改符号地址 , 而是利用符号顺序 , 重新排列整个代码在文件的偏移地址 , 将启动需要加载的方法地址放到前面内存页中 , 以此达到减少 page fault 的次数从而实现时间上的优化。所以在项目的跟路径,我们创建一个文件 touch change.order,然后我们可以通过修改mapfile text中的符号顺序,在build setting设置order file文件路径,然后重新编译,观察最新的build map file,你会发现其中的符号表是按照我们设置的order中的符号而定的

     c)如何获取启动加载所有函数的符号呢?接下来就要说到clang插桩了。。。

     

     参照:juejin.cn/post/684490… (好文章)

          juejin.cn/post/684490…

     

2.2.Clang静态插桩

      总结:静态插桩实际上是在编译期就在每一个函数内部二进制源数据添加 hook 代码 ( 我们添加的 __sanitizer_cov_trace_pc_guard 函数 ) 来实现全局的方法 hook 的效果 .

llvm内置了一个简单的代码覆盖率检测(SanitizerCoverage)。它在函数级、基本块级和边缘级插入对用户定义函数的调用。我们这里的批量hook,就需要借助于SanitizerCoverage。

      这里有一些很详细的clang静态插桩的说明

      juejin.cn/post/684490…

      www.jianshu.com/p/d4baff644…

   

3.dyld--动态链接器(iOS启动时到main实现之前的操作)

    dyld(the dynamic link editor),Apple 的动态链接器,系统 kernel 做好启动程序的初始准备后,交给 dyld 负责,援引并翻译《 Mike Ash 这篇 blog 》对 dyld 作用顺序的概括

    简单来说:dyld在程序运行时它先将动态链接的image(可执行文件)递归加载,再从可执行文件image递归加载所有的符号,具体话的步骤如下:

    a)dyld 开始将程序二进制文件初始化

    b)交由 ImageLoader 读取 image,其中包含了我们的类、方法等各种符号

    c)由于 runtime 向 dyld 绑定了回调,当 image 加载到内存后,dyld 会通知 runtime 进行处理

    d)runtime 接手后调用 map_images 做解析和处理,接下来 load_images 中调用 call_load_methods 方法,遍历所有加载进来的 Class,按继承层级依次调用 Class 的 +load 方法和其 Category 的 +load 方法

    至此,可执行文件中和动态库所有的符号(Class,Protocol,Selector,IMP,…)都已经按格式成功加载到内存中,被 runtime 所管理,再这之后,runtime 的那些方法(动态添加 Class、swizzle 等等才能生效)

    参照:zhuanlan.zhihu.com/p/353980034

    

4.dSYM(crash日志处理)

   定义:在每次编译后都会生成一个 dSYM 文件,程序在执行中通过地址来调用方法函数,而 dSYM 文件里存储了函数地址映射,这样调用栈里的地址可以通过 dSYM 这个映射表能够获得具体函数的位置。一般都会用来处理 crash 时获取到的调用栈 .crash 文件将其符号化。

   作用: 当release的版本 crash的时候,会有一个日志文件,包含出错的内存地址, 使用symbolicatecrash工具能够把日志和dSYM文件转换成可以阅读的log信息,也就是将内存地址,转换成程序里的函数或变量和所属于的 文件名.(如何设置 release 版本? Product -> scheme -> EditScheme)

   如何找到:/Users/用户名/Library/Developer/Xcode/DerivedData/littleTest-fcueraakmygtmodagachcreqjbjw/Build/Products/Release

   

   疑问:如何监控线上的crash调用栈变成可理解的崩溃信息呢?

5.Mach-O文件格式分析

   含义:Mach-O,是Mach object文件格式的缩写,是一种可执行文件、目标代码、共享程序库、动态加载代码和核心DUMP。是a.out格式的一种替代。Mach-O 提供更多的可扩展性和更快的符号表信息存取。Mach-O应用在基于Mach核心的系统上,目前NeXTSTEP、Darwin、Mac OS X(iPhone)都是使用这种可执行文件格式。

    记录编译后的可执行文件,对象代码,共享库,动态加载代码和内存转储的文件格式。不同于 xml 这样的文件,它只是二进制字节流,里面有不同的包含元信息的数据块,比如字节顺序,cpu 类型,块大小等。文件内容是不可以修改的,因为在 .app 目录中有个 _CodeSignature 的目录,里面包含了程序代码的签名,这个签名的作用就是保证签名后 .app 里的文件,包括资源文件,Mach-O 文件都不能够更改

    参照:

      www.jianshu.com/p/54d842db3…  Mach-O:文件格式分析

      mp.weixin.qq.com/s/I60p2M-IH…  Mach-O和动态库的加载过程

 

6.编译过程的优化,包含编译的时间等

首先我们需要了解一下iOS的编译过程,然后针对这些过程中做一些方案设计,达到优化的目的。

解决方案:

6.1.工程组件化

第一个优化是把整个工程的编译过程打散,把代码按照业务线拆分成一个个独立的子工程,每个子工程的编译过程都是独立的。每个子工程只需要保证自己工程的源码能够编译成功,对外输出统一的静态库和资源文件包的产物。这个产物我们叫做 Bundle。整个build过程变成了单工程compile,多工程link,大大减少了Build过程中的compile花费的时间.

好处:1)对于开发人员,每个业务开发只需要把自己这个子工程切为源码引用,把其他非自己模块的子工程全部用静态库依赖,本地编译也只需要编译自己的子工程,可以大大提升本地开发编译速度。

     2)对于测试人员,打包过程就变成了把所有已经编译好的子Bundle静态库链接到一个壳工程里,不需要对每个文件进行编译,可以很快的打包测试验证

6.2.组件库的二进制化,加快编译速度:其实就是打包成动态库/静态库。

由于过多的动态库会导致启动速度减慢得不偿失,此外iOS对于动态库的表现形式只有framework。若想做源码与二进制切换时,引入头文件的地方也不得不进行更改!!!而如果只是打包成静态库.a文件则不需要更改引用代码.所以综上所述,我们选择打包成静态库的方式不需修改引用代码、缩小体积提升编译速度。

6.3.增量编译

  1)合并有变动的文件

  • 打包任务会根据新的 commitId 下载一份代码副本,不能直接使用该副本,因为代码文件内容没有变动,仅仅是文件属性的变动也会导致 xcodebuild 缓存不生效。因此需要副本和工作区内的源码做 diff,仅仅合并内容有变动的文件。

  • 使用 python 的 filecmp 实现合并代码逻辑,并且支持配置 ignore。

  • xcodebuild 指定 -derivedDataPath 设置缓存路径,并将该目录配置到 diff ignore 中。

   2)提供清除缓存的功能

  • xcodebuild 的缓存有时候会出问题,比如修改了 c++ 文件后有时并不会生效,这种需要提供清除缓存的功能,可以由开发自由选择使用。

blog.csdn.net/icefishlily…  增量编译,CCache的实现

juejin.cn/post/684490… 组件二进制方案

blog.csdn.net/ctrip_tech/…  携程开发体验优化

  疑问1:如何实现二进制调试?

7.内存优化