Xcode 构建幕后原理

Xcode 10 中的 new build system 是使用 Swift 从零开始编写实现的,其在性能和可靠性方面有着显著的提升。

本文,我们将来探索一下 Xcode 构建的幕后原理,这一切都是从在我们点击了 Xcode 的 “build” 按钮(或按下了快捷键 Command + B)之后开始的。

下面,我们将以一个名为 PetWall 的 iOS app 项目作为例子来进行介绍。

概述

事实上,Xcode 的构建过程的本质就是 执行一系列构建任务,比如:编译链接源代码、拷贝资源(图片、storyboard 等)、代码签名、代码静态检查等。其中,绝大多数任务的本质就是 执行命令行工具,比如:clangldactoolcodesign 等,如下所示。基于 Xcode 的项目配置信息,这些工具会 选择特定的参数、按照特定的顺序 来执行。而构建系统(build system)的职责就是 创建任务并调度执行

1
2
3
4
5
$ swiftc -module-name PetWall -target arm64-apple-ios12.0 -swift-version 4.2 ...
$ clang -x objective-c -arch arm64 ... PetViewController.m -o PetViewController.o
$ ld -o PetWall -framework PetKit PetViewController.o ...
$ actool --app-icon AppIcon ... Assets.xcassets
$ ...

下面我们从构建系统如何确定任务的执行顺序说起。

构建系统

任务依赖

事实上,构建任务的顺序是根据任务之间的依赖关系而确定的。根据任务的输入和输出就可以分析任务之间的依赖关系,举个例子,对于编译任务而言,以源代码文件作为输入(如:PetController.m),以目标文件作为输出(如:PetController.o);对于链接任务而言,以多个编译任务所输出的目标文件作为输入(如:PetController.oPetView.oPeteViewController.o),以可执行文件或库作为输出(如:PetWall)。

由此,我们可以得出如下所示的任务依赖关系。很显然,编译任务可以并行执行,而链接任务依赖了编译任务的输出,所以需要在编译任务之后执行。

在实际情况中,当我们点击 “Build” 按钮后,Xcode 构建系统会解析 Xcode project 文件,分析项目中的所有文件、目标和依赖关系,结合 build setting,最终得到一个树形结构的 定向图(directed graph),如下图所示,其详细描述了项目构建中所有的依赖关系。

然后,低级执行引擎(low-level execution engin)会根据定向图来执行任务。有些任务可以并行执行,有些任务则有先后顺序,如下图所示:

注意:Xcode new build system 使用的低级执行引擎是 llbuild,这是一个开源项目,详见 传送门

依赖记录

对于开发者而言,很难发现太多的依赖信息,但是对于 Xcode 而言,它能够在任务执行过程中找到更多的信息。比如,clang 在编译 OC 文件时会生成一个以 .d 后缀的文件,该文件记录了对应 OC 文件包含的所有头文件信息。下一次构建时,构建系统会根据 .d 文件检查其所依赖的头文件是否存在修改,如果存在修改,那么将重新编译此 OC 文件。这是构建系统通过利用过往的依赖记录进行优化的一种手段。

上图示例中,当 clang 编译 PetController.m 时会生成 PetController.oPetController.dPetController.d 记录了 PetController.m 所包含的头文件。当下一次编译时,如果检测到 PetController.d 中记录的头文件发生了变化,构建系统会重新编译 PetController.m

增量构建

在 Xcode 构建系统中,增量构建是一个非常重要的功能,能够极大地提升构建效率。增量构建是基于精准的依赖信息来检测用户的修改,具体细节如下:

  • 在构建时为每个任务生成一个签名。该签名是基于任务输入的统计信息(如修改时间、文件路径)的哈希值。
  • 在下一次构建时,构建系统校验对每个任务的前后两次签名,从而决定是否需要重新执行。

举个例子,如果用户只修改了 PetViewController.m,那么构建系统只会重新执行 3 个任务,从而实现了增量构建。

Clang

