Clang Module 是大概 2013 年左右出现的,它的出现是为了解决传统基于 C 语言的编程语言的头文件包含的弊端。也是现代 Apple 平台软件开发一定会用到的一个技术,了解 Clang Module 对我们组织代码结构,理解 Xcode 编译流程,优化编译速度,定位编译错误等都会有帮助。

传统头文件包含的弊端

传统的头文件包含,存在以下几个主要的问题:

编译性能问题

对于传统头文件包含,预处理器会将头文件的内容复制粘贴过来替换 #include 预处理指令。而很多头文件内都会包含相同的其它头文件,例如底层依赖,系统库等,这样造成了不同的源文件中会出现重复的内容。也就是说,对于 M 个源文件,如果有 N 个头文件,复杂度量级是 M x N 的,编译器会对每个重复的内容都进行一次文本分析,做了大量重复的工作,拖慢了编译时间。

例如系统的 Foundation 框架,框架内嵌套包含了 800 个以上的其它头文件,整个框架的大小在 9MB 以上,作为最最基础的框架,几乎每个源文件都会包含 Foundation.h。对于传统的头文件包含,Foundation.h 的内容及其包含的其它头文件会被不断地重复进行词法分析语义分析,拖慢编译速度。

脆弱性

脆弱性是因为 #include 替换得到的内容会受到其它预处理指令的影响。例如头文件中有某个符号 XXX,如果在包含这个头文件之前,存在类似 #define XXX "other text" 这样的宏定义,就会导致头文件中的所有 XXX 都被替换为 "other text",而导致编译错误。

#define XXX "other text"
#include "XXX.h"

必须使用惯例方案来解决一些问题

传统的头文件包含无法解决头文件重复包含的问题,因此大家都用一种惯例来避免重复包含。

#ifndef __XXXX_H__
#define __XXXX_H__
// 头文件内容
#endif

虽然现代开发工具都能自动生成这些,但还是存在一定的不便。

另外为了解决宏在多个库之间重名的问题,大家都会把宏的名称起的很长很长,增加前缀和后缀。

对工具的迷惑性

在 C 语言为基础的语言中,软件库的边界不是很清晰,比如很难辨别一个头文件到底是哪个语言的,因为 C、C++、Objective-C 等语言的头文件都是 .h。也很难弄清楚一个头文件到底是属于哪个库的。这对于开发基于这些软件库的工具带来了一定的难度。

Clang Module 能解决什么问题?

语义导入

Clang Module 从传统头文件包含的文本导入改进成了更健壮,效率更高的语义导入。当编译器看到一个 Module 导入指令时,编译器会去加载一个二进制文件,这个二进制文件提供了这个模块所有 API 的信息,这些 API 可以直接给其它代码使用。

编译性能提升

Clang Module 提升了编译性能,每个模块只需要编译一次,然后会生成一个模块的二进制表示(.pcm,预编译模块,下文会说明),并缓存到磁盘上。下次遇到 import 这个模块时,编译器不需要再次编译 Module,而是直接读取这个缓存的二进制表示即可。

上下文无关

Clang Module 解决了脆弱性的问题,每个 Module 都是一个独立的实体,会被隔离地、独立的编译,是上下文无关的。当 import 模块时,会忽略 import 上下文的其它的预处理指令,这样 import 之前的预处理指令不会对模块导入产生任何影响。

每个模块都是一个自包含的个体,他们上下文无关,相互隔离,因此不在需要使用一些惯例方法来避免出现一些问题,因为这些问题已经不会出现了。

自己制作一个模块

为了能对有一个直观的了解,我们可以自己动手制作一个 模块。用 Xcode 创建一个新的 iOS app 工程作为测试使用。然后在工程根目录下新建一个 group 命名为 Frameworks。

在命令行中进入 Frameworks 文件夹,新建一个 Dog.framework 文件夹,名字可以随意,这里是随便起的。

mkdir Dog.framework

然后回到 Xcode 中,在 Frameworks 目录上右击鼠标,选择 Adds files to ... 把 Dog.framework 添加到 Frameworks 目录内。
此时编译会报错 Framework not found Dog,接下来我们看看怎样制作出一个 Xcode 能正确识别并编译的模块。

在 Dog.framework 中新建 Dog.swift 文件并添加以下内容:

// Dog.swift
import Foundation

public class Dog: NSObject {
    public func bark() {
        print("bark")
    }

    @objc func objcBark() {
        print("objc bark")
    }
}

接下来我们来为这个 framework 生成接口文件。在命令行中执行以下命令:

swiftc -module-name Dog -c Dog.swift -target arm64-apple-ios16.2-simulator -sdk /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator16.2.sdk -emit-module -emit-objc-header -emit-objc-header-path Dog-Swift.h

swiftc 是 Swift 语言的编译器,它底层也调用了 clang。下面对参数一一进行说明:

  • -module-name Dog 模块的名称,使用者可以通过 import + 这个名称来引入模块。
  • -c Dog.swift 指定要编译的源文件。
  • -target arm64-apple-ios16.2-simulator 指定生成目标的架构。
  • -sdk /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator16.2.sdk 指定要链接进来的 SDK,这里使用的是 iOS 16.2 的模拟器版本。
  • -emit-module 会生成一个 .swiftdoc 文件和一个 .swiftmodule 文件。
  • -emit-objc-header 生成 Objective-C 头文件,仅包含被标记为 @objc 的符号。
  • -emit-objc-header-path 指定 Objective-C 头文件的路径,这里我们遵循了 Xcode 的惯例,使用 ”模块名+Swift.h“ 来命名。

虽然需要的文件已经生成了,但是并不是 Xcode 支持的 module 目录结构,无法被 Xcode 读取。我们可以通过观察 Xcode 创建的 Framework 来了解这种结构,来创建正确的结构。

在 Dog.framework 文件夹中创建 Headers 文件夹,然后把 Dog-Swift.h 移动到 Headers 文件夹中。然后在 Dog.framework 文件夹中再创建一个 Modules 文件夹,然后在 Modules 文件夹中创建 Dog.swiftmodule 文件夹,把 Dog.swiftdoc 和 Dog.swiftmodule 移动到 Dog.swiftmodule 文件夹中。最后把这两个文件重命名为 arm64.swiftdoc 和 arm64.swiftmodule。

当前 Dog.framework 的目录结构为:

Dog.framework/
|---- Dog
|---- Headers
|    |---- Dog-Swift.h
|---- Modules
     |---- Dog.swiftmodule
         |---- arm64.swiftdoc
         |---- arm64.swiftmodule 

现在接口已经有了,但是还没有二进制库文件,依然无法编译通过,下面我们来生成二进制库文件。

执行以下命令:

swiftc -module-name Dog -parse-as-library -c Dog.swift -target arm64-apple-ios16.2-simulator -sdk /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator16.2.sdk -emit-object

这个命令中有很多参数跟上一个命令是一样的,在此不做重复说明,仅说明下上个命令中没有的参数。

  • -parse-as-library 让编译器把文件解释为一个库,而不是一个可执行文件。
  • -emit-object 输出目标文件。

这个命令执行完以后会生成 Dog.o 这个目标文件,然后我们需要把目标文件归档为库。

libtool -static Dog.o -arch_only arm64 -o Dog

这里为了简化流程选择了创建静态库,而不是动态链接库,因此使用 -static。这时在 Dog.framework 中会出现 Dog 这个二进制静态库文件。此时我们在 ViewController 中 import Dog 然后编译工程,就能编译通过了。说明当前这种目录结构可以让 Xcode 正确找到模块所需文件。

Module map

接下来我们来试一试用 Objective-C 来调用 Dog 模块会怎样。

在上面的工程中再创建一个 Objective-C 类,命名为 OCObject,并让 Xcode 自动创建头文件桥接文件,并添加以下代码:

// OCObject.h
@interface OCObject : NSObject

- (void)doSomething;

@end
// OCObject.m
#import "OCObject.h"
#import <Dog/Dog-Swift.h>

@implementation OCObject

- (void)doSomething {
    Dog *dog = [[Dog alloc] init];
    [dog objcBark];
}

@end

会发现此时是可以打印出 objc mark 的。然后把 #import <Dog/Dog-Swift.h> 替换成标准的模块导入语法 @import Dog;,编译却报错了,提示 ”Module 'Dog' not found“。

这时因为 framework 中缺少了一个重要的 modulemap 文件,Xcode 就无法找到模块。#import <Dog/Dog-Swift.h> 之所以有效是因为它本身是一个向前兼容的语句,如果 framework 支持模块,则导入模块,如果 framework 不支持模块,它会像 #include 一样去搜索路径中找到这个头文件,直接把文本内容粘贴到这里。

Module map 指明了 framework 中的头文件逻辑结构应该如何映射为模块。参考用 Xcode 创建 framework 时自动创建的 module map 文件,会发现在 Modules 文件夹下有一个 module.modulemap 文件,其内容如下:

framework module ObserveModuleStructure {
  umbrella header "ObserveModuleStructure.h"

  export *
  module * { export * }
}

module ObserveModuleStructure.Swift {
  header "ObserveModuleStructure-Swift.h"
  requires objc
}

通过参考 clang 的文档,来对这个语法一一进行说明:

  • framework module XXXX 定义了一个 framework 语义的模块
  • umbrella header "XXXX.h" 说明把 XXXX.h 文件作为模块的 unbrella header,伞头文件相当于模块中所有公共头文件的一个集合,方便使用者导入。
  • export * 将所有子模块中的符号进行重导出到主模块中
  • module * { export * } 定义子模块,这里为 * 则是为 umbrella header 中的每个头文件都创建一个子模块。

根据这个语法编写自己的 module map 文件,路径为 Dog.framework/Modules/module.modulemap:

// Dog.framework/Modules/module.modulemap
framework module Dog {
    umbrella header "Dog.h"
    export *
    module * { export * }
}

module Dog.Swift {
    header "Dog-Swift.h"
    requires objc
}

此时依然编译报错,还需要一个 unbrella header 文件,创建一个 Dog.h 文件放到 Dog.framework/Headers/ 中,内容为空即可。然后就可以编译通过,打印出 bark objc。

Module Map 语言语法

官方把这种语法叫做模块映射语言(Module Map Language)。

根据 Clang 的文档,模块映射语言在 Clang 的大版本之间可能不会保持稳定,因此在平常的开发中,让 Xcode 去自动生成就好。

模块声明

[framework] module module-id [extern_c] [system] {
    module-member
}

framework

framework 代表这个模块是是一个 Darwin 风格的 framework。Darwin 风格的 framework 主要出现在 macOS 和 iOS 操作系统中,它的全部内容都包含在一个 Name.framework 文件夹中,这个 Name 就是 framework 的名字,这个文件夹的内容布局如下:

Name.framework/
    Modules/module.modulemap    framework 的模块映射
    Headers/                    包含了 framework 中的头文件
    PrivateHeaders/             包含了 framework 中私有的头文件
    Frameworks/                 包含嵌入的其它 framework
    Resources/                  包含额外的资源
    Name                        指向共享库的符号链接

system

system 指定了这个模块是一个系统模块。当一个系统模块被重编译后,模块的所有头文件都会被当做系统头文件,这样一些警告就不会出现。这和在头文件中放置 #pragma GCC system_header 等效。

extern_c

extern_c 指明了模块中包含的 C 代码可以被 C++ 使用。当这个模块被编译用来给 C++ 调用时,所有模块中的头文件都会被包含在一个隐含的 extern "C" 代码块中。

模块体

模块体包含了 header、requires 等常见的声明和子模块声明,例如:

framework module Dog {
    umbrella header "Dog.h"
    requires objc
    module * { export * }
}

header

header 指定了要把哪些头文件映射为模块。umbrella header 则是指定了综合性伞头文件。

requires

requires 声明指定了导入这个模块的编译单元需要满足的条件。这个条件有语言、平台、编译环境和目标特定功能等。例如 requires cplusplus11 表示模块需要在支持 C++11 的环境中使用,requires objc 表示模块需要在支持 Objective-C 语言的环境中使用。

module

module 用来声明模块中的子模块,如果是 module * 则代表模块中的每个头文件都会作为一个子模块。

子模块声明

在主模块的模块体中嵌套地声明模块就是子模块。例如在 MyLib 模块中声明一个子模块 A,写法如下:

module MyLib {
    module A {
        header "A.h"
        export *
    }
}

explicit

explicit 修饰符是用来修饰子模块的。如果想使用被 explicit 修饰的子模块,必须在 import 时指定子模块的名字,像这样 import modulename.submodulename,或者这个子模块已经被其它已导入的模块重导出过。

export

export 指定了将哪个模块的 API 进行重新导出,成为 export 所在的模块的 API。

export_as

export_as 将当前模块的 API 通过另一个指定的模块导出。

module MyFrameworkCore {
    export_as MyFramework
}

上面的例子中,MyFrameworkCore 中的 API 将通过 MyFramework 导出。

模块映射语言还包含很多其它的声明语句,例如 useconfig_macrslinkconflict 等,由于在 iOS 开发中出现的不是很多,这里就不做一一说明,有兴趣可以查看 Clang 的官方文档。

Clang Module 的缓存机制

Clang 可以通过读取 modulemap 文件的内容将 modulemap 中指定的模块编译成预编译模块(Precompiled Module),后缀名是 .pcm。

clang -cc1 -emit-obj use.c -fmodules -fimplicit-module-maps -fmodules-cache-path=prebuilt -fdisable-module-hash

上面的命令通过指定参数 implicit-module-maps 让编译器根据一定的规则自己去查找 modulemap 文件,通过指定参数 modules-cache-path 告诉编译器预编译模块的缓存路径。Clang 会根据 modulemap 中的信息编译各个模块,将生成的 .pcm 文件放到 prebuilt 目录下。

.pcm 文件以一种编译器可以轻松读取并解析的格式保存了模块的信息,之后编译器在编译其它模块时如果遇到了需要依赖这个模块,则可以快速的从 .pcm 中读取模块信息而不需要重新编译模块。

在 Xcode 中使用 Clang Module

用 Xcode 创建的框架或库都是默认开启 Clang Module 支持的,也就是在 Build Settings 中,Defines Module 的设置为 YES。如果是很老的库可能没有开启,手动把 Defines Module 设置为 YES 即可。

当 Defines Modules 是 YES 是,Xcode 在编译工程时会给 clang 命令增加 -fmodules 等模块相关参数,开启模块支持。

结语

很多时候,开发工具都对我们隐藏了很多底层的细节,了解这些细节,可以帮助我们了解底层的原理,分析并解决一些棘手的问题。Clang 是 Apple 平台上重要的工具,值得我们去研究探索。感谢您的阅读,如果文章有不正确的地方,或者您有自己的见解,欢迎发表评论来讨论。

参考资料


SwiftFun
1 声望1 粉丝