普通视图

发现新文章,点击刷新页面。
昨天以前SketchK's Blog

从预编译的角度理解 Swift 与 Objective-C 及混编机制

作者 SketchK
2022年8月30日 01:00

这篇文章是我早年在美团技术博客上发布的一篇文章, 部分内容可能已经过时, 请开发者注意.
将文章同步到个人博客主要是为了同步和备份.

TL;DR

文章涉及面广,篇幅长,阅读完需要耗费一定的时间与精力,如果你带有较为明确的阅读目的,可以参考以下建议完成阅读:

  • 如果你对预编译的理论知识已经了解,可以直接从【原来它是这样的】的章节开始进行阅读,这会让你对预编译有一个更直观的了解。
  • 如果你对 search path 的工作机制感兴趣,可以直接【关于第一个问题】的章节阅读,这会让你更深刻,更全面的了解到它们的运作机制,
  • 如果您对 Xcode Phases 里的 Header 的设置感到迷惑,可以直接从【揭开 Public,Private,Project 的真实面目】阅读,这会让你理解为什么说 Private 并不是真正的私有头文件
  • 如果你想了解如何通过 hmap 技术提升编译速度,可以从关于【基于 hmap 优化 Search Path 的策略】的章节开始阅读,这会给你提供一种新的编译加速思路。
  • 如果你想了解如何通过 VFS 技术进行 Swift 产物的构建,可以从 【关于第二个问题】开始阅读,这会让你理解如何用另外一种提升构建 Swift 产物的效率。
  • 如果你想了解 Swift 和 Objective-C 是如何找寻方法声明的,可以从 【Swift 来了】的章节阅读,这会让你从原理上理解混编的核心思路和解决方案。

概述

随着 Swift 的发展,国内的技术社区出现了一些关于如何实现 Swift 与 Objective-C 混编的文章,这些文章的主要内容还是围绕着指导开发者进行各种操作来实现混编的效果,例如在 Build Setting 中开启某个选项,在 podspec 中增加某个字段,鲜有文章对这些操作背后的工作机制做剖析,大部分核心概念也都是一笔带过。

正是因为这种现状,很多开发者在面对与预期不符的行为时,又或者各种奇怪报错时,会无从下手,而这也是由于对其工作原理不够了解所导致的。

笔者自身在美团平台负责 CI/CD 相关的工作,这其中也包含了 Objective-C 与 Swift 混编的内容,出于让更多开发者能够进一步理解混编工作机制的目的,笔者编写了这篇技术文章。

该文章从预编译的基础知识入手,由浅至深的介绍了 Objective-C 和 Swift 的工作机制,并通过这些机制来解释混编项目中使用到的技术和各种参数的作用,由此来指导开发者如何进行混编。

好了废话不多说,我们开始吧!

预编译知识指北

#import 的机制和缺点

在我们使用某些系统组件的时候,我们通常会写出如下形式的代码:

1
#import <UIKit/UIKit.h>

#import 其实是 #include 语法的微小创新,它们在本质上还是十分接近的。#include 做的事情其实就是简单的复制粘贴,将目标 .h 文件中的内容一字不落地拷贝到当前文件中,并替换掉这句 #include,而 #import 实质上做的事情和 #include 是一样的,只不过它还多了一个能够避免头文件重复引用的能力而已。

为了更好的理解后面的内容,我们这里需要展开说一下它到底是如何运行的?

从最直观的角度来看:

假设在 MyApp.m 文件中,我们 #importiAd.h 文件,编译器解析此文件后,开始寻找 iAd 包含的内容(ADInterstitialAd.hADBannerView.h),及这些内容包含的子内容(UIKit.hUIController.hUIView.hUIResponder.h),并依次递归下去,最后,你会发现 #import <iAd/iAd.h> 这段代码变成了对不同 SDK 的头文件依赖。

01.png

如果你觉得听起来有点费劲,或者似懂非懂,我们这里可以举一个更加详细的例子,不过请记住,对于 C 语言的预处理器而言, #import 就是一种特殊的复制粘贴。

结合前面提到的内容,在 AppDelegate 中添加 iAd.h

1
2
3
4
#import <iAd/iAd.h>
@implementation AppDelegate
//...
@end

然后编译器会开始查找 iAd/iAd.h 到底是哪个文件且包含何种内容,假设它的内容如下:

1
2
3
4
/* iAd/iAd.h */
#import <iAd/ADBannerView.h>
#import <iAd/ADBannerView_Deprecated.h>
#import <iAd/ADInterstitialAd.h>

在找到上面的内容后,编译器将其复制粘贴到 AppDelegate 中:

1
2
3
4
5
6
7
#import <iAd/ADBannerView.h>
#import <iAd/ADBannerView_Deprecated.h>
#import <iAd/ADInterstitialAd.h>

@implementation AppDelegate
//...
@end

现在,编译器发现文件里有 3 个 #import 语句 了,那么就需要继续寻找这些文件及其相应的内容,假设 ADBannerView.h 的内容如下:

1
2
3
4
5
6
7
8
/* iAd/ADBannerView.h */
@interface ADBannerView : UIView
@property (nonatomic, readonly) ADAdType adType;

- (id)initWithAdType:(ADAdType)type

/* ... */
@end

那么编译器会继续将其内容复制粘贴到 AppDelegate 中,最终变成如下的样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
@interface ADBannerView : UIView
@property (nonatomic, readonly) ADAdType adType;

- (id)initWithAdType:(ADAdType)type

/* ... */
@end
#import <iAd/ADBannerView_Deprecated.h>
#import <iAd/ADInterstitialAd.h>

@implementation AppDelegate
//...
@end

这样的操作会一直持续到整个文件中所有 #import 指向的内容被替换掉,这也意味着 .m 文件最终将变得极其的冗长。

虽然这种机制看起来是可行的,但它有两个比较明显的问题:健壮性和拓展性。

健壮性

首先这种编译模型会导致代码的健壮性变差!

这里我们继续采用之前的例子,在 AppDelegate 中定义 readonly0x01,而且这个定义的声明在 #import 语句之前,那么此时又会发生什么事情呢?

编译器同样会进行刚才的那些复制粘贴操作,但可怕的是,你会发现那些在属性声明中的 readonly 也变成了 0x01,而这会触发编译器报错!

1
2
3
4
5
6
7
8
9
10
11
@interface ADBannerView : UIView
@property (nonatomic, 0x01) ADAdType adType;

- (id)initWithAdType:(ADAdType)type

/* ... */
@end

@implementation AppDelegate
//...
@end

面对这种错误,你可能会说它是开发者自己的问题。

确实,通常我们都会在声明宏的时候带上固定的前缀来进行区分。但生活里总是有一些意外,不是么?

假设某个人没有遵守这种规则,那么在不同的引入顺序下,你可能会得到不同的结果,对于这种错误的排查,还是挺闹心的,不过这还不是最闹心的,因为还有动态宏的存在,心塞 ing。

所以这种靠遵守约定来规避问题的解决方案,并不能从根本上解决问题,这也从侧面反应了编译模型的健壮性是相对较差的。

拓展性

说完了健壮性的问题,我们来看看拓展性的问题。

Apple 公司对它们的 Mail App 做过一个分析,下图是 Mail 这个项目里所有 .m 文件的排序,横轴是文件编号排序,纵轴是文件大小。

IMAGE

可以看到这些由业务代码构成的文件大小的分布区间很广泛,最小可能有几 kb,最大的能有 200+ kb,但总的来说,可能 90% 的代码都在 50kb 这个数量级之下,甚至更少。

如果我们往该项目的某个核心文件(核心文件是指其他文件可能都需要依赖的文件)里添加了一个对 iAd.h 文件的引用,对其他文件意味着什么呢?

这里的核心文件是指其他文件可能都需要依赖的文件

这意味着其他文件也会把 iAd.h 里包含的东西纳入进来,当然,好消息是,iAd 这个 SDK 自身只有 25KB 左右的大小。

IMAGE

但你得知道 iAd 还会依赖 UIKit 这样的组件,这可是个 400KB+ 的大家伙

IMAGE

所以,怎么说呢?

在 Mail App 里的所有代码都需要先涵盖这将近 425KB 的头文件内容,即使你的代码只有一行 hello world

如果你认为这已经让人很沮丧的话,那还有更打击你的消息,因为 UIKit 相比于 macOS 上的 Cocoa 系列大礼包,真的小太多了,Cocoa 系列大礼包可是 UIKit 的 29 倍……

所以如果将这个数据放到上面的图表中,你会发现真正的业务代码在 file size 轴上的比重真的太微不足道了。

所以这就是拓展性差带来的问题之一!

很明显,我们不可能用这样的方式引入代码,假设你有 M 个源文件且每个文件会引入 N 个头文件,按照刚才的解释,编译它们的时间就会是 M * N,这是非常可怕的!

备注:文章里提到的 iAd 组件为 25KB,UIKit 组件约为 400KB, macOS 的 Cocoa 组件是 UIKit 的 29 倍等数据,是 WWDC 2013 Session 404 Advances in Objective-C 里公布的数据,随着功能的不断迭代,以现在的眼光来看,这些数据可能已经偏小,在 WWDC 2018 Session 415 Behind the Scenes of the Xcode Build Process 中提到了 Foundation 组件,它包含的头文件数量大于 800 个,大小已经超过 9MB。

PCH(PreCompiled Header)是一把双刃剑

为了优化前面提到的问题,一种折中的技术方案诞生了,它就是 PreCompiled Header。

我们经常可以看到某些组件的头文件会频繁的出现,例如 UIKit,而这很容易让人联想到一个优化点,我们是不是可以通过某种手段,避免重复编译相同的内容呢?

而这就是 PCH 为预编译流程带来的改进点!

它的大体原理就是,在我们编译任意 .m 文件前, 编译器会先对 PCH 里的内容进行预编译,将其变为一种二进制的中间格式缓存起来,便于后续的使用。当开始编译 .m 文件时,如果需要 PCH 里已经编译过的内容,直接读取即可,无须再次编译。

虽然这种技术有一定的优势,但实际应用起来,还存在不少的问题。

首先,它的维护是有一定的成本的,对于大部分历史包袱沉重的组件来说,将项目中的引用关系梳理清楚就十分麻烦,而要在此基础上梳理出合理的 PCH 内容就更加麻烦,同时随着版本的不断迭代,哪些头文件需要移出 PCH,哪些头文件需要移进 PCH 将会变得越来越麻烦。

其次,PCH 会引发命名空间被污染的问题,因为 PCH 引入的头文件会出现在你代码中的每一处,而这可能会是多于的操作,比如 iAd 应当出现在一些与广告相关的代码中,它完全没必要出现在帮助相关的代码中(也就是与广告无关的逻辑),可是当你把它放到 PCH 中,就意味组件里的所有地方都会引入 iAd 的代码,包括帮助页面,这可能并不是我们想要的结果!

如果你想更深入的了解 PCH 的黑暗面,建议阅读 4 Ways Precompiled Headers Cripple Your Code ,里面已经说得相当全面和透彻。

所以 PCH 并不是一个完美的解决方案,它能在某些场景下提升编译速度,但也有缺陷!

Clang Module 的来临

为了解决前面提到的问题,Clang 提出了 module 的概念,关于它的介绍可以在 Clang 官网 上找到。

简单来说,你可以把它理解为一种对组件的描述,包含了对接口(API)和实现(dylib/a)的描述,同时 module 的产物是被独立编译出来的,不同的 module 之间是不会影响的。

在实际编译之时,编译器会创建一个全新的空间,用它来存放已经编译过的 module 产物。如果在编译的文件中引用到某个 module 的话,系统将优先在这个列表内查找是否存在对应的中间产物,如果能找到,则说明该文件已经被编译过,则直接使用该中间产物,如果没找到,则把引用到的头文件进行编译,并将产物添加到相应的空间中以备重复使用。

在这种编译模型下,被引用到的 module 只会被编译一次,且在运行过程中不会相互影响,这从根本上解决了健壮性和拓展性的问题。

module 的使用并不麻烦,同样是引用 iAd 这个组件,你只需要这样写即可。

1
@import iAd;

在使用层面上,这将等价于以前的 #import <iAd/iAd.h> 语句,但是会使用 clang module 的特性加载整个 iAd 组件。如果只想引入特定文件(比如 ADBannerView.h),原先的写法是 #import <iAd/ADBannerView.h.h>,现在可以写成:

1
@import iAd.ADBannerView;

通过这种写法会将 iAd 这个组件的 API 导入到我们的应用中,同时这种写法也更符合语义化(semanitc import)。

虽然这种引入方式和之前的写法区别不大,但它们在本质上还是有很大程度的不同,Module 不会“复制粘贴”头文件里的内容,也不会让 @import 所暴露的 API 被开发者本地的上下文篡改,例如前面提到的 #define readonly 0x01

此时,如果你觉得前面关于 clang module 的描述还是太抽象,我们可以再进一步去探究它工作原理, 而这就会引入一个新的概念 – modulemap。

不论怎样,module 只是一个对组件的抽象描述罢了,而 modulemap 则是这个描述的具体呈现,它对框架内的所有文件进行了结构化的描述,下面是 UIKit 的 modulemap 文件

1
2
3
4
5
framework module UIKit {
umbrella header "UIKit.h"
module * {export *}
link framework "UIKit"
}

这个 module 定义了组件的 umbrella header 文件(UIKit.h),需要导出的子 module(所有),以及需要 link 的框架名称(UIKit),正是通过这个文件,让编译器了解到 Module 的逻辑结构与头文件结构的关联方式!

可能又有人会好奇,为什么我从来没看到过 @import 的写法呢?

这是因为 Xcode 的编译器能够将符合某种格式的 #import 语句自动转换成 module 识别的 @import 语句,从而避免了开发者的手动修改。

画板.png

唯一需要开发者完成的就是开启相关的编译选项。

IMAGE

对于上面的编译选项,需要开发者注意的是:

Apple Clang - Language - ModulesEnable Module 选项是指引用系统库的的时候,是否采用 module 的形式。

Packaing 里的 Defines Module 是指开发者编写的组件是否采用 module 的形式。

说了这么多,我想你应该对 #importpch@import 有了一定的概念。当然,如果我们深究下去,可能还会有如下的疑问:

  • 对于未开启 clang module 特性的组件,clang 是通过怎样的机制查找到头文件的呢?在查找系统头文件和非系统头文件的过程中,有什么区别么?
  • 对于已开启 clang module 特性的组件,clang 是如何决定编译当下组件的 module 呢?另外构建的细节又是怎样的,以及如何查找这些 module 的?还有查找系统的 module 和非系统的 module 有什么区别么?

为了解答这些问题,我们不妨先动手实践一下,看看上面的理论知识在现实中的样子。

原来它是这样的

在前面的章节中,我们将重点放在了原理上的介绍,而在在这个章节中,我们将动手看看这些预编译环节的实际样子。

#import 的样子

假设我们的源码样式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#import "SQViewController.h"
#import <SQPod/ClassA.h>

@interface SQViewController ()
@end

@implementation SQViewController
- (void)viewDidLoad {
[super viewDidLoad];
ClassA *a = [ClassA new];
NSLog(@"%@", a);
}

- (void)didReceiveMemoryWarning {
[super didReceiveMemoryWarning];
}
@end

想要查看代码预编译后的样子,我们可以在 Navigate to Related Items 按钮中找到 Preprocess 选项

IMAGE

既然知道了如何查看预编译后的样子,我们不妨看看代码在使用 #import, PCH 和 @import 后,到底会变成什么样子?

这里我们假设被引入的头文件,即 ClassA 中的内如如下:

1
2
3
4
@interface ClassA : NSObject
@property (nonatomic, strong) NSString *name;
- (void)sayHello;
@end

通过 preprocess 可以看到代码大致如下,这里为了方便展示,将无用代码进行了删除。这里记得要将 Build Setting 中 Packaging 的 Define Module 设置为 NO,因为其默认值为 YES,而这会导致我们开启 clang module 特性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@import UIKit;
@interface SQViewController : UIViewController
@end

@interface ClassA : NSObject
@property (nonatomic, strong) NSString *name;
- (void)sayHello;
@end

@interface SQViewController ()
@end

@implementation SQViewController
- (void)viewDidLoad {
[super viewDidLoad];
ClassA *a = [ClassA new];
NSLog(@"%@", a);
}

- (void)didReceiveMemoryWarning {
[super didReceiveMemoryWarning];
}
@end

这么一看,#import 的作用还就真的是个 copy & write。

pch 的真容

对于 CocoaPods 默认创建的组件,一般都会关闭 PCH 的相关功能,例如笔者创建的 SQPod 组件,它的 Precompile Prefix Header 功能默认值为 NO。

IMAGE

为了查看预编译的效果,我们将 Precompile Prefix Header 的值改为 YES,并编译整个项目,通过查看 build log,我们可以发现相比于 NO 的状态,在编译的过程中,增加了一个步骤,即 Precompile SQPod-Prefix.pch 的步骤。

画板.png

通过查看这个命令的 -o 参数,我们可以知道其产物是名为 SQPod-Prefix.pch.gch 的文件

IMAGE

这个文件就是 PCH 预编译后的产物,同时在编译真正的代码时,会通过 -include 参数将其引入

画板.png

又见 clang module

在开启 Define Module 后,系统会为我们自动创建相应的 modulemap 文件,这一点可以在 Build Log 中查找到

IMAGE

它的内容如下:

1
2
3
4
5
6
framework module SQPod {
umbrella header "SQPod-umbrella.h"

export *
module * { export * }
}

当然,如果系统自动生成的 modulemap 并不能满足你的诉求,我们也可以使用自己创建的文件,此时只需要在 Build Setting 的 Module Map File 选项中填写好文件路径,相应的 clang 命令参数是 -fmodule-map-file

画板.png

最后让我们看看 module 编译后的产物形态。

这里我们构建一个名为 SQPod 的 module ,将它提供给名为 Example 的工程使用,通过查看 -fmodule-cache-path 的参数,我们可以找到 module 的缓存路径

画板.png

进入对应的路径后,我们可以看到如下的文件

IMAGE

其中后缀名为 pcm 的文件就是构建出来的二进制中间产物。

现在,我们不仅知道了预编译的基础理论知识,也动手查看了预编译环节在真实环境下的产物,现在我们要开始解答之前提到的两个问题了!

打破砂锅问到底

关于第一个问题

对于未开启 clang module 特性的组件,clang 是通过怎样的机制查找到头文件的呢?在查找系统头文件和非系统头文件的过程中,有什么区别么?

在早期的 clang 编译过程中,头文件的查找机制还是基于 header search path 的,这也是大多数人所熟知的工作机制,所以我们不做赘述,只做一个简单的回顾。

header seach path 是构建系统提供给编译器的一个重要参数,它的作用是在编译代码的时候,为编译器提供了查找相应头文件路径的信息,通过查阅 Xcode 的 Build System 信息,我们可以知道相关的设置有三处 header search path,system header search path,user header search path。

IMAGE

它们的区别也很简单,system header search path 是针对系统头文件的设置,通常代指 <> 方式引入的文件,user header search path 则是针对非系统头文件的设置,通常代指 "" 方式引入的文件,而 header search path 并不会有任何限制,它普适于任何方式的头文件引用。

听起来好像很复杂,但关于引入的方式,无非是以下四种形式:

1
2
3
4
#import <A/A.h>
#import "A/A.h"
#import <A.h>
#import "A.h"

我们可以两个维度去理解这个问题,一个是引入的符号形式,另一个是引入的内容形式

  • 引入的符号形式:通常来说,双引号的引入方式(“A.h” 或者 "A/A.h")是用于查找本地的头文件,需要指定相对路径,尖括号的引入方式(<A.h> 或者 <A/A.h>)是全局的引用,其路径由编译器提供,如引用系统的库,但随着 header search path 的加入,让这种区别已经被淡化了。

  • 引入的内容形式:对于 X/X.hX.h 这两种引入的内容形式,前者是说在对应的 search path 中,找到目录 A 并在 A 目录下查找 A.h,而后者是说在 search path 下查找 A.h 文件,而不一定局限在 A 目录中,至于是否递归的寻找则取决于对目录的选项是否开启了 recursive 模式

画板.png

在很多工程中,尤其是基于 CocoaPods 开发的项目,我们已经不会区分 system header search path 和 user header search path,而是一股脑的将所有头文件路径添加到 header search path 中,这就导致我们在引用某个头文件时,不会再局限于前面提到的约定,甚至在某些情况下,前面提到的四种方式都可以做到引入某个指定头文件。

header maps

随着项目的迭代和发展,原有的头文件索引机制还是受到了一些挑战,为此,Clang 官方也提出了自己的解决方案。

为了理解这个东西,我们首先要在 build setting 中开启 Use Header Map 选项。

IMAGE

然后在 build log 里获取相应组件里对应文件的编译命令,并在最后加上 -v 参数,来查看其运行的秘密:

1
clang <list of arguments> -c SQViewController.m -o SQViewcontroller.o -v

在 console 的输出内容中,我们会发现一段有意思的内容:

画板.png

通过上面的图,我们可以看到编译器将寻找头文件的顺序和对应路径展示出来了,而在这些路径中,我们看到了一些陌生的东西,即后缀名为 .hmap 的文件。

那 hmap 到底这是个什么东西呢?

当我们开启 Build Setting 中的 Use Header Map 选项后,会自动生成的一份头文件名和头文件路径的映射表,而这个映射表就是 hmap 文件,不过它是一种二进制格式的文件,也有人叫它为 header map,总之,它的核心功能就是让编译器能够找到相应头文件的位置。

为了更好的理解它,我们可以通过 milend 编写的小工具 hmap 来查其内容。

在执行相关命令(即 hmap print)后,我们可以发现这些 hmap 里保存的信息结构大致如下:

IMAGE

需要注意,映射表的键值并不是简单的文件名和绝对路径,它的内容会随着使用场景产生不同的变化,例如头文件引用是在 "..." 的形式,还是 <...> 的形式,又或是在 Build Phase 里 Header 的配置情况。

IMAGE

至此我想你应该明白了,一旦开启 Use Header Map 选项后,Xcode 会优先去 hmap 映射表里寻找头文件的路径,只有在找不到的情况下,才会去 header search path 中提供的路径遍历搜索。

当然这种技术也不是一个什么新鲜事儿,在 Facebook 的 buck 工具中也提供了类似的东西,只不过文件类型变成了 HeaderMap.java 的样子。

查找系统库的头文件

上面的过程让我们理解了在 header map 技术下,编译器是如何寻找相应的头文件的,那针对系统库的文件又是如何索引的呢?例如 #import <Foundation/Foundation.h>

回想一下上一节 console 的输出内容,它的形式大概如下:

1
2
3
4
5
6
7
8
9
10
11
12
#include "..." search starts here:
XXX-generated-files.hmap (headermap)
XXX-project-headers.hmap (headermap)

#include <...> search starts here:
XXX-own-target-headers.hmap (headermap)
XXX-all-target-headers.hmap (headermap)
Header Search Path
DerivedSources
Build/Products/Debug (framework directory)
$(SDKROOT)/usr/include
$(SDKROOT)/System/Library/Frameworks(framework directory)

我们会发现,这些路径大部分是用于查找非系统库文件的,也就是开发者自己引入的头文件,而与系统库相关的路径只有以下两个:

1
2
3
#include <...> search starts here:
$(SDKROOT)/usr/include
$(SDKROOT)/System/Library/Frameworks.(framework directory)

当我们查找 Foundation/Foundation.h 这个文件的时候,我们会首先判断是否存在 Foundation 这个 framework。

1
$SDKROOT/System/Library/Frameworks/Foundation.framework

接着,我们会进入 framework 的 Headers 文件夹里寻找对应的头文件

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

如果没有找到对应的文件,索引过程会在此中断,并结束查找。

以上便是系统库的头文件搜索逻辑。

framework search path

到底为止,我们已经解释了如何依赖 header search path,hmap 等技术寻找头文件的工作机制,也介绍了寻找系统库(system framework)头文件的工作机制。

那这是全部头文件的搜索机制么?答案是否定的,其实我们还有一种头文件搜索机制,它是基于 Framework 这种文件结构进行的。

IMAGE

对于开发者自己的 Framework,可能会存在 “private” 头文件,例如在 podspec 里用 private_header_files 的描述文件,这些文件在构建的时候,会被放在 Framework 文件结构中的 PrivateHeaders 目录。

所以针对有 PrivateHeaders 目录的 Framework 而言,clang 在检查 Headers 目录后,会去 PrivateHeaders 目录中寻找是否存在匹配的头文件,如果这两个目录都没有,才会结束查找。

1
$SDKROOT/System/Library/Frameworks/Foundation.framework/PrivateHeaders/SecretClass.h

不过也正是因为这个工作机制,会产生一个特别有意思的问题,那就是当我们使用 Framework 的方式引入某个带有 “private” 头文件的组件时,我们总是可以以下面的方式引入这个头文件!

画板.png

怎么样,是不是很神奇,这个被描述为 “private” 的头文件怎么就不私有了?

究其原因,还是由于 clang 的工作机制,那为什么 clang 要设计出来这种看似很奇怪的工作机制呢?

揭开 Public,Private,Project 的真实面目

其实你也看到我在上一段的写作中,将所有 private 单词标上了双引号,其实就是在暗示,我们曲解了 private 的含义。

那么这个 “private” 到底是什么意思呢?

在 Apple 官方的 Xcode Help - What are build phases? 文档中,我们可以看到如下的一段解释:

Associates public, private, or project header files with the target. Public and private headers define API intended for use by other clients, and are copied into a product for installation. For example, public and private headers in a framework target are copied into Headers and PrivateHeaders subfolders within a product. Project headers define API used and built by a target, but not copied into a product. This phase can be used once per target.

总的来说,我们可以知道一点,就是 Build Phases - Headers 中提到 Public 和 Private 是指可以供外界使用的头文件,且分别放在最终产物的 Headers 和 PrivateHeaders 目录中,而 Project 中的头文件是不对外使用的,也不会放在最终的产物中。

如果你继续翻阅一些资料,例如 StackOverflow - Xcode: Copy Headers: Public vs. Private vs. Project?StackOverflow - Understanding Xcode’s Copy Headers phase,你会发现在早期 Xcode Help 的 Project Editor 章节里,有一段名为 Setting the Role of a Header File 的段落,里面详细记载了三个类型的区别。

Public: The interface is finalized and meant to be used by your product’s clients. A public header is included in the product as readable source code without restriction.
Private: The interface isn’t intended for your clients or it’s in early stages of development. A private header is included in the product, but it’s marked “private”. Thus the symbols are visible to all clients, but clients should understand that they’re not supposed to use them.
Project: The interface is for use only by implementation files in the current project. A project header is not included in the target, except in object code. The symbols are not visible to clients at all, only to you.

至此我们应该彻底了解了 Public,Private,Project 的区别,简而言之,Public 还是通常意义上的 Public,Private 则代表 In Progress 的含义,至于 Project 才是通常意义上的 Private 含义。

那么 CocoaPods 中 Podspec 的 Syntax 里还有 public_header_filesprivate_header_files 两个字段,它们的真实含义是否和 Xcode 里的概念冲突呢?

这里我们仔细阅读一下官方文档的解释,尤其是 private_header_files 字段。

IMAGE

我们可以看到,private_header_files 在这里的含义是说,它本身是相对于 public 而言的,这些头文件本义是不希望暴露给用户使用的,而且也不会产生相关文档,但是在构建的时候,会出现在最终产物中,只有既没有被 public 和 private 标注的头文件,才会被认为是真正的私有头文件,且不出现在最终的产物里。

其实这么看来,CocoaPods 对于 public 和 private 的理解是和 Xcode 中的描述一致的,两处的 Private 并非我们通常理解的 Private,它的本意更应该是开发者准备对外开放,但又没完全 ready 的头文件,更像一个 In Progress 的含义。

所以,如果你真的不想对外暴露某些头文件,请不要再使用 Headers 里的 Private 或者 podspec 里的 private_header_files 了。

至此,我想你应该彻底理解了 Search Path 的搜索机制和略显奇怪的 Public,Private,Project 设定了!

基于 hmap 优化 Search Path 的策略

在查找系统库的头文件的章节中,我们通过 -v 参数看到了寻找头文件的搜索顺序:

1
2
3
4
5
6
7
8
9
10
11
12
#include "..." search starts here:
XXX-generated-files.hmap (headermap)
XXX-project-headers.hmap (headermap)

#include <...> search starts here:
XXX-own-target-headers.hmap (headermap)
XXX-all-target-headers.hmap (headermap)
Header Search Path
DerivedSources
Build/Products/Debug (framework directory)
$(SDKROOT)/usr/include
$(SDKROOT)/System/Library/Frameworks(framework directory)

假设,我们没有开启 hmap 的话,所有的搜索都会依赖 header search path 或者 framework search path,那这就会出现 3 种问题:

  • 第一个问题,在一些巨型项目中,假设依赖的组件有 400+,那此时的索引路径就会达到 800+ 个(一份 public 路径,一份 private 路径),同时搜索操作可以看做是一种 IO 操作,而我们知道 IO 操作通常也是一种耗时操作,那么,这种大量的耗时操作必然会导致编译耗时增加。
  • 第二个问题,在打包的过程中,如果 header search path 过多过长,会触发命令行过长的错误,进而导致命令执行失败的情况。
  • 第三个问题,在引入系统库的头文件时,clang 会将前面提到的目录遍历完才进入搜索系统库的路径,也就是 $(SDKROOT)/System/Library/Frameworks(framework directory),即前面的 header search 路径越多,耗时也会越长,这是相当不划算的。

那如果我们开启 hmap 后,是否就能解决掉所有的问题呢?

实际上并不能,而且在基于 CocoaPods 管理项目的状况下,又会带来新的问题。下面是一个基于 CocoaPods 构建的全源码工程项目,它的整体结构如下:

首先,Host 和 Pod 是我们的两个 Project,Pods 下的 target 的产物类型为 static library。

其次,Host 底下会有一个同名的 Target,而 Pods 目录下会有 n+1 个 target,其中 n 取决于你依赖的组件数量,而 1 是一个名为 Pods-XXX 的 target,最后,Pods-XXX 这个 target 的产物会被 Host 里的 target 所依赖。

整个结构看起来如下所示。

画板.png

此时我们将 PodA 里的文件全部放在 Header 的 Project 类型中。

IMAGE

在基于 Framework 的搜索机制下,我们是无法以任何方式引入到 ClassB 的,因为它既不在 Headers 目录,也不在 PrivateHeader 目录中。

可是如果我们开启了 Use Header Map 后,由于 PodA 和 PodB 都在 Pods 这个 Project 下,满足了 Header 的 Project 定义,通过 Xcode 自动生成的 hmap 文件会带上这个路径,所以我们还可以在 PodB 中以 #import "ClassB.h" 的方式引入。

而这种行为,我想应该是大多数人并不想要的结果,所以一旦开启了 Use Header Map,再结合 CocoaPods 管理工程项目的模式,我们极有可能会产生一些误用私有头文件的情况,而这个问题的本质是 Xcode 和 CocoaPods 在工程和头文件上的理念冲突造成的。

除此之外,CocoaPods 在处理头文件的问题上还有一些让人迷惑的地方,它在创建头文件产物这块的逻辑大致如下:

  • 在构建产物为 Framework 的情况下:
    • 根据 podspec 里的 public_header_files 字段的内容,将相应头文件设置为 Public 类型,并放在 Headers 中
    • 根据 podspec 里的 private_header_files 字段的内容,将相应文件设置为 Private 类型,并放在 PrivateHeader 中
    • 将其余未描述的头文件设置为 Project 类型,且不放入最终的产物中
    • 如果 podspec 里未标注 public 和 private 的时候,会将所有文件设置为 public 类型,并放在 Header 中
  • 在构建产物为 Static Library 的情况下:
    • 不论 podspec 里如何设置 public_header_filesprivate_header_files,相应的头文件都会被设置为 Project 类型
    • Pods/Headers/Public 中会保存所有被声明为 public_header_files 的头文件
    • Pods/Headers/Private 中会保存所有头文件,不论是 public_header_files 或者 private_header_files 描述到,还是那些未被描述的,这个目录下是当前组件的所有头文件全集
    • 如果 podspec 里未标注 public 和 private 的时候,Pods/Headers/PublicPods/Headers/Private 的内容一样且会包含所有头文件。

正是由于这种机制,还导致了另外一种有意思的问题。

在 Static Library 的状况下,一旦我们开启了 Use Header Map,结合组件里所有头文件的类型为 Project 的情况,这个 hmap 里只会包含 #import "A.h" 的键值引用,也就是说只有 #import "A.h" 的方式才会命中 hmap 的策略,否则都将通过 header search path 寻找其相关路径。

而我们也知道,在引用其他组件的时候,通常都会采用 #import <A/A.h> 的方式引入。至于为什么会用这种方式,一方面是这种写法会明确头文件的由来,避免问题,另一方面也是这种方式可以让我们在是否开启 clang module 中随意切换,当然还有一点就是,Apple 在 WWDC 里曾经不止一次的建议开发者使用这种方式引入头文件。

接着上面的话题来说,所以说在 Static Library 的情况下且以 #import <A/A.h> 这种标准方式引入头文件时,开启 Use Header Map 并不会提升编译速度,而这同样是 Xcode 和 CocoaPods 在工程和头文件上的理念冲突造成的。

画板.png

这样来看的话,虽然 hmap 有种种优势,但是在 CocoaPods 的世界里显得格格不入,也无法发挥自身的优势。

那这就真的没有办法解决了么?

当然,问题是有办法解决的,我们完全可以自己动手做一个基于 CocoaPods 规则下的 hmap 文件。

举一个简单的例子,通过遍历 PODS 目录里的内容去构建索引表内容,借助 hmap 工具生成 header map 文件,然后将 Cocoapods 在 header search path 中生成的路径删除,只添加一条指向我们自己生成的 hmap 文件路径,最后关闭 Xcode 的 Ues Header Map 功能,也就是 Xcode 自动生成 hmap 的功能,如此这般,我们就实现了一个简单的,基于 CocoaPods 的 header map 功能。

同时在这个基础上,我们还可以借助这个功能实现不少管控手段,例如

  • 从根本上杜绝私有文件被暴露的可能性。
  • 统一头文件的引用形式

目前我们已经自研了一套基于上述原理的 cocoapods 插件,它的名字叫做 cocoapods-hmap-prebuilt,是由笔者与同事 @宋旭陶 共同开发的,

说了这么多,让我们看看它在实际工程中的使用效果!

经过全源码编译的测试,我们可以看到该技术在提速上的收益较为明显,以美团和点评 App 为例,全链路时长能够提升 45% 以上,其中 Xcode 打包时间能提升 50%。

关于第二个问题

对于已开启 clang module 特性的组件,clang 是如何决定编译当下组件的 module 呢?另外构建的细节又是怎样的,以及如何查找这些 module 的?还有查找系统的 module 和非系统的 module 有什么区别么?

首先,我们来明确一个问题, clang 是如何决定编译当下组件的 module 呢

#import <Foundation/NSString.h> 为例,当我们遇到这个头文件的时候:

首先会去 Framework 的 Headers 目录下寻找相应的头文件是否存在,然后就会到 Modules 目录下查找 modulemap 文件

画板.png

此时,Clang 会去查阅 modulemap 里的内容,看看 NSString 是否为 Foundation 这个 Module 里的一部分,

1
2
3
4
5
6
7
8
9
10
11
12
13
// Module Map - Foundation.framework/Modules/module.modulemap
framework module Foundation [extern_c] [system] {
umbrella header "Foundation.h"
export *
module * {
export *
}

explicit module NSDebug {
header "NSDebug.h"
export *
}
}

很显然,这里通过 umbrella header,我们是可以在 Foundation.h 中找到 NSString.h 的。

1
2
3
4
5
6
// Foundation.h

#import <Foundation/NSStream.h>
#import <Foundation/NSString.h>
#import <Foundation/NSTextCheckingResult.h>

至此,clang 会判定 NSString.h 是 Foundation 这个 module 的一部分并进行相应的编译工作,此时也就意味着 #import <Foundation/NSString.h> 会从之前的 textual import 变为 module import

Module 的构建细节

上面的内容解决了是否构建 module,而这一块我们会详细阐述构建 module 的过程!

在构建开始前,clang 会创建一个完全独立的空间来构建 module,在这个空间里会包含 module 涉及的所有文件,除此之外不会带入其他任何文件的信息,而这也是 module 健壮性好的关键因素之一。

不过,这并不意味着我们无法影响到 module 的唯一性,真正能影响到其唯一性的是其构建的参数,也就是 clang 命令后面的内容,关于这一点后面还会继续展开,这里我们先点到为止。

当我们在构建 Foundation 的时候,我们会发现 Foundation 自身要依赖一些组件,这意味着我们也需要构建被依赖组件的 module

画板.png

但很明显的是,我们会发现这些被依赖组件也有自己的依赖关系,在它们的这些依赖关系中,极有可能会存在重复的引用。

画板.png

此时 module 的复用机制就体现出来优势了,我们可以复用先前构建出来的 module,而不必一次次的创建或者引用,例如 Drawin 组件,而保存这些缓存文件的位置就是前面章节里提到的保存 pcm 类型文件的地方。

先前我们提到了 clang 命令的参数会真正影响到 module 的唯一性,那具体的原理又是怎样的?

clang 会将相应的编译参数进行一次 hash,将获得的 hash 值作为 module 缓存文件夹的名称,这里需要注意的是,不同的参数和值会导致文件夹不同,所以想要尽可能的利用 module 缓存,就必须保证参数不发生变化。

1
2
3
4
5
6
7
8
9
10
11
12
13
$ clang -fmodules —DENABLE_FEATURE=1 …
## 生成的目录如下
98XN8P5QH5OQ/
CoreFoundation-2A5I5R2968COJ.pcm
Security-1A229VWPAK67R.pcm
Foundation-1RDF848B47PF4.pcm

$ clang -fmodules —DENABLE_FEATURE=2 …
## 生成的目录如下
1GYDULU5XJRF/
CoreFoundation-2A5I5R2968COJ.pcm
Security-1A229VWPAK67R.pcm
Foundation-1RDF848B47PF4.pcm

这里我们大概了解了系统组件的 module 构建机制,这也是开启 Enable Modules(C and Objective-C) 的核心工作原理。

神秘的 Virtual File System(VFS)

对于系统组件,我们可以在 /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator14.2.sdk/System/Library/Frameworks 目录里找到它的身影,它的目录结构大概是这样的

IMAGE

也就是说,对于系统组件而言,构建 module 的整个过程是建立在这样一个完备的文件结构上,即在 Framework 的 Modules 目录中查找 modulemap,在 Headers 目录中加载头文件。

那对于用户自己创建的组件,clang 又是如何构建 module 的呢?

通常我们的开发目录大概是下面的样子,它并没有 modules 目录,也没有 headers 目录,更没有 modulemap 文件,看起来和 framework 的文件结构也有着极大的区别。

IMAGE

在这种情况下,clang 是没法按照前面所说的机制去构建 module 的,因为在这种文件结构中,压根就没有 Modules 和 Headers 目录。

为了解决这个问题,clang 又提出了一个新的解决方案,叫做 Virtual File System(VFS)。

简单来说,通过这个技术,clang 可以在现有的文件结构上虚拟出来一个 Framework 文件结构,进而让 clang 遵守前面提到的构建准则,顺利完成 module 的编译,同时 VFS 也会记录文件的真实位置,以便在出现问题的时候,将文件的真实信息暴露给用户。

为了进一步了解 VFS,我们还是从 Build Log 中查找一些细节!

画板.png

在上面的编译参数里,我们可以找到一个 -ivfsoverlay 的参数,查看 help 说明,可以知道其作用就是向编译器传递一个 VFS 描述文件并覆盖掉真实的文件结构信息。

1
-ivfsoverlay <value>    Overlay the virtual filesystem described by file over the real file system

顺着这个线索,我们去看看这个参数指向的文件,它是一个 yaml 格式的文件,在将内容进行了一些裁剪后,它的核心内容如下,:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
{
"case-sensitive": "false",
"version": 0,
"roots": [
{
"name": "XXX/Debug-iphonesimulator/PodA/PodA.framework/Headers",
"type": "directory",
"contents": [
{ "name": "ClassA.h", "type": "file",
"external-contents": "XXX/PodA/PodA/Classes/ClassA.h"
},
......
{ "name": "PodA-umbrella.h", "type": "file",
"external-contents": "XXX/Target Support Files/PodA/PodA-umbrella.h"
}
]
},
{
"contents": [
"name": "XXX/Products/Debug-iphonesimulator/PodA/PodA.framework/Modules",
"type": "directory"
{ "name": "module.modulemap", "type": "file",
"external-contents": "XXX/Debug-iphonesimulator/PodA.build/module.modulemap"
}
]
}
]
}

结合前面提到的内容,我们不难看出它在描述这样一个文件结构:

借用一个真实存在的文件夹来模拟 framework 里的 Headers 文件夹,在这个 Headers 文件夹里有名为 PodA-umbrella.hClassA.h 等的文件,不过这几个虚拟文件与 external-contents 指向的真实文件相关联,同理还有 Modules 文件夹和它里面的 module.modulemap 文件。

通过这样的形式,一个虚拟的 framework 目录结构诞生了!此时 clang 终于能按照前面的构建机制为用户创建 module 了!

Swift 来了

没有头文件的 Swift

前面的章节我们聊了很多 C 语言系的预编译知识,在这个体系下,文件的编译是分开的,当我们想引用其他文件里的内容时,就必须引入相应的头文件。

画板.png

而对于 Swift 这门语言来说,它并没有头文件的概念,对于开发者而言,这确实省去了写头文件的重复工作,但这也意味着,编译器会进行额外的操作来查找接口定义并需要持续关注接口的变化!

为了更好的解释 Swift 和 Objective-C 是如何寻找到彼此的方法声明的,我们这里引入一个例子,在这个例子由三个部分组成:

  • 第一部分是一个 ViewController 的代码,它里面包含了一个 view,其中 PetViewController 和 PetView 都是 Swift 代码。
  • 第二部分是一个 App 的代理,它是 Objective-C 代码。
  • 第三个部分是一段单测代码,用来测试第一个部分中的 ViewController,它是 Swift 代码。
1
2
3
4
5
import UIKit
class PetViewController: UIViewController {
var view = PetView(name: "Fido", frame: frame)

}
1
2
3
4
#import "PetWall-Swift.h"
@implementation AppDelegate

@end
1
2
3
@testable import PetWall
class TestPetViewController: XCTestCase {
}

它们的关系大致如下所示:

画板.png

为了能让这些代码编译成功,编译器会面对如下 4 个场景:

首先是寻找声明,这包括寻找当前 target 内的方法声明(PetView),也包括来自 Objective-C 组件里的声明(UIViewController 或者 PetKit)。

然后是生成接口,这包括被 Objective—C 使用的接口,也包括被其他 target (Unit Test)使用的 Swift 接口。

第一步 - 如何寻找 Target 内部的 Swift 方法声明

在编译 PetViewController.swift 时,编译器需要知道 PetView 的初始化构造器的类型,才能检查调用是否正确。

此时编译器会加载 PetView.swift 文件并解析其中的内容, 这么做的目的就是确保初始化构造器真的存在,并拿到相关的类型信息,以便 PetViewController.swift 进行验证。

画板.png

编译器并不会对初始化构造器的内部做检查,但它仍然会进行一些额外的操作,这是什么意思呢?

与 clang 编译器不同的是,swiftc 编译的时候,会将相同 target 里的其他 swift 文件进行一次解析,用来检查其中与被编译文件关联的接口部分是否符合预期。

同时我们也知道,每个文件的编译是独立的,且不同文件的编译是可以并行开展的,所以这就意味着每编译一个文件,就需要将当前 target 里的其余文件当做接口,重新编译一次。 等于任意一个文件,在整个编译过程中,只有 1 次被作为生产 .o 产物的输入,其余时间会被作为接口文件反复解析。

画板.png

不过在 Xcode 10 以后,Apple 对这种编译流程进行了优化!

在尽可能保证并行的同时,将文件进行了分组编译,这样就避免了 group 内的文件重复解析,只有不同 group 之间的文件会有重复解析文件的情况。

画板.png

而这个分组操作的逻辑,就是刚才提到的一些额外操作。

至此,我们应该了解了 Target 内部是如何寻找 Swift 方法声明的了。

第二步 - 如何找到 Objective-C 组件里的方法声明

回到第一段代码中,我们可以看到 PetViewController 是继承自 UIViewController,而这也意味着我们的代码会与 Objective-C 代码进行交互,因为大部分系统库,例如 UIKit 等,还是使用 Objective-C 编写的。

在这个问题上,Swift 采用了和其他语言不一样的方案!

通常来说,两种不同的语言在混编时需要提供一个接口映射表,例如 JavaScript 和 TypeScript 混编时候的 .d.ts 文件,这样 TypeScript 就能够知道 JavaScript 方法在 TS 世界中的样子。

然而,Swift 不需要提供这样的接口映射表, 免去了开发者为每个 Objective-C API 声明其在 Swift 世界里样子,那它是怎么做到的呢?

很简单,Swift 编译器将 clang 的大部分功能包含在其自身的代码中,这就使得我们能够以 module 的形式,直接引用 Objective-C 的代码。

画板.png

既然是通过 module 的形式引入 Objective-C,那么 framework 的文件结构则是最好的选择,此时编译器寻找方法声明的方式就会有下面三种场景:

  • 对于大部分的 target 而言,当导入的是一个 Objective-C 类型的 framework 时,编译器会通过 modulemap 里的 header 信息寻找方法声明

  • 对于一个既有 Objective-C,又有 Swift 代码的 framework 而言,编译器会从当前 framework 的 umbrella header 中寻找方法声明,从而解决自身的编译问题,这是因为通常情况下 modulemap 会将 umbrella header 作为自身的 header 值。

  • 对于 App 或者 Unit Test 类型的 target,开发者可以通过为 target 创建 briding header 来导入需要的 Objective-C 头文件,进而找到需要的方法声明。

不过我们应该知道 Swift 编译器在获取 Objective-C 代码过程中,并不是原原本本的将 Objective—C 的 API 暴露给 Swift,而是会做一些 “Swift 化” 的改动,例如下面的 Objective-C API 就会被转换成更简约的形式。

画板.png

这个转换过程并不是什么高深的技术,它只是在编译器上的硬编码,如果感兴趣,可以在 Swift 的开源库中的找到相应的代码 - PartsOfSpeech.def

当然,编译器也给与了开发者自行定义 “API 外貌” 的权利,如果你对这一块感兴趣,不妨阅读我的另一篇文章 - WWDC20 10680 - Refine Objective-C frameworks for Swift,那里面包含了很多重塑 Objective-C API 的技巧。

不过这里还是要提一句,如果你对生成的接口有困惑,可以通过下面的方式查看编译器为 Objective-C 生成的 Swift 接口。

IMAGE

第三步 - Target 内的 Swift 代码是如何为 Objective-C 提供接口的

前面讲了 Swift 代码是如何引用 Objective-C 的 API,那么 Objective-C 又是如何引用 Swift 的 API 呢?

从使用层面来说,我们都知道 Swift 编译器会帮我们自动生成一个头文件,以便 Objective-C 引入相应的代码,就像第二段代码里引入的 PetWall-Swift.h 文件,这种头文件通常是编译器自动生成的,名字的构成是 组件名-Swift 的形式。

画板.png

但它到底是怎么产生的呢?

在 Swift 中,如果某个类继承了 NSObject 类且 API 被 @objc 关键字标注,就意味着它将暴露给 Objective-C 代码使用。

不过对于 App 和 Unit Test 类型的 target 而言,这个自动生成的 header 会包含访问级别为 public 和 internal 的 API,这使得同一 target 内的 Objective-C 代码也能访问 Swift 里 internal 类型的 API,这也是所有 Swift 代码的默认访问级别。

但对于 framework 类型的 target 而言,Swift 自动生成的头文件只会包含 public 类型的 API,因为这个头文件会被作为构建产物对外使用,所以像 internal 类型的 API 是不会包含在这个文件中。

注意,这种机制会导致在 framework 类型的 target 中,如果 Swift 想暴露一些 API 给内部的 Objective-C 代码使用,就意味着这些 API 也必须暴露给外界使用,即必须将其访问级别设置为 public 。

那么编译器自动生成的 API 到底是什么样子,有什么特点呢?

画板.png

上面是截取了一段自动生成的头文件代码,左侧是原始的 Swift 代码,右侧是自动生成的 Objective-C 代码,我们可以看到在 Objective-C 的类中,有一个名为 SWIFT_CLASS 的宏,将 Swift 与 Objective-C 中的两个类进行了关联。

如果你稍加注意,就会发现关联的一段乱码中还绑定了当前的组件名(PetWall),这样做的目的是避免两个组件的同名类在运行时发生冲突。

当然,你也可以通过向 @objc(Name) 关键字传递一个标识符,借由这个标识符来控制其在 Objective-C 中的名称,如果这样做的话,需要开发者确保转换后的类名不与其他类名出现冲突。

画板.png

这大体上就是 Swift 如何像 Objective-C 暴露接口的机理了,如果你想更深入的了解这个文件的由来,就需要看看第四步。

第四步 - Swift Target 如何生成供外部 Swift 使用的接口

Swift 采用了 Clang Module 的理念,并结合自身的语言特性进行了一系列的改进。

在 Swift 中,module 是方法声明的分发单位,如果你想引用相应的方法,就必须引入对应的 module,之前我们也提到了 swift 的编译器包含了 clang 的大部分内容,所以它也是兼容 clang module 的。

所以我们可以引入 Objective-C 的 module,例如 XCTest,也可以引入 Swift Target 生成的 module,例如 PetWall

1
2
3
4
5
6
7
8
import XCTest
@testable import PetWall
class TestPetViewController: XCTestCase {
func testInitialPet() {
let controller = PetViewController()
XCTAssertEqual(controller.view.name, "Fido")
}
}

在引入 swift 的 module 后,编译器会反序列化一个后缀名为 .swiftmodule 的文件,并通过这种文件里的内容来了解相关接口的信息。

例如,以下图为例,在这个单元测试中,编译器会加载 PetWall 的 module,并在其中找寻 PetViewController 的方法声明,由此确保其创建行为是符合预期的。

画板.png

这看起来很像第一步中 target 寻找内部 Swift 方法声明的样子,只不过这里将解析 swift 文件的步骤,换成了解析 swiftmodule 文件而已。

不过需要注意的是,这个 swfitmodule 文件并不是文本文件,它是一个二进制格式的内容,通常我们可以在构建产物的 Modules 文件夹里寻找到它的身影。

IMAGE

在 target 的编译的过程中,面向整个 target 的 swiftmodule 文件并不是一下产生的,每一个 swift 文件都会生成一个 swiftmodule 文件,编译器会将这些文件进行汇总,最后再生成一个完整的,代表整个 target 的 swiftmodule,也正是基于这个文件,编译器构造出了用于给外部使用的 Objective-C 头文件,也就是第三步里提到的头文件

画板.png

不过随着 Swift 的发展,这一部分的工作机制也发生了些许变化。

我们前面提到的 swiftmodule 文件是一种二进制格式的文件,而这个文件格式会包含一些编译器内部的数据结构,不同编译器产生的 swiftmodule 文件是互相不兼容的,这也就导致了不同 Xcode 构建出的产物是无法通用的,如果对这方面的细节感兴趣,可以阅读 Swift 社区里的两篇官方 Blog:Evolving Swift On Apple Platforms After ABI StabilityABI Stability and More,这里就不展开讨论了。

为了解决这一问题,Apple 在 Xcode 11 的 Build Setting 中提供了一个新的编译参数 Build Libraries for Distribution,正如这个编译参数的名称一样,当我们开启它后,构建出来的产物不会再受编译器版本的影响,那它是怎么做到这一点的呢?

为了解决这种对编译器的版本依赖,Xcode 在构建产物上提供了一个新的产物,swiftinterface 文件。

IMAGE

这个文件里的内容和 swiftmodule 很相似,都是当前 module 里的 API 信息,不过 swiftinterface 是以文本的方式记录,而非 swiftmodule 的二进制方式。

这就使得 swiftinterface 的行为和源代码一样,后续版本的 swift 编译器也能导入之前编译器创建的 swiftinterface 文件,像使用源码的方式一样使用它。

为了更进一步了解它,我们来看看 swiftinterface 的真实样子,下面是一个 .swift 文件和 .swiftinterface 文件的比对图。

画板.png

在 swiftinterface 文件中,有以下点需要注意

  • 文件会包含一些元信息,例如文件格式版本,编译器信息,和 Swift 编译器将其作为模块导入所需的命令行子集。
  • 文件只会包含 public 的接口,而不会包含 private 的接口,例如 currentLocation
  • 文件只会包含方法声明,而不会包含方法实现,例如 Spacesship 的 init,fly 等方法
  • 文件会包含所有隐式声明的方法,例如 Spacesship 的 deinit 方法 ,Speed 的 Hashable 协议

总的来说,swiftinterface 文件会在编译器的各个版本中保持稳定,主要原因就是这个接口文件会包含接口层面的一切信息,不需要编译器再做任何的推断或者假设。

好了,至此我们应该了解了 Swift Target 是如何生成供外部 Swift 使用的接口了。

这四步意味着什么?

此 module 非彼 module

通过上面的例子,我想大家应该能清楚的感受到 swift module 和 clang module 不完全是一个东西,虽然它们有很多相似的地方。

clang module 是面向 C 语言家族的一种技术,通过 modulemap 文件来组织 .h 文件中的接口信息,中间产物是二进制格式的 pcm 文件。

swift module 是面向 Swift 语言的一种技术,通过 swiftinterface 文件来组织 .swift 文件中的接口信息,中间产物二进制格式的 swiftmodule 文件。

画板.png

所以说理清楚这些概念和关系后,我们在构建 Swift 组件的产物时,就会知道哪些文件和参数不是必须的了。

例如当你的 Swift 组件不想暴露自身的 API 给外部的 Objective-C 代码使用的话,可以将 Build Setting 中 Swift Compiler - General 里的 Install Objective-C Compatiblity Header 参数设置为 NO,其编译参数为 SWIFT_INSTALL_OBJC_HEADER,此时不会生成 <ProductModuleName>-Swift.h 类型的文件,也就意味着外部组件无法以 Objective-C 的方式引用组件内 Swift 代码的 API。

IMAGE

而当你的组件里如果压根就没有 Objective-C 代码的时候,你可以将 Build Setting 中 Packaging 里 Defines Module 参数设置为 NO,其编译参数为 DEFINES_MODULE, 此时不会生成 <ProductModuleName>.modulemap 类型的文件。

IMAGE

Swift 和 Objective-C 混编的三个“套路”

基于刚才的例子,我们应该理解了 swift 在编译时是如何找到其他 API 的,以及它又是如何暴露自身 API 的,而这些知识就是解决混编过程中的基础知识,为了加深影响,我们可以将其绘制成 3 个流程图

当 Swift 和 Objective-C 文件同时在一个 App 或者 Unit Test 类型的 target 中,不同类型文件的 API 寻找机制如下

画板.png

当 Swift 和 Objective-C 文件在不同 target 中,例如不同 Framework 中,不同类型文件的 API 寻找机制如下

画板.png

当 Swift 和 Objective-C 文件同时在一个target 中,例如同一 Framework 中,不同类型文件的 API 寻找机制如下

画板.png

对于第三个流程图,需要做以下补充说明

  • 由于 swiftc,也就是 swift 的编译器,包含了大部分的 clang 功能,其中就包含了 clang module,借由组件内已有的 modulemap 文件,swift 编译器就可以轻松找到相应的 Objective-C 代码。
  • 相比于第二个流程而言,第三个流程中的 modulemap 是组件内部的,而第二个流程中,如果想引用其他组件里的 Objective-C 代码,需要引入其他组件里的 modulemap 文件才可以
  • 所以基于这个考虑,并未在流程 3 中标注 modulemap。

构建 Swift 产物的新思路

在前面的章节里,我们提到了 Swift 找寻 Objective-C 的方式,其中提到了,除了 App 或者 Unit Test 类型的 target 外,其余的情况下都是通过 framework 的 module map 来寻找 Objective-C 的 API,那么如果我们不想使用 framework 的形式呢?

目前来看,这个在 Xcode 中是无法直接实现的,原因很简单,Build Setting 中 Search Path 选项里并没有 modulemap 的 search path 配置参数。

IMAGE

为什么一定需要 modulemap 的 search path 呢?

基于前面了解到的内容,swiftc 包含了 clang 的大部分逻辑,在预编译方面,swiftc 只包含了 clang module 的模式,而没有其他模式,所以 Objective-C 想要暴露自己的 API 就必须通过 modulemap 来完成。

而对于 Framework 这种标准的文件夹结构,modulemap 文件的相对路径是固定的,它就在 Modules 目录中,所以 Xcode 基于这种标准结构,直接内置了相关的逻辑,而不需要将这些配置再暴露出来。

从组件的开发者角度来看,他只需要关心 modulemap 的内容是否符合预期,以及路径是否符合规范。

从组件的使用者角度来看,他只需要正确的引入相应的 Framework 就可以使用到相应的 API。

这种只需要配置 Framework 的方式,避免了配置 header search path,也避免了配置 static library path,可以说是一种很友好的方式,如果再将 modulemap 的配置开放出来,反而显得多此一举。

那如果我们抛开 Xcode,抛开 Framework 的限制,还有别的办法构建 Swift 产物么?

答案是肯定有的,这就需要借助前面所说的 VFS 技术!

假设我们的文件结构如下所示:

1
2
3
4
5
6
7
8
9
├── LaunchPoint.swift
├── README.md
├── build
├── repo
│ └── MyObjcPod
│ └── UsefulClass.h
└── tmp
├── module.modulemap
└── vfs-overlay.yaml

其中 LaunchPoint.swift 引用了 UsefulClass.h 中的一个公开 API,并产生了依赖关系。

另外,vfs-overlay.yaml 文件重新映射了现有的文件目录结构,其内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
'version': 0,
'roots': [
{ 'name': '/MyObjcPod', 'type': 'directory',
'contents': [
{ 'name': 'module.modulemap', 'type': 'file',
'external-contents': 'tmp/module.modulemap'
},
{ 'name': 'UsefulClass.h', 'type': 'file',
'external-contents': 'repo/MyObjcPod/UsefulClass.h'
}
]
}
]
}

至此,我们通过如下的命令,便可以获得 LaunchPoint 的 swiftmodule,swiftinterface 等文件,具体的示例可以查看我在 github 上的链接 - manually-expose-objective-c-API-to-swift-example

1
swiftc -c LaunchPoint.swift -emit-module -emit-module-path build/LaunchPoint.swiftmodule -module-name index -whole-module-optimization -parse-as-library -o build/LaunchPoint.o -Xcc -ivfsoverlay -Xcc tmp/vfs-overlay.yaml -I /MyObjcPod

那这意味着什么呢?

这就意味着,只提供相应的 .h 文件和 .modulemap 文件就可以完成 Swift 二进制产物的构建,而不再依赖 Framework 的实体。同时,对于 CI 系统来说,在构建产物时,可以避免下载无用的二进制产物(.a 文件),这从某种程度上会提升编译效率。

如果你没太理解上面的意思,我们可以展开说说。

例如,对于 PodA 组件而言,它自身依赖 PodB 组件,在使用原先的构建方式时,我们需要拉取 PodB 组件的完整 Framework 产物,这会包含 Headers 目录,Modules 目录里的必要内容,当然还会包含一个二进制文件(PodB),但在实际编译 PodA 组件的过程中,我们并不需要 B 组件里的二进制文件,而这让拉取完整的 Framework 文件显得多余了。

IMAGE

而借助 VFS 技术,我们就能避免拉取多余的二进制文件,进一步提升 CI 系统的编译效率。

总结

感谢你的耐心阅读,至此,整篇文章终于结束了,通过这篇文章,我想你应该:

  • 理解 Objective-C 的三种预编译的工作机制,其中 clang module 做到了真正意义上的语义引入,提升了编译的健壮性和扩展性。
  • 在 Xcode 的 search path 的各种技术细节使用到了 hmap 技术,通过加载映射表的方式避免了大量重复的 IO 操作,可以提升编译效率。
  • 在处理 Framework 的头文件索引时,总是会先搜索 Headers 目录,再搜索 PrivateHeader 目录
  • 理解 Xcode Phases 构建系统中,Public 代表公开头文件,Private 代表不需要使用者感知,但物理存在的文件, 而 Project 代表不应让使用者感知,且物理不存在的文件。
  • 不使用 Framework 的情况下且以 #import <A/A.h> 这种标准方式引入头文件时,在 CocoaPods 上使用 hmap 并不会提升编译速度。
  • 通过 cocoapods-hmap-built 插件,可以将大型项目的全链路时长节省 45% 以上,Xcode 打包环节的时长节省 50% 以上。
  • clang module 的构建机制确保了其不受上下文影响(独立编译空间),复用效率高(依赖决议),唯一性(参数哈希化)
  • 系统组件通过已有的 Framework 文件结构实现了构建 module 的基本条件 ,而非系统组件通过 VFS 虚拟出相似的 Framework 文件 结构,进而具备了编译的条件。
  • 可以粗浅的将 Clang Module 里的 .h/m.moduelmap.pch 的概念对应为 Swift Module 里的 .swift.swiftinterface.swiftmodule 的概念
  • 理解三种具有普适性的 Swift 与 Objective-C 混编方法
    • 同一 target 内(App 或者 Unit 类型),基于 <PorductModuleName>-Swift.h<PorductModuleName>-Bridging-Swift.h
    • 同一 target 内,基于 <PorductModuleName>-Swift.h 和 clang 自身的能力
    • 不同 target 内,基于 <PorductModuleName>-Swift.hmodule.modulemap
  • 利用 VFS 机制构建,可以在构建 Swift 产物的过程中避免下载无用的二进制产物,进一步提升编译效率

最后,在编写这篇文章的过程中,我的同事 @叶樉 和 @宋旭陶 给与了我许多指导与帮助,也正是在大家的共同努力下,才有了这篇文章,希望它能对亲爱的读者您,有所帮助!

参考文档

作者简介

思琦,笔名 SketchK,美团点评 iOS 工程师,目前负责移动端 CI/CD 方面的工作及平台内 Swift 技术相关的事宜。

cocoapods-hmap-prebuilt - 一款可以让大型 iOS 工程编译速度提升 50% 的工具

作者 SketchK
2022年8月30日 00:32

这篇文章是我早年在美团技术博客上发布的一篇文章, 部分内容可能已经过时, 请开发者注意.
将文章同步到个人博客主要是为了同步和备份.

cocoapods-hmap-prebuilt 是什么?

cocoapods-hmap-prebuilt 是美团平台迭代组自研的一款 cocoapods 插件,以 Header Map 技术 为基础,进一步提升代码的编译速度,完善头文件的搜索机制。

虽然以二进制组件的方式构建 App 是 HPX (公司移动端统一持续集成/交付平台)的主流解决方案,但在某些场景下(Profile、Address/Thread/UB/Coverage Sanitizer、App 级别静态检查、ObjC 方法调用兼容性检查等等等等),我们的构建工作还是需要以全源码编译的方式进行;再结合实际开发过程中,大多是以源码的方式开发,所以我们将实验对象设置为基于全源码编译的流程。

废话不多说,我们来看看它的实际使用效果!

总的来说,以美团和大众点评的全源码编译流程为实验对象的前提下,cocoapods-hmap-prebuilt 插件能将总链路提升 45% 以上的速度,在 Xcode 打包环节上能提升 50% 以上的速度,是不是有点动心了?

为了更好的理解这个插件的价值和功能,我们不妨先理解一下当前的工程中存在的问题!

为什么现有的项目不够好?

目前公司内的 App 都是基于 CocoaPods 做包管理方面的工作,所以在实际的开发过程中,CocoaPods 会在 Pods/Header/ 目录下添加组件名目录和头文件软链,类似于下面的形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/Users/sketchk/Desktop/MyApp/Pods
└── Headers
├── Private
│ └── AFNetworking
│ ├── AFHTTPRequestOperation.h -> ./XXX/AFHTTPRequestOperation.h
│ ├── AFHTTPRequestOperationManager.h -> ./XXX/AFHTTPRequestOperationManager.h
│ ├── ...
│ └── UIRefreshControl+AFNetworking.h -> ./XXX/UIRefreshControl+AFNetworking.h
└── Public
└── AFNetworking
   ├── AFHTTPRequestOperation.h -> ./XXX/AFHTTPRequestOperation.h
   ├── AFHTTPRequestOperationManager.h -> ./XXX/AFHTTPRequestOperationManager.h
   ├── ...
   └── UIRefreshControl+AFNetworking.h -> ./XXX/UIRefreshControl+AFNetworking.h

也正是通过这样的目录结构和软链,CocoaPods 得以在 Header Search Path 中添加如下的参数,使得预编译环节顺利进行。

1
2
3
4
5
$(inherited)
${PODS_ROOT}/Headers/Private
${PODS_ROOT}/Headers/Private/AFNetworking
${PODS_ROOT}/Headers/Public
${PODS_ROOT}/Headers/Public/AFNetworking

虽然这种构建 search path 的方式解决了预编译的问题,但在某些项目中,例如多达 400+ 组件的巨型项目中,会造成以下几点问题:

  1. 大量的 header search path 路径,会造成编译参数中的 -I 选项极速膨胀,在达到一定长度后,甚至会造成无法编译的情况
  2. 目前美团的工程中,已经有近 5W 个头文件,这意味着不论是头文件的搜索过程,还是软链的创建过程,都会引起大量的文件 IO 操作,进而会产生一些耗时操作。
  3. 编译时间会随着组件数量急剧增长,以美团和大众点评有 400+ 个组件的体量为参考,全源码打包耗时均为 1 小时以上。
  4. 基于路径顺序查找头文件的方式有潜在的风险,例如重名头文件的情况,排在后面的头文件永远无法参与编译
  5. 由于 ${PODS_ROOT}/Headers/Private 路径的存在,让引用其他组件的私有头文件变为了可能。

上面的问题,好一点的不过是浪费了 1 个小时而已,而不好的情况则是让有风险的代码上线了,你说开发者头疼不头疼?

Header Map 是个啥?

还好 cocoapods-hmap-prebuilt 的出现,让这些问题变成了历史,不过要想理解它为什么能解决这些问题,我们得先理解一下什么是 Header Map!

Header Map 其实是一组头文件信息映射表!

为了更直观的理解 Header Map,我们可以在 build setting 中开启 Use Header Map 选项,真实的体验一下它。

IMAGE

然后在 build log 里获取相应组件里对应文件的编译命令,并在最后加上 -v 参数,来查看其运行的秘密:

1
clang <list of arguments> -c some-file.m -o some-file.o -v

在 console 的输出内容中,我们会发现一段有意思的内容:

IMAGE

通过上面的图,我们可以看到编译器将寻找头文件的顺序和对应路径展示出来了,而在这些路径中,我们看到了一些陌生的东西,即后缀名为 .hmap 的文件,后面还有个括号写着 headermap。

没错!它就是 Header Map 的实体。

此时 clang 已经在刚才提到的 hmap 文件里塞入了一份头文件名和头文件路径的映射表,不过它是一种二进制格式的文件,为了验证这个的说法,我们可以通过 milend 编写的hmap 工具来查其内容。

在执行相关命令(即 hmap print)后,我们可以发现这些 hmap 里保存的信息结构大致如下, 类似于一个 key-value 的形式,key 值是头文件的名称,value 是头文件的实际物理路径:

IMAGE

需要注意,映射表的键值内容会随着使用场景产生不同的变化,例如头文件引用是在 "..." 的形式下,还是 <...> 的形式下,又或是在 Build Phase 里 Header 的配置情况。例如,你将头文件设置为 public 的时候,在某些 hmap 中,它的 key 值就为 PodA/ClassA,而将其设置为 project 的时候,它的 key 值可能就是 ClassA,而配置这些信息的地方,如下图所示:

IMAGE

至此我想你应该了解到 Header Map 到底是个什么东西了。

当然这种技术也不是一个什么新鲜事儿,在 Facebook 的 buck 工具中也提供了类似的东西,只不过文件类型变成了 HeaderMap.java 的样子。

此时,我估计你可能并不会对 buck 产生太多的兴趣,而是开始思考上一张图中 Headers 的 public,private,project 到底代表着什么意思,好像我从来没怎么关注过,以及为什么它会影响 hmap 里的内容?

Public,Private,Project 是个啥?

在 Apple 官方的 Xcode Help - What are build phases? 文档中,我们可以看到如下的一段解释:

Associates public, private, or project header files with the target. Public and private headers define API intended for use by other clients, and are copied into a product for installation. For example, public and private headers in a framework target are copied into Headers and PrivateHeaders subfolders within a product. Project headers define API used and built by a target, but not copied into a product. This phase can be used once per target.

总的来说,我们可以知道一点,就是 Build Phases - Headers 中提到 Public 和 Private 是指可以供外界使用的头文件,而 Project 中的头文件是不对外使用的,也不会放在最终的产物中。

如果你继续翻阅一些资料,例如 StackOverflow - Xcode: Copy Headers: Public vs. Private vs. Project?StackOverflow - Understanding Xcode’s Copy Headers phase,你会发现在早期 Xcode Help 的 Project Editor 章节里,有一段名为 Setting the Role of a Header File 的段落,里面详细记载了三个类型的区别。

Public: The interface is finalized and meant to be used by your product’s clients. A public header is included in the product as readable source code without restriction.
Private: The interface isn’t intended for your clients or it’s in early stages of development. A private header is included in the product, but it’s marked “private”. Thus the symbols are visible to all clients, but clients should understand that they’re not supposed to use them.
Project: The interface is for use only by implementation files in the current project. A project header is not included in the target, except in object code. The symbols are not visible to clients at all, only to you.

至此我们应该彻底了解了 Public,Private,Project 的区别,简而言之,Public 还是通常意义上的 Public,Private 则代表 In Progress 的含义,至于 Project 才是通常意义上的 Private 含义。

此时,你会不会联想到 CocoaPods 中 Podspec 的 Syntax 里还有 public_header_filesprivate_header_files 两个字段,它们的真实含义是否和 Xcode 里的概念冲突呢?

这里我们仔细阅读一下官方文档的解释,尤其是 private_header_files 字段。

画板.png

我们可以看到,private_header_files 在这里的含义是说,它本身是相对于 public 而言的,这些头文件本义是不希望暴露给用户使用的,而且也不会产生相关文档,但是在构建的时候,会出现在最终产物中,只有既没有被 public 和 private 标注的头文件,才会被认为是真正的私有头文件,且不出现在最终的产物里。

其实看起来,CocoaPods 对于 public 和 private 的官方解释是和 Xcode 中的描述一致的,两处的 Private 并非我们通常理解的 Private,它的本意更应该是开发者准备对外开放,但又没完全 Ready 的头文件,更像一个 In Progress 的含义。

这一块是不是有点让人大跌眼镜,那么,在现实世界中,我们是否正确的使用了它们呢?

为什么用原生的 hmap 不能改善编译速度?

前面我们介绍了 hmap 是什么,以及怎么开启它(启用 Build Setting 中的 Use Header Map 选项),也介绍了一些影响生成 hmap 的因素(Public,Private,Project)

那是不是我只要开启 Xcode 提供的 Use Header Map 就可以提升编译速度了呢?

很可惜,答案是不行的!

至于原因,我们就从下面的例子开始说起,假设我们有一个基于 CocoaPods 构建的全源码工程项目,它的整体结构如下:

  • 首先,Host 和 Pod 是我们的两个 Project,Pods 下的 target 的产物类型为 static library。
  • 其次,Host 底下会有一个同名的 Target,而 Pods 目录下会有 n+1 个 target,其中 n 取决于你依赖的组件数量,而 1 是一个名为 Pods-XXX 的 target,最后,Pods-XXX 这个 target 的产物会被 Host 里的 target 所依赖。

整个结构看起来如下所示。

IMAGE

当构建的产物类型为 Static Library 的时候,CocoaPods 在创建头文件产物过程中,它的逻辑大致如下:

  • 不论 podspec 里如何设置 public_header_filesprivate_header_files,相应的头文件都会被设置为 Project 类型
  • Pods/Headers/Public 中会保存所有被声明为 public_header_files 的头文件
  • Pods/Headers/Private 中会保存所有头文件,不论是 public_header_files 或者 private_header_files 描述到,还是那些未被描述的,这个目录下是当前组件的所有头文件全集
  • 如果 podspec 里未标注 public 和 private 的时候,Pods/Headers/PublicPods/Headers/Private 的内容一样且会包含所有头文件。

正是由于这种机制,会导致一些有意思的问题发生。

  • 首先,由于所有头文件都被当做最终产物保留下来,在结合 header search path 里 Pods/Headers/Private 路径的存在,我们完全可以引用到其他组件里的私有头文件,例如我只要使用 #import <SomePod/Private_Header.h> 的方式,就会命中私有文件的匹配路径。
  • 其次,就是在 Static Library 的状况下,一旦我们开启了 Use Header Map,结合组件里所有头文件的类型为 Project 的情况,这个 hmap 里只会包含 #import "ClassA.h" 的键值引用,也就是说只有 #import "ClassA.h" 的方式才会命中 hmap 的策略,否则都将通过 header search path 寻找其相关路径,例如下图中的 PodB,在其 build 的过程中,Xcode 会为 PodB 生成 5 个 hmap 文件,也就是说这 5 个文件只会在编译 PodB 中使用,其中 PodB 会依赖 PodA 的一些头文件,但由于 PodA 中的头文件都是 Project 类型的,所以其在 hmap 里的 key 全部为 ClassA.h ,也就是说我们只能以 #import "ClassA.h" 的方式引入。

IMAGE

而我们也知道,在引用其他组件的时候,通常都会采用 #import <A/A.h> 的方式引入。至于为什么会用这种方式,一方面是这种写法会明确头文件的由来,避免问题,另一方面也是这种方式可以让我们在是否开启 clang module 中随意切换,当然还有一点就是,Apple 在 WWDC 里曾经不止一次的建议开发者使用这种方式引入头文件。

接着上面的话题来说,所以说在 Static Library 的情况下且以 #import <A/A.h> 这种标准方式引入头文件时,开启 Use Header Map 选项并不会帮我们提升编译速度。

但真的就没有办法使用 Header Map 了么?

cocoapods-hmap-prebuilt 诞生了

当然,总是有办法解决的,我们完全可以自己动手做一个基于 CocoaPods 规则下的 hmap 文件,正是基于这个想法,美团自研的 cocoapods-hmap-prebuilt 插件诞生了!

它的核心功能并不多,大概有以下几点:

  • 借助 CocodPods 处理 Header Search Path 和创建头文件 soft link 的时机,构建了头文件索引表并以此生成 n+1 个 hmap 文件(n 是每个组件自己的 private header 信息,1 是所有组件公共的 public header 信息)
  • 重写 xcconfig 文件里的 header search path 到对应的 hmap 文件上,一条指向组件自己的 private hmap,一条指向所有组件共用的 public hmap。
  • 针对 public hmap 里的重名头文件进行了特殊处理,只允许保存组件名/头文件名方式的 key-value,排查重名头文件带来的异常行为。
  • 将组件自身的 Ues Header Map 功能关闭,减少不必要的文件创建和读取

听起来可能有点绕,内容也有点多,不过这些你都不用关心,你只需要通过以下 2 个步骤就能将其使用起来:

  1. 在 Gemfile 里声明插件
  2. 在 Podfile 里使用插件
1
2
3
4
5
6
7
8
9
10
11
12
13
// this is part of Gemfile
source 'http://sakgems.sankuai.com/' do
gem 'cocoapods-hmap-prebuilt'
gem 'XXX'
...
end

// this is part of Podfile
target 'XXX' do
plugin 'cocoapods-hmap-prebuilt'
pod 'XXX'
...
end

除此之外,为了拓展其实用性,我们还提供了头文件补丁(解决重名头文件的定向选取)和环境变量注入(无侵入的在其他系统中使用)的能力,便于其在不同场景下的使用。

总结

至此,关于 cocoapods-hmap-prebuilt 的介绍就要结束了。

回看整个故事的开始,Header Map 是我在研究 Swift 和 Objective-C 混编过程中发现的一个很小的知识点,而且 Xcode 自身就实现了一套基于 Header Map 的功能,在实际的使用过程中,它的表现并不理想。

但幸运的是,在后续的探索的过程中,我们发现了为什么 Xcode 的 Header Map 没有生效,以及为什么它与 CocoaPods 出现了不兼容的情况,虽然它的原理并不复杂,核心点就是将文件查找和读取等 IO 操作编变成了内存读取操作,但结合实际的业务场景,我们发现它的收益是十分可观的。

或许这是在提醒我们,要永远对技术保持一颗好奇的心!

最后,非常感谢 @宋旭陶 同学在工作之余,和我一起完成了 cocoapods-hmap-prebuilt 插件的开发工作,也非常感谢 @叶樉 同学,在我困惑的时候给出很多富有建设性的指导和意见。

其实利用 clang module 技术也可以解决本文一开始提到的几个问题,但它并不在这篇文章的讨论范围中,如果你对 clang module 或者对 Swift 与 Objective-C 混编感兴趣,欢迎阅读参考文档中的 《从预编译的角度理解 Swift 与 Objective-C 及混编机制》一文,以了解更多的详细信息。

参考文档

作者

思琦,笔名 SketchK,美团点评 iOS 工程师,目前负责移动端 CI/CD 方面的工作及平台内 Swift 技术相关的事宜。

Xcode Concept 学习笔记

作者 SketchK
2020年5月15日 04:04

只怪自己当年学东西不够扎实,这次让我好好理解一下 Xcode 里的相关基础概念吧!

如果使用 Xcode 进行开发,我们常常会与这么几个概念打交道:Workspace,Project,Target,Scheme 和 Build Setting。

官方对这些概念的解释可以参考这篇文档 - Xcode Concepts

虽然文档本身已经被列为 Archived 的状态,但大毛病没有,还是可以拿来再学习一遍,所以后面的内容将围绕它展开。

另外在学习和调研的过程中,我发现了一个蛮有价值的博客,地址是 pewpewthespells.com,里面有不少关于 Xcode 的文章,估计是个做 CI/CD 工作的妹子,下面的几篇文章可以当做索引手册收藏起来:

概述

  • Target -一个 target 代表一个产品
  • Project - 对应 .xcodeproj 类型的文件,project 包含了构建产品所需的源文件,一个 project 可以有多个 target
  • Workspace - 对应 .xcworkspace 文件,用来组织管理 project 和其他文档的,workspace 可以包含多个 project,project 可以属于多个 workspace
  • Scheme - scheme 决定了哪个 target 去运行,它可以针对编译,运行,测试,打包等进行配置
  • Build Setting - build settings 就是构建产品时的一些设置,target 可以覆盖 project 一些相同的设置

Xcode Target

一个 Target 确定一个产物(product)的构建,包括一些指令(instructions),例如怎么从一个 Project 或者 Workspace 的一堆文件导出一个产物。简单来说,一个 Target 就定义了一个产物,一个 Target 对应一个 Product,它管理着一个产物的 Build System 的“输入”(一堆源文件和一些处理这些源文件的 Instruction)。 Projects 可以包含一个或者多个 Target,它们代表不同的产物,例如:如果你的产物需要做 Lite 和 Pro 版本,那么你可以考虑采取两个 Target 来处理。

构建一个产物的 Instructions(指令)的表现形式是构建设置(Build Settings),构建规则(Buidling Rules)和构建参数(Build Phases),这些都可以在 Xcode 的 Project Editor 中调整。一个 Target 的 Build Settings 继承 Project 的 Build Settings,但是可以重写覆盖 Project Settings。同时间内只能有一个 Active Target,Xcode Scheme 能够指定 Active Target。

一个 Target 可以跟其他 Target 相关联。如果一个 Target 在构建的时候需要另外一个 Target 的输出,我们说前者依赖于后者。

如果两个 Target 在相同的 Workspace 里,Xcode 能够发现它们的依赖关系,它能够以需要的顺序构建产品。这样的关系可以被称为隐形从属依赖(Implicit Dependency)。当然你也可以在 Build Setting 里为两个 Targets 指明显示依赖关系(Explicit Dependency)。

例如:在同一个 workspace 中,可以构建一个 library 和一个链接这个 library 的 application。Xcode 可以发现这种依赖关系,并首先自动构建 library。但是,如果想链接某个版本的 library,就需要在 build settings 明确依赖关系,该依赖项会覆盖隐式依赖项。

Xcode Project

Xcode Project 是个构建一个或者多个产物所需要的文件,资源,信息等的存储库(repository)。Project 包含用于构建产物的所有元素,并且管理这些元素间的关系。它包含一个或多个 Target,指定怎样去构建产品。Project 在工程里面默认的为所有的 Target 指定 Build Settings,当然每个 Target 可以覆盖 Project 的 Build Settings,去指定自己特有的 Build Settings。

一个 Xcode project 包含下面的信息:

  • 源文件的引用:
    • 源码,包括头文件和实现文件
    • Libraries and Frameworks
    • 资源文件(plist等)
    • 图片文件
    • nib 文件(xib, stroyboard等)
  • 用于在结构导航器( structure navigator)中组织源文件(source files)的组(groups),这里又分物理文件和引用文件
  • Project 级别的 Build Configurations. 你可以为 Project 指定多个 Build Configuration,例如,Xcode 就默认为我们指定了 Debug 和 Release 的 Build settings,当然你也可以自定义。
  • Targets,每个 Target 会指定:
    • 通过 Project 构建的一个产物的引用
    • 构建该产物所需的资源文件的引用
    • 用于构建该产物的构建配置(Build configurations),包括对其他 Targets 和 Settings 的依赖;如果 Targets 的 build configurations 没有配置时,使用 Project 级别的 Build Configurations
  • 用来 Debug 和 Test 程序的可执行环境(Executable Environment),包括:
    • 从 Xcode run 或 debug 时启动的可执行文件
    • 要传递给可执行文件的命令行参数
    • 程序 run 时要设置的环境变量

A project 可以单独存在,也可以被包含在 workspace 里面(cocoapods 就是被包含在 workspace 里面)。

Xcode Workspace

一个 Workspace 是一个 Xcode 文档,组合不同的 Project、文档,所以你可以同时管理多个 Project。一个 Workspace 可以包含任意数量的 Xcode projects 和其他文件。除了组织每个 Xcode Projects 中的所有文件外,Workspace 还维护 projects 与他们各自 Targets 之间的隐式/显示关联。

Workspace 扩展 workflows 的范围

一个工程文件(Project File)包含指向 project 中所有文件的指针,Build Configurations 和 Project 的其他信息。在 Xcode 3 之前,Projects 之前关联是很复杂的事情,大多数工作流仅限于单个 Project。从 Xcode 4 之后,你可以创建一个 Workspace 去包含多个 Projects 和其他文件。

除了提供被包含在 Xcode Project 中的所有文件的访问外,Workspace 还拓展许多重要的 Xcode Workflows 的范围。例如,由于 indexing(文件索引)遍布整个 Workspace,所以,在 workspace 中, code completion、Jump to Definition 和所有其他的内容感知特性,可以在所有 Projects 中无缝衔接运作。因为 refactoring operations(重构操作)横跨整个 Workspace 的所有内容,所以,你可以在一个 framework project 中重构 API,并且在其他 application projects 中使用这个 framework。构建时,一个 project 可以利用 workspace 中其他 projects 的 products。

workspace 文档包含被囊括的 projects 和其他文件,不再有其他数据。一个 project 可以被多个 workspace 持有。下图展示一个 workspace 包含两个 Xcode projects 以及一个文档 project。

Workspaces 中的 Projects 共享 Build Directory

默认情况下,Workspace 下面的 projects 都是在同一个目录下构建的,也就是 Workspace 的编译目录(workspace build directory)。由于是在同一个目录下面,Project 的资源文件都彼此都是可见的,可互相引用的。所以,如果你有多个 Projects 使用相同库的时候,不需要将它分别拷贝到各个 Project 中。

Xcode 会在编译目录下检查文件发现它们的隐形从属依赖。例如,如果 Workspace 中的一个 Project 编译的时候需要链接到相同 Workspace 的其他 Project 某个库,Xcode 会自动帮你先编译那个库,即使构建配置没有显式的指定从属依赖关系。如果需要的话,你可以指定显式从属依赖,但是你必须创建 Project 引用。

Workspace 中的每个 Project 仍然有属于它们自己的独立的标识。你能通过 project 的打开方式控制 project 受不受其他 projects 的影响,例如单独打开 Project 而不是通过 Workspace。因为,一个 Project 可以属于多个 Workspace,你可以任意组合 Projects,而无需重新配置 projects 或者 workspaces。

你可以使用 workspace 默认的 build directory,也可以自己指定一个。注意:如果一个 project 指定一个 build directory,这个 build directory 会覆盖全部所在的 workspace 里的默认 build directory。

Xcode Scheme

一个 Xcode Scheme(方案)定义三样东西:一个要生成的目标(targets to build)的集合、building 时使用的配置(configuration)、以及要执行的测试集合。

你可以拥有任意数量的 scheme,但一次只能有一个是活跃状态(active),你可以指定 scheme 是否储存在 project 中(这种方案下,scheme 在每一个包含这个 project 的 workspace 中都可用),或者储存在 workspace 中(仅在当前 workspace 中可用)。选择要激活的 scheme 时,可以选择运行目标(设备)。

Build Setting

一个 Build Setting 是一个变量,包含着如何构建产物的信息。例如,可以指定 Xcode 传递给编译器的选项

Build Settings 有 Project 和 Target 两个级别,Project 级别中的 Build Setting 适用项目中所有的 targets,只要该项 setting 没有被 Target 级别的重写覆盖。

每个 Target 管理着创建一个产物的源文件,一个 build configuration 指定一组 build settings,用于以特定的方式构建一个 product。例如,通常有 debug 和 release 俩种分开的 build configurations。

一个 Build Setting 包含两个部分:Setting Title(标题) 和 Definition(定义),类似于 key-value 结构。前者标示该 Build Setting 的名称,后者是一个常量或一个表达式,用于确定 Build Setting 的值。

另外,当你通过 Project 模板新建一个 Project 时,Xcode 会生成一个默认的 Build Settings,你也可以为 Project 或者某个 Target 创建自定义的 Build settings。你还可以设定 Conditional Build Settings,一个 Conditional Build setting 的值取决于是否满足一个或多个先决条件。这个机制也可以被用在指定用于基于目标架构构建产品的SDK。

参考资料

  1. Xcode: What is a target and scheme in plain language?
  2. Xcode Project vs. Xcode Workspace - Differences
  3. Xcode 相关概念
  4. iOS项目Project和Target配置详解
❌
❌