Clang 是 Apple 的 C 语言家族的官方编译器,支持 C、C++、Objective-C、Objective-C++。

在 Objective-C 中,头文件 .h 是一个承诺,表示该实现 .m 存在于其他位置。如果仅修改 .h 文件,在里面添加新的函数 A,而未在 .m 文件中实现该函数,那么就没有遵循承诺。 随后,我们在 B 类中使用函数 A。这在编译期间不会报错,因为编译器相信我们约定的承诺,但是在链接过程中,就会出现 symbol undefined error

这里可能会有一个问题:Clang 是如何查找头文件的?事实上,用户头文件和系统头文件的查找方式所有不同,下文我们将通过 PetWall 的例子分别进行介绍。PetWall 是一个混编项目,其组成大致如下:

  • 主体部分使用 Swift 编写
  • PetKit 库使用 Objective-C 编写
  • PetSupport 库使用 C++ 编写

Clang 如何查找用户头文件

对于用户头文件,Xcode 构建系统是从 header map 文件开始查找头文件。

Header Map

那么 header map 到底是什么呢?Header map 本质上记录了头文件的位置。如下例子中,PetKit-project-headers.hmap 是面向 Project 的 header map,PetKit-own-target-headers.hmap 是面向 Target 的 header map。

Project 和 Target 关系:Project 是一系列相关 Target 的集合,包含了所有 Target 的文件,每个 Target 的文件都是 Project 文件的子集。

PetKit-project-headers.hmap 的前两项 PetKit.hCat.h,header map 仅仅是给它们加上了框架的名称,分别得到 PetKit/PetKit.hPetKit/Cat.h,这两个是共有头文件。这样的映射能够使现有项目正常运行,但是对于 Clang Module 可能会有遇到一些问题,下文将介绍其中的原因。

PetKit-project-headers.hmap 的第三项 CatLogic_Private.h 是一个项目头文件。header map 将它指向原始位置。

对于 PetKit-own-target-headers.hmap,所有的头文件都指向对应的原始位置。

我的理解是,如果通过 import "PetKit.h" 的方式导入,Clang 通过 PetKit-project-headers.hmap 转换成 import "PetKit/PetKit.h",然后 Clang 再通过 PetKit-own-target-headers.hmap 转换实际的位置,即 /x/src/PetKit/PetKit/Cat/Cat.h

Clang 如何查找系统头文件

Header map 只是用于 Clang 查找用户头文件。对于系统头文件,它们并没有 header map,因此 Clang 直接从下面这两个目录中开始查找。事实上,在查找用户头文件时,如果通过 header map 没有找到头文件,也会采取与查找系统头文件相同的方式继续查找。这两个目录如下所示:

import <Foundation/Foundation.h> 为例,Clang 会在 (SDKROOT)/usr/include 之后拼接上 Foundation/Foundation.h,在如下的路径中进行查找:

1
$(SDKROOT)/usr/include/Foundation/Foundation.h

如果在 $(SDKROOT)/usr/include 中没找到对应的头文件,则继续在框架目录下进行查找:

1
$(SDKROOT)/System/Library/Frameworks/Foundation.framework/Headers/Foundation.h

在本例子中,每一次导入 Foundation.h,Clang 都要处理超过 800 个头文件。每一次编译,都要解析差不多 9 MB 的源代码文件。对于如此大量的重复工作,Clang 是如何解决这个问题的呢?

Clang Module

为了加速编译,通过使用 Module,Clang 可以只解析一次头文件,并且将相关信息存储在磁盘中,作为缓存用于下一次构建时进行重用。

为了能够实现这个目标,Clang module 必须要有以下这些特性:

  • 上下文无关(context-free):比如在某些情况下,我们可能会在导入头文件之前,通过宏定义修改模块内部状态,这种情况会导致模块无法重用。

  • 自包含(self-contained):即管理自身所有依赖,不用在导入该模块时还必须导入其他模块。

Module Map

