Swift 与 Objective-C 混编优化
Swift 与 Objective-C 混编优化
2019 年 3 月 25 日,苹果发布了 Swift 5.0 版本,宣布了 ABI 稳定,并且Swift runtime 和标准库已经植入系统中,而且苹果新出文档都用 Swift,Sample Code 也是 Swift,可以看出 Swift 是苹果扶持与研发的重点方向。
国内外各大公司都在相继试水,只要关注 Swift 在国内 iOS 生态圈现状,就会发现,Swift 在国内 App 应用的比重逐渐升高。对于新 App 来说,可以直接用纯 Swift 进行开发,而对于老 App 来说,绝大部分以前都是用 OC 开发的,因此 Swift/OC 混编是一个必然面临的问题。
1. Swift 和 OC 混编开发
关于 Swift 和 OC 间如何混编,业内也已经有很多相关文章详细讲解,简单来说 OC/Swift 调用 Swift,最终通过 Swift Module 进行,而 Swift 调用 OC 时,则是通过 Clang Module,当然也可以通过 Clang Module 进行 OC 对 OC 的调用。58同城于 2020 年正式上线首个 Swift/OC(Objective-C,以下简称 OC)项目,与此同时,也在全公司范围内开展了一个多部门协作项目——混天项目,主要目标:
1)一是提供混编的基础设施建设,如提供通过的 Module 化方案;
2)二是扩展各工具链的混编能力,如对无用类检测工具 WBBlades(https://github.com/wuba/WBBlades)进行 Swift 能力的扩展;
3)三是对已有的基础库进行 Module 化和 Swift 适配;
4)四是将混编开发在各 App 和各业务线中推广和落地。
在 Module 化实践中发现,实际数据与苹果官方 Module 编译时间数据不一致,于是通过 Clang 源码和数据相结合的方式对 Clang Module 进行了深入研究,找到了耗时的原因。由于 Swift/OC 混编下需要 Module 化的支持,同时借鉴业内 HeaderMap 方案让 OC 调用 OC 时避开 Module 化调用,将编译时间优化了约 35%,较好地解决了在 Module 化下的编译时间问题。
2. Clang Module 初探
Clang Module 在 2012 LLVM Developers Meeting 上第一次被提出,主要用来解决 C 语言预处理的各种问题。Modules 试图通过隔离特定库的接口并且编译一次生成高效的序列化文件来避免 C 预处理器重复解析 Header 的问题。在探究 Clang Module 之前,先了解一下预处理的前世今生。一个源代码文件到经过编译输出为目标文件主要分为下面几个阶段:
源文件在经过 Clang 前端包含:词法分析(Lexical analysis) 、语法分析(Syntactic analysis) 、语义分析(Semantic analysis)。最后输出与平台无关的 IR(LLVM IR generator)进而交给后端进行优化生成汇编输出目标文件。
词法分析(Lexical analysis)作为前端的第一个步骤负责处理源代码的文本输入,具体步骤就是将语言结构拆分为一组单词和记号(token),跳过注释,空格等无意义的字符,并将一些保留关键字转义为定义好的类型。词法分析过程中遇到源代码 “#“ 的字符,且该字符在源代码行的起始位置,则认为它是一个预处理指令,会调用预处理器(Preprocessor)处理后续。在开发中引入外部文件的 include/import 指令,定义宏 define 等指令均是在预处理阶段交由预处理器进行处理。Clang Module 机制的引入带来的改变着重于解决常规预处理阶段的问题,那么跟随一起来重点探究一下其中的区别和实现原理吧!
3. 普通 import 的机制
Clang Module 机制引入之前,在日常开发中,如果需要在源代码中引入外部的一些定义或者声明,常见的做法就是使用 #import 指令来使用外部的 API。那么这些使用的方式在预处理阶段是怎么处理的呢?针对编译器遇到 #import<PodName/header.h> 或者 #import ”header.h” 这种导入方式时候,# 开头在词法分析阶段会触发预处理(Preprocessor)。而对于 Clang 的预处理器 import 与 include 指令都属于它的关键词。预处理器在处理 import Directive 时候主要工作为通过导入的 header 名称去查找文件的磁盘所在路径,然后进入该文件创建新的词法分析器对导入的头文件进行词法分析。如下所示:编译器在遇到 #import 或者 #include 指令时,触发预处理机制查询头文件的路径,进入头文件对头文件的内容进行解析的流程。
以单个文件编译过程为维度举例:在针对一个文件编译输出目标文件的过程中,可能会引入多个外界的头文件,而被引入多个外界头文件也有可能存在引入外界头文件。这样的情况就导致虽然只是在编译单个文件,但是预处理器会对引入的头文件进行层层展开。这也是很多人称 #import 与 include 是一种特殊“复制”效果的原因。
那么在这种预处理器的机制在工程中编译中会存在什么问题呢?苹果官方在 2012 的 WWDC 视频上同样给了解答:Header Fragility (健壮性)和 Inherently Non-Scalable (不可扩展性)。来看下面一段代码,在 PodBTestObj 类的文件中定义一个 ClassName 字符串的宏,然后在导入 PoBClass1.h 头文件,在 PoBClass1.h 的头文件中同样定义一个结构体名为 ClassName,这里与在 PodBTestObj 类中定义的宏同名。预处理的特殊的“复制”机制,在预处理阶段会发生下图所见的结果:
这样的问题相信在日常开发中并不罕见,而为了解决这种重名的问题,常规的手法只能通过增加前缀或者提前约定规则等方式来解决。
同时指出这种机制在应对大型工程编译过程中的所带来的消耗问题。假设有 N 个源文件的工程,那么每个源文件引用 M 个头文件,由于预处理的这种机制,在针对处理每个源文件的编译过程中会对引入的 M 个头文件进行展开,经历一遍遍的词法分析-语法分析-语义分析的过程。那么能想象一下针对系统头文件的引入在预处理阶段将会是一个多么庞大的开销!
那么针对 C 语言预处理器存在的问题,苹果有哪些方案可以优化这些存在的问题呢?
4. PCH (Precompiled Headers)
PCH(Precompile Prefix Header File)文件,也就是预编译头文件,其文件里的内容能被项目中的其他所有源文件访问。日常开发中,通常放一些通用的宏和头文件,方便编写代码,提高效率。关于 PCH 的概述,苹果是这样定义的:which uses a serialized representation of Clang’s internal data structures, encoded with the LLVM bitstream format.(使用 Clang 内部数据结构序列化表示,采用的 LLVM 字节流表示)。它的设计理念当项目中几乎每个源文件中都包含一组通用的头文件时,将该组头文件写入 PCH 文件中。
在编译项目中的流程中,每个源文件的处理都会首先去加载 PCH 文件的内容,所以一旦 PCH 编译完成,后续源文件在处理引入的外部文件时候会复用 PCH 编译后的内容,从而加快编译速度。PCH 文件中存放所需要的外部头文件的信息(包括不局限于声明、定义等)。它以特殊二进制形式进行存储,而在每个源代码编译处理外部头文件信息时候,不需要每次进行头文件的展开和“复制”重复操作。而只需要“懒加载”预编译好的 PCH 内容即可。
存储内容方面它存放着序列化的 AST 文件。AST 文件本身包含 Clang 的抽象语法树和支持数据结构的序列化表示,它们使用与 LLVM’s bitcode file format. 相同的压缩位流进行存储。关于 AST File 文件的存储结构可以在官方文档有详细的了解。它作为苹果一种优化方案被提出,但是实际的工程中源代码的引用关系是很复杂的,所以找出一组几乎所有源文件都包含的头文件基本不可能,同时对于代码更新维护更是一个挑战。其次在被包含头文件改动下,因为 PCH 会被所有源文件引入,会带来代码“污染”的问题。同时一旦 PCH 文件发生改动,会导致大面积的源代码重编造成编译时间的浪费。
5. Modules
上述简单回顾了一些 C 语言预处理的机制以及为解决编译消耗引入 PCH 的方案,但是在一定程度上 PCH 方案也存在很大的缺陷。因此在 2012 LLVM Developer’s Meeting 首次提出了 Modules 的概念。那么 Module 到底是什么呢?
Module 简单来说可以认为它是对一个组件的抽象描述,包含组件的接口和实现。Module 机制推出主要用来解决上述所阐述的预处理问题,想要探究 Clang Module 的实现,首先需要去开启 Module。那么针对 iOS 工程怎么开启 Module 呢? 只需要打开编译选项中:
对!没看错,仅仅需要在 Xcode 的编译选项中修改配置即可。而在代码的使用上几乎可以不用修改代码,开启 Module 之后,通过引用头文件的方式可以继续沿用 #import <PodName/Header.h> 方式。当然对于开发者也可以采用新的方式 @import ModuleName.SubModuleName,以及 @import ModuleName这几种方式。更为详细的信息和使用方法可以在苹果的官方文档中查看。
6. 苹果对 Module 的解读
上文提到过基于 C 语言预处理器提供的 #include 机制提供的访问外界库 API 的方式存在的伸缩性和健壮性的问题。Modules 提供了更为健壮,更高效的语义模型来替换之前 textual preprocessor 改进对库的 API 访问方式。苹果官方文档中针对 Module 的解读有以下几个优势:
1)扩展性:每个 Module 只会编译一次,将 Module 导入 Translantion unit 的时间是恒定的。对于库 API 的访问只会解析一次,将 #include 的机制下的由 M x N 编译问题简化为 M + N。
2)健壮性:每个 Module 作为一个独立的实体,具备一个一致的预处理环境。不需要去添加下划线,或者前缀等方式解决命名的问题。每个库不会影响另外一个库的编译方式。
翻阅了苹果 WWDC 2013 的 Advances in Objective-C 视频,视频中针对编译时间性能方面进行了 PCH 和 Module 编译速度的数据分析。苹果给出的结论是小项目中 Module 比 PCH 能提升 40% 的编译时间,并且随着工程规模的不断增大,如增大到 Xcode 级别,Module 的编译速度也会比 PCH 稍快。PCH 也是为了加速编译而存在的,由此也可以间接得出结论,Module的编译速度要比没有 PCH 的情况下,是更快的,如在 Mail 下,应该提升 40% 以上。
参考文献链接