关于ios:从预编译的角度理解Swift与ObjectiveC及混编机制

50次阅读

共计 31067 个字符,预计需要花费 78 分钟才能阅读完成。

写在后面

本文涉及面较广,篇幅较长,浏览完须要消耗肯定的工夫与精力,如果你带有较为明确的浏览目标,能够参考以下倡议实现浏览:

  • 如果你对预编译的理论知识曾经理解,能够间接从【原来它是这样的】的章节开始进行浏览,这会让你对预编译有一个更直观的理解。
  • 如果你对 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 混编的内容,出于让更多开发者可能进一步了解混编工作机制的目标,撰写了这篇技术文章。

废话不多说,咱们开始吧!

预编译常识指北

#import 的机制和毛病

在咱们应用某些零碎组件的时候,咱们通常会写出如下模式的代码:

#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 的头文件依赖。

如果你感觉听起来有点吃力,或者似懂非懂,咱们这里能够举一个更加具体的例子,不过请记住,对于 C 语言的预处理器而言,#import 就是一种非凡的复制粘贴。

联合后面提到的内容,在 AppDelegate 中增加 iAd.h

#import <iAd/iAd.h>
@implementation AppDelegate
//...
@end

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

/* iAd/iAd.h */
#import <iAd/ADBannerView.h>
#import <iAd/ADBannerView_Deprecated.h>
#import <iAd/ADInterstitialAd.h>

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

#import <iAd/ADBannerView.h>
#import <iAd/ADBannerView_Deprecated.h>
#import <iAd/ADInterstitialAd.h>

@implementation AppDelegate
//...
@end

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

/* iAd/ADBannerView.h */
@interface ADBannerView : UIView
@property (nonatomic, readonly) ADAdType adType;

- (id)initWithAdType:(ADAdType)type

/* ... */
@end

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

@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,而这会触发编译器报错!

@interface ADBannerView : UIView
@property (nonatomic, 0x01) ADAdType adType;

- (id)initWithAdType:(ADAdType)type

/* ... */
@end

@implementation AppDelegate
//...
@end

面对这种谬误,你可能会说它是开发者本人的问题。

的确,通常咱们都会在申明宏的时候带上固定的前缀来进行辨别。但生存里总是有一些意外,不是么?

假如某个人没有恪守这种规定,那么在不同的引入程序下,你可能会失去不同的后果,对于这种谬误的排查,还是挺闹心的。不过,这还不是最闹心的,因为还有动静宏的存在,心塞 ing。

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

拓展性

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

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

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

如果咱们往该项目标某个外围文件(外围文件是指其余文件可能都须要依赖的文件)里增加了一个对 iAd.h 文件的援用,对其余文件意味着什么呢?

这里的外围文件是指其余文件可能都须要依赖的文件

这意味着其余文件也会把 iAd.h 里蕴含的货色纳入进来,当然,好消息是,iAd 这个 SDK 本身只有 25KB 左右的大小。

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

所以,怎么说呢?

在 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 这个组件,你只须要这样写即可。

@import iAd;

在应用层面上,这将等价于以前的 #import <iAd/iAd.h> 语句,然而会应用 Clang Module 的个性加载整个 iAd 组件。如果只想引入特定文件(比方 ADBannerView.h),原先的写法是 #import <iAd/ADBannerView.h.h>,当初能够写成:

@import iAd.ADBannerView;

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

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

此时,如果你感觉后面对于 Clang Module 的形容还是太形象,咱们能够再进一步去探索它工作原理,而这就会引入一个新的概念—— modulemap。

不论怎样,Module 只是一个对组件的形象形容罢了,而 modulemap 则是这个形容的具体出现,它对框架内的所有文件进行了结构化的形容,上面是 UIKit 的 modulemap 文件。

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 语句,从而防止了开发者的手动批改。

惟一须要开发者实现的就是开启相干的编译选项。

对于下面的编译选项,须要开发者留神的是:

Apple Clang - Language - ModulesEnable Module 选项是指援用零碎库的的时候,是否采纳 Module 的模式。

Packaging 里的 Defines Module 是指开发者编写的组件是否采纳 Module 的模式。

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

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

为了解答这些问题,咱们无妨先入手实际一下,看看下面的理论知识在事实中的样子。

原来它是这样的

在后面的章节中,咱们将重点放在了原理上的介绍,而在这个章节中,咱们将入手看看这些预编译环节的理论样子。

#import 的样子

假如咱们的源码款式如下:

#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 选项

既然晓得了如何查看预编译后的样子,咱们无妨看看代码在应用 #import, PCH 和 @import 后,到底会变成什么样子?

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

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

通过 preprocess 能够看到代码大抵如下,这里为了不便展现,将无用代码进行了删除。这里记得要将 Build Setting 中 Packaging 的 Define Module 设置为 NO,因为其默认值为 YES,而这会导致咱们开启 Clang Module 个性。

@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。

为了查看预编译的成果,咱们将 Precompile Prefix Header 的值改为 YES,并编译整个我的项目,通过查看 Build Log,咱们能够发现相比于 NO 的状态,在编译的过程中,减少了一个步骤,即 Precompile SQPod-Prefix.pch 的步骤。

通过查看这个命令的 -o 参数,咱们能够晓得其产物是名为 SQPod-Prefix.pch.gch 的文件。

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

又见 Clang Module

在开启 Define Module 后,零碎会为咱们主动创立相应的 modulemap 文件,这一点能够在 Build Log 中查找到。

它的内容如下:

framework module SQPod {
  umbrella header "SQPod-umbrella.h"

  export *
  module * {export *}
}

当然,如果零碎主动生成的 modulemap 并不能满足你的诉求,咱们也能够应用本人创立的文件,此时只须要在 Build Setting 的 Module Map File 选项中填写好文件门路,相应的 clang 命令参数是 -fmodule-map-file

最初让咱们看看 Module 编译后的产物状态。

这里咱们构建一个名为 SQPod 的 Module,将它提供给名为 Example 的工程应用,通过查看 -fmodule-cache-path 的参数,咱们能够找到 Module 的缓存门路。

进入对应的门路后,咱们能够看到如下的文件:

其中后缀名为 pcm 的文件就是构建进去的二进制两头产物。

当初,咱们不仅晓得了预编译的基础理论常识,也入手查看了预编译环节在实在环境下的产物,当初咱们要开始解答之前提到的两个问题了!

打破砂锅问到底

对于第一个问题

对于未开启 Clang Module 个性的组件,Clang 是通过怎么的机制查找到头文件的呢?在查找零碎头文件和非零碎头文件的过程中,有什么区别么?

在晚期的 Clang 编译过程中,头文件的查找机制还是基于 Header Seach Path 的,这也是大多数人所熟知的工作机制,所以咱们不做赘述,只做一个简略的回顾。

Header Search Path 是构建零碎提供给编译器的一个重要参数,它的作用是在编译代码的时候,为编译器提供了查找相应头文件门路的信息,通过查阅 Xcode 的 Build System 信息,咱们能够晓得相干的设置有三处 Header Search Path、System Header Search Path、User Header Search Path。

它们的区别也很简略,System Header Search Path 是针对零碎头文件的设置,通常代指 <> 形式引入的文件,uUser Header Search Path 则是针对非零碎头文件的设置,通常代指 "" 形式引入的文件,而 Header Search Path 并不会有任何限度,它普适于任何形式的头文件援用。

听起来如同很简单,但对于引入的形式,无非是以下四种模式:

#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 模式

在很多工程中,尤其是基于 CocoaPods 开发的我的项目,咱们曾经不会辨别 System Header Search Path 和 User Header Search Path,而是一股脑的将所有头文件门路增加到 Header Search Path 中,这就导致咱们在援用某个头文件时,不会再局限于后面提到的约定,甚至在某些状况下,后面提到的四种形式都能够做到引入某个指定头文件。

Header Maps

随着我的项目的迭代和倒退,原有的头文件索引机制还是受到了一些挑战,为此,Clang 官网也提出了本人的解决方案。

为了了解这个货色,咱们首先要在 Build Setting 中开启 Use Header Map 选项。

而后在 Build Log 里获取相应组件里对应文件的编译命令,并在最初加上 -v 参数,来查看其运行的机密:

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

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

通过下面的图,咱们能够看到编译器将寻找头文件的程序和对应门路展现进去了,而在这些门路中,咱们看到了一些生疏的货色,即后缀名为 .hmap 的文件。