那么,Clang 如何知道是否应该构建一个模块呢?

#import <Foundation/NSString.h> 为例,Clang 会先去 Foundation.framework 中查找对应的头文件 NSString.h。然后 Clang 会再去 Foundation.framework 中查找 Modules/module.modulemap,通过分析 module.modulemap 判断 NSString.h 是否是模块的一部分。如果找到了,Clang 会将文本导入转换成模块导入,然后才开始构建模块。

那么什么是 Module Map 呢?

Module map 描述了如何将头文件映射成模块。如下所示,是 Foundation.framework 的 module map 文件。

Module map 文件中定义了模块的名称 Foundation,并指定了模块的保护伞头文件(umbrella header)Foundation.h。如果想要查找 NSString.h,需要到 Foundation.h 中去查找。

Build Module

当 Clang 确定将文本导入转换成模块导入时,那么就需要构建模块了。

在构建 Foundation 模块时,我们可能也需要构建其所依赖的模块,如下图所示:

Module Cache

模块构建完之后,会被缓存到磁盘中的 Module Cache,位于 ~/Library/Developer/Xcode/DerivedData/ModuleCache.noindex/ 目录下。当构建模块时,Clang 会根据命令行参数得到一个哈希值,并以此作为模块的存储索引。

用户模块

上面介绍了如何查找并构建系统模块,那么对于用户模块该怎么做呢?

以 PetWall 为例,我们通过 #import <PetKit/Cat.h> 导入并启用模块。此时,我们会先通过 header map 进行查找,找到 cat.h 后,却没有找到 Modules/ 目录,很显然也没有 module.modulemap。因此,Clang 也就不会构建模块。此时该怎么办呢?

事实上,对于这种情况,可以采用 Clang 虚拟文件系统(Clang's Virtual File System) 的技术来解决。Clang 会创建抽象的 PetKit.framework(其中包含 Modules/module.modulemap),从而使 Clang 能够构建模块。

注意,如果在开启模块的情况下,在导入时却不指定框架的名称,可能会报错。如下所示为例,有两个导入声明,对于第二个导入声明,我们知道 Cat.h 是 PetKit 模块的一部分,但是 Clang 并不知道,因为我们没有指定框架的名称。在这种情况下,可能会出现重复定义的报错。

1
2
#import <PetKit/PetKit.h>
#import "Cat.h"

为了避免这种报错,我们应该在导入时指定框架的名称。

1
2
#import <PetKit/PetKit.h>
#import <PetKit/Cat.h>

Swift

接下来,我们再来看一下构建系统是如何查找 Swift 声明的呢?

我们知道,Clang 会分开编译配一个 OC 文件。如果我们要引用一个文件中的某个类,那么必须要导入声明了这个类的头文件。

不过,在 Swift 中我们并不需要导入头文件。这样能够使代码更加简洁,但也意味着编译器要做更多的工作。

以 PetWall 为例,PetViewController 使用 Swift 编写,AppDelegate 使用 OC 编写,单元测试使用 Swift 编写。

为了编译 PetViewController,编译器做两步操作:

  • 查找声明
  • 生成接口

查找声明

很显然,查找的声明有两种来源:

  • Swift Target
  • Objective-C

查找 Swift Target 中的声明

关于查找 Swift Target 中的声明,在编译 PetViewController.swift 时,编译器需要找到 PetView 的初始化方法,然后才能检查调用方式。在此之前,根据依赖关系,编译器需要先解析并验证 PetView.swift,从而确保 PetView 的初始化方法调用正确。不过,编译器不需要解析 PetView 初始化方法的函数体部分。

在编译 Swift 文件过程中,编译器会查找 Swift Target 中的所有 Swift 文件。当并行编译文件时,由于需要查找声明,所以会产生大量的重复工作,如下所示:

为了解决这个问题,Xcode 10 将文件进行分组编译,从而减少冗余工作,如下所示:

查找 Objective-C 中的声明

除了 Swift Target 中的声明,编译器还需要查找 OC 声明。事实上,swiftc 编译器中嵌入了 Clang,所以我们可以直接导入 Clang 框架。

OC 中的声明主要有三种来源:

  • 导入的 OC framework:源自 Clang module map 暴露的头文件
  • 混编 framework 的内部:源自 umbrella header
  • application 和 单元测试内部:源自 target 的 bridging header

导入器在找到声明后,通常会对它们进行命名修饰(Name Mangling),使其更具 Swift 风格。比如,导入器会将 - (void)drawPet:(nonnull Pet *)pet atPoint:(CGPoint)point; 命名修饰为 draw(fluffy, at: origin) 这样的风格。事实上,编译器是通过内部的一个单词表来实现这个功能的,当然有些单词没有被收录其中,所以转换后的命名也并不一定符合预期。对于这种情况,我们可以使用 NS_SWIFT_NAME 来显式定义导入命名。

生成接口

很显然,生成的接口有两种用途:

  • 用于 Objective-C
  • 用于 Swift Target

生成用于 Objective-C 的接口

事实上,编译器会为 Swift 生成一个头文件。这个头文件中会包含继承自 NSObject 的类和使用 @objc 修饰的方法的声明。

对于不同的场景,生成的头文件也有所不同。

  • 对于应用内部,头文件会包含 publicinternal 的声明,允许在 OC 中使用 Swift 内部的方法和属性。
  • 对于框架,头文件只会包含 public 的声明。

在上述例子中,PetCollar 自动生成的 OC 类名是 _TtC7PetWall9PetCollar,这样是为了避免与 OC 中定义的类名产生冲突。当然,我们也可以通过 @objc(Name) 的方式指定其在 OC 中的命名,这时需要我们自己保证命名不会产生冲突。

生成用于 Swift Target 的接口

当我们要在其他 Swift Target 中引用 Swift 时,首先需要导入模块。在 Swift 中,一个模块是一个可分配的声明单元。为了使用这些声明,我们必须要导入模块。每个 Swift Target 都会生成一个单独的模块。对于 PetWall 也是如此。

当导入一个模块后,编译器会反序列化一个特殊的 swiftmodule 文件,用于检查用到的类型。比如,在上述例子中,编译器会加载 PetWall.swiftmodule 中的 PetViewController 的部分来确保创建的 PetViewController 的代码是正确的。

swiftmodule VS header

swiftmodule 文件是一个序列化的二进制表示,描述了模块的声明。编译器可以反序列化 swiftmodule 文件,用于检查类型。因此,swiftmodule 有点类似于生成的 OC 头文件(generated Objective-C header)。区别在前者是二进制表示,后者是文本表示。此外,swiftmodule 还包含了私有声明的命名和类型,因此我们可以用于进行调试。

对于增量编译,编译器会部分地生成 swiftmodule 文件,并最终合并成一个 swiftmodule 文件和一个 OC 头文件,如下图所示:

总结

本文分别从构建系统、Clang、Swift 混编等方面介绍了 Xcode 构建过程的幕后原理。从中,我们了解了之前所不知道的一些细节,如:header map、clang module、module map、swift module 等。

关于链接这一部分,本文没有进行介绍,链接的本质就是对符号进行修正,转换成实际的地址。具体后面有时间再进行补充。

参考

  1. Behind the Scenes of the Xcode Build Process
  2. Build 过程
  3. Building faster in xcode
  4. Clang Modules
  5. Swift Compiler: What we can learn
  6. Introduction to the LLVM for a iOS Engineer
  7. Importing Objective-C into Swift
  8. Swift Compiler: What we can learn
  9. Compile-time code optimization for Swift and Objective-C
  10. Introduction to Xcode
  11. (WWDC) Xcode 构建过程的幕后 —— Clang 编译器
  12. (WWDC) Xcode 构建过程的幕后 —— Swift
  13. (WWDC) Xcode 构建过程的幕后 —— Linker
  14. llbuild