那 hmap 到底这是个什么货色呢?

当咱们开启 Build Setting 中的 Use Header Map 选项后,会主动生成的一份头文件名和头文件门路的映射表,而这个映射表就是 hmap 文件,不过它是一种二进制格局的文件,也有人叫它为 Header Map。总之,它的外围性能就是让编译器可能找到相应头文件的地位。

为了更好的了解它,咱们能够通过 milend 编写的小工具 hmap 来查其内容。

在执行相干命令(即 hmap print)后,咱们能够发现这些 hmap 里保留的信息结构大抵如下:

须要留神,映射表的键值并不是简略的文件名和绝对路径,它的内容会随着应用场景产生不同的变动,例如头文件援用是在 "..." 的模式,还是 <...> 的模式,又或是在 Build Phase 里 Header 的配置状况。

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

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

查找零碎库的头文件

下面的过程让咱们了解了在 Header Map 技术下,编译器是如何寻找相应的头文件的,那针对零碎库的文件又是如何索引的呢?例如 #import <Foundation/Foundation.h>

回忆一下上一节 console 的输入内容,它的模式大略如下:

#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)

咱们会发现,这些门路大部分是用于查找非零碎库文件的,也就是开发者本人引入的头文件,而与零碎库相干的门路只有以下两个:

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

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

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

接着,咱们会进入 Framework 的 Headers 文件夹里寻找对应的头文件。

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

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

以上便是零碎库的头文件搜寻逻辑。

Framework Search Path

到当初为止,咱们曾经解释了如何依赖 Header Search Path、hmap 等技术寻找头文件的工作机制,也介绍了寻找零碎库(System Framework)头文件的工作机制。

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

对于开发者本人的 Framework,可能会存在 “private” 头文件,例如在 podspec 里用 private_header_files 的形容文件,这些文件在构建的时候,会被放在 Framework 文件构造中的 PrivateHeaders 目录。

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

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

不过也正是因为这个工作机制,会产生一个特地有意思的问题,那就是当咱们应用 Framework 的形式引入某个带有 “Private” 头文件的组件时,咱们总是能够以上面的形式引入这个头文件!

怎么样,是不是很神奇,这个被形容为 “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 字段。

咱们能够看到,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 参数看到了寻找头文件的搜寻程序:

#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 所依赖。

整个构造看起来如下所示:

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

在基于 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 在工程和头文件上的理念抵触造成的。

这样来看的话,尽管 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 文件。

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

// 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 的。

// 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。

但很显著的是,咱们会发现这些被依赖组件也有本人的依赖关系,在它们的这些依赖关系中,极有可能会存在反复的援用。

此时,Module 的复用机制就体现进去劣势了,咱们能够复用先前构建进去的 Module,而不用一次次的创立或者援用,例如 Drawin 组件,而保留这些缓存文件的地位就是后面章节里提到的保留 pcm 类型文件的中央。

先前咱们提到了 Clang 命令的参数会真正影响到 Module 的唯一性,那具体的原理又是怎么的?

Clang 会将相应的编译参数进行一次 Hash,将取得的 Hash 值作为 Module 缓存文件夹的名称,这里须要留神的是,不同的参数和值会导致文件夹不同,所以想要尽可能的利用 Module 缓存,就必须保障参数不发生变化。

$ 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 目录里找到它的身影,它的目录构造大略是这样的:

也就是说,对于零碎组件而言,构建 Module 的整个过程是建设在这样一个齐备的文件构造上,即在 Framework 的 Modules 目录中查找 modulemap,在 Headers 目录中加载头文件。
那对于用户本人创立的组件,Clang 又是如何构建 Module 的呢?

通常咱们的开发目录大略是上面的样子,它并没有 Modules 目录,也没有 Headers 目录,更没有 modulemap 文件,看起来和 Framework 的文件构造也有着极大的区别。

在这种状况下,Clang 是没法依照后面所说的机制去构建 Module 的,因为在这种文件构造中,压根就没有 Modules 和 Headers 目录。

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

简略来说,通过这个技术,Clang 能够在现有的文件构造上虚构进去一个 Framework 文件构造,进而让 Clang 恪守后面提到的构建准则,顺利完成 Module 的编译,同时 VFS 也会记录文件的实在地位,以便在呈现问题的时候,将文件的实在信息裸露给用户。

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

在下面的编译参数里,咱们能够找到一个 -ivfsoverlay 的参数,查看 Help 阐明,能够晓得其作用就是向编译器传递一个 VFS 形容文件并笼罩掉实在的文件构造信息。

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

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

{
  "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 语言系的预编译常识,在这个体系下,文件的编译是离开的,当咱们想援用其余文件里的内容时,就必须引入相应的头文件。

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

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

  • 第一局部是一个 ViewController 的代码,它外面蕴含了一个 View,其中 PetViewController 和 PetView 都是 Swift 代码。
  • 第二局部是一个 App 的代理,它是 Objective-C 代码。
  • 第三个局部是一段单测代码,用来测试第一个局部中的 ViewController,它是 Swift 代码。
import UIKit
class PetViewController: UIViewController {var view = PetView(name: "Fido", frame: frame)
  …
}
#import "PetWall-Swift.h"
@implementation AppDelegate
…
@end
@testable import PetWall
class TestPetViewController: XCTestCase {}

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

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

首先是寻找申明,这包含寻找以后 Target 内的办法申明(PetView),也包含来自 Objective-C 组件里的申明(UIViewController 或者 PetKit)。

而后是生成接口,这包含被 Objective-C 应用的接口,也包含被其余 Target (Unit Test)应用的 Swift 接口。

第一步 – 如何寻找 Target 外部的 Swift 办法申明

在编译 PetViewController.swift 时,编译器须要晓得 PetView 的初始化结构器的类型,能力查看调用是否正确。

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

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

与 Clang 编译器不同的是,Swiftc 编译的时候,会将雷同 Target 里的其余 Swift 文件进行一次解析,用来查看其中与被编译文件关联的接口局部是否合乎预期。

同时咱们也晓得,每个文件的编译是独立的,且不同文件的编译是能够并行发展的,所以这就意味着每编译一个文件,就须要将以后 Target 里的其余文件当做接口,从新编译一次。等于任意一个文件,在整个编译过程中,只有 1 次被作为生产 .o 产物的输出,其余工夫会被作为接口文件重复解析。

不过在 Xcode 10 当前,Apple 对这种编译流程进行了优化。

在尽可能保障并行的同时,将文件进行了分组编译,这样就防止了 Group 内的文件反复解析,只有不同 Group 之间的文件会有反复解析文件的状况。

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

至此,咱们应该理解了 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 的代码。

既然是通过 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 就会被转换成更简洁的模式。

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

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

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

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

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

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

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

在 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 到底是什么样子,有什么特点呢?

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

如果你稍加留神,就会发现关联的一段乱码中还绑定了以后的组件名(PetWall),这样做的目标是防止两个组件的同名类在运行时发生冲突。

当然,你也能够通过向 @objc(Name) 关键字传递一个标识符,借由这个标识符来管制其在 Objective-C 中的名称,如果这样做的话,须要开发者确保转换后的类名不与其余类名呈现抵触。

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

第四步 – Swift Target 如何生成供内部 Swift 应用的接口

Swift 采纳了 Clang module 的理念,并联合本身的语言个性进行了一系列的改良。

在 Swift 中,Module 是办法申明的散发单位,如果你想援用相应的办法,就必须引入对应的 Module,之前咱们也提到了 Swift 的编译器蕴含了 Clang 的大部分内容,所以它也是兼容 Clang Module 的。

所以咱们能够引入 Objective-C 的 Module,例如 XCTest,也能够引入 Swift Target 生成的 Module,例如 PetWall。

import XCTest
@testable import PetWall
class TestPetViewController: XCTestCase {func testInitialPet() {let controller = PetViewController()
    XCTAssertEqual(controller.view.name, "Fido")
  }
}

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

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

这看起来很像第一步中 Target 寻找外部 Swift 办法申明的样子,只不过这里将解析 Swift 文件的步骤,换成了解析 Swiftmodule 文件而已。

不过须要留神的是,这个 Swfitmodule 文件并不是文本文件,它是一个二进制格局的内容,通常咱们能够在构建产物的 Modules 文件夹里寻找到它的身影。

在 Target 的编译的过程中,面向整个 Target 的 Swiftmodule 文件并不是一下产生的,每一个 Swift 文件都会生成一个 Swiftmodule 文件,编译器会将这些文件进行汇总,最初再生成一个残缺的,代表整个 Target 的 Swiftmodule,也正是基于这个文件,编译器结构出了用于给内部应用的 Objective-C 头文件,也就是第三步里提到的头文件。

不过随着 Swift 的倒退,这一部分的工作机制也产生了些许变动。

咱们后面提到的 Swiftmodule 文件是一种二进制格局的文件,而这个文件格式会蕴含一些编译器外部的数据结构,不同编译器产生的 Swiftmodule 文件是相互不兼容的,这也就导致了不同 Xcode 构建出的产物是无奈通用的,如果对这方面的细节感兴趣,能够浏览 Swift 社区里的两篇官网 Blog:Evolving Swift On Apple Platforms After ABI Stability 和 ABI Stability and More,这里就不展开讨论了。

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

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

这个文件里的内容和 Swiftmodule 很类似,都是以后 Module 里的 API 信息,不过 Swiftinterface 是以文本的形式记录,而非 Swiftmodule 的二进制形式。

这就使得 Swiftinterface 的行为和源代码一样,后续版本的 Swift 编译器也能导入之前编译器创立的 Swiftinterface 文件,像应用源码的形式一样应用它。

为了更进一步理解它,咱们来看看 Swiftinterface 的实在样子,上面是一个 .swift 文件和 .swiftinterface 文件的比对图。

在 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 文件。

所以说理分明这些概念和关系后,咱们在构建 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。

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

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

基于方才的例子,咱们应该了解了 Swift 在编译时是如何找到其余 API 的,以及它又是如何裸露本身 API 的,而这些常识就是解决混编过程中的基础知识,为了加深影响,咱们能够将其绘制成 3 个流程图。

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

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

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

对于第三个流程图,须要做以下补充阐明:

  • 因为 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 配置参数。

为什么肯定须要 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 技术!

假如咱们的文件构造如下所示:

├── LaunchPoint.swift
├── README.md
├── build
├── repo
│   └── MyObjcPod
│       └── UsefulClass.h
└── tmp
    ├── module.modulemap
    └── vfs-overlay.yaml

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

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

{
  '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

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 文件显得多余了。

而借助 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 产物的过程中防止下载无用的二进制产物,进一步晋升编译效率。

参考文档

  • Apple – WWDC 2013 Advances in Objective-C
  • Apple – WWDC 2018 Behind the Scenes of the Xcode Build Process
  • Apple – WWDC 2019 Binary Frameworks in Swift
  • Apple – WWDC 2020 Distribute binary frameworks as Swift packages
  • Swift org – Evolving Swift On Apple Platforms After ABI Stability
  • Swift org – ABI Stability and More
  • StackOverflow – #import using angle brackets < > and quote marks“”
  • StackOverflow – Xcode: Copy Headers: Public vs. Private vs. Project?
  • StackOverflow – Understanding Xcode’s Copy Headers phase
  • Xcode Help – What are build phases?
  • Xcode Build Settings
  • Big Nerd Ranch – Manual Swift: Understanding the Swift/Objective-C Build Pipeline
  • Big Nerd Ranch – Build Log Groveling for Fun and Profit: Manual Swift Continued
  • Big Nerd Ranch – Build Log Groveling for Fun and Profit, Part 2: Even More Manual Swift
  • Quality Coding – 4 Ways Precompiled Headers Cripple Your Code
  • try! Swift Tokyo 2018 – Exploring Clang Modules
  • milen.me – Swift, Module Maps & VFS Overlays

作者简介

  • 思琦,笔名 SketchK,美团点评 iOS 工程师,目前负责挪动端 CI/CD 方面的工作及平台内 Swift 技术相干的事宜。
  • 旭陶,美团 iOS 工程师,目前负责 iOS 端开发提效相干事宜。
  • 霜叶,2015 年退出美团,先后从事过 Hybrid 容器、iOS 根底组件、iOS 开发工具链和客户端继续集成门户零碎等工作。

| 想浏览更多技术文章,请关注美团技术团队(meituantech)官网微信公众号。

| 在公众号菜单栏回复【2020 年货】、【2019 年货】、【2018 年货】、【2017 年货】、【算法】等关键词,可查看美团技术团队历年技术文章合集。

正文完
 0