從預(yù)編譯的角度理解Swift與Objective-C及混編機(jī)制

從預(yù)編譯的角度理解Swift與Objective-C及混編機(jī)制

版權(quán)說明,此文章版權(quán)為原作者所有窑邦。本人在此只是作為學(xué)習(xí)備忘記錄而已周伦。
原文地址

原創(chuàng) 思琦 旭陶 霜葉 美團(tuán)技術(shù)團(tuán)隊 2月25日

收錄于話題

iOS8

前端10

美團(tuán)平臺3

[圖片上傳中...(image-e9f679-1621421730412-61)]

總第436****篇

2021年 第006篇

[圖片上傳中...(image-8c1f76-1621421730411-59)]

本文從預(yù)編譯的基礎(chǔ)知識入手宫莱,由淺至深的介紹了 Objective-C 和 Swift 的工作機(jī)制只酥,并通過這些機(jī)制來解釋混編項目中使用到的技術(shù)和各種參數(shù)的作用娃胆,由此來指導(dǎo)開發(fā)者如何進(jìn)行混編遍希。

寫在前面

本文涉及面廣,篇幅較長里烦,閱讀完需要耗費一定的時間與精力凿蒜。如果你帶有較為明確的閱讀目的,可以參考以下建議進(jìn)行閱讀:

  • 如果你對預(yù)編譯的理論知識已經(jīng)了解胁黑,可以直接從【原來它是這樣的】的章節(jié)開始進(jìn)行閱讀废封,這會讓你對預(yù)編譯有一個更直觀的了解。
  • 如果你對 Search Path 的工作機(jī)制感興趣丧蘸,可以直接從【關(guān)于第一個問題】的章節(jié)閱讀漂洋,這會讓你更深刻,更全面的了解到它們的運(yùn)作機(jī)制,
  • 如果您對 Xcode Phases 里的 Header 的設(shè)置感到迷惑刽漂,可以直接從【揭開 Public演训、Private、Project 的真實面目】的章節(jié)開始閱讀贝咙,這會讓你理解為什么說 Private 并不是真正的私有頭文件
  • 如果你想了解如何通過 hmap 技術(shù)提升編譯速度仇祭,可以從【基于 hmap 優(yōu)化 Search Path 的策略】的章節(jié)開始閱讀,這會給你提供一種新的編譯加速思路颈畸。
  • 如果你想了解如何通過 VFS 技術(shù)進(jìn)行 Swift 產(chǎn)物的構(gòu)建乌奇,可以從 【關(guān)于第二個問題】章節(jié)開始閱讀,這會讓你理解如何用另外一種提升構(gòu)建 Swift 產(chǎn)物的效率眯娱。
  • 如果你想了解 Swift 和 Objective-C 是如何找尋方法聲明的礁苗,可以從 【Swift 來了】的章節(jié)閱讀,這會讓你從原理上理解混編的核心思路和解決方案徙缴。

概述

隨著 Swift 的發(fā)展试伙,國內(nèi)技術(shù)社區(qū)出現(xiàn)了一些關(guān)于如何實現(xiàn) Swift 與 Objective-C 混編的文章,這些文章的主要內(nèi)容還是圍繞著指導(dǎo)開發(fā)者進(jìn)行各種操作來實現(xiàn)混編的效果于样,例如在 Build Setting 中開啟某個選項疏叨,在 podspec 中增加某個字段,而鮮有文章對這些操作背后的工作機(jī)制做剖析穿剖,大部分核心概念也都是一筆帶過蚤蔓。

正是因為這種現(xiàn)狀,很多開發(fā)者在面對與預(yù)期不符的行為時糊余,亦或者遇到各種奇怪的報錯時秀又,都會無從下手,而這也是由于對其工作原理不夠了解所導(dǎo)致的贬芥。

筆者在美團(tuán)平臺負(fù)責(zé) CI/CD 相關(guān)的工作吐辙,這其中也包含了 Objective-C 與 Swift 混編的內(nèi)容,出于讓更多開發(fā)者能夠進(jìn)一步理解混編工作機(jī)制的目的蘸劈,撰寫了這篇技術(shù)文章昏苏。

廢話不多說,我們開始吧威沫!

預(yù)編譯知識指北

#import 的機(jī)制和缺點

在我們使用某些系統(tǒng)組件的時候贤惯,我們通常會寫出如下形式的代碼:

#import <UIKit/UIKit.h>

#import 其實是 #include 語法的微小創(chuàng)新,它們在本質(zhì)上還是十分接近的壹甥。#include 做的事情其實就是簡單的復(fù)制粘貼救巷,將目標(biāo) .h 文件中的內(nèi)容一字不落地拷貝到當(dāng)前文件中,并替換掉這句 #include句柠,而 #import 實質(zhì)上做的事情和 #include 是一樣的浦译,只不過它還多了一個能夠避免頭文件重復(fù)引用的能力而已棒假。

為了更好的理解后面的內(nèi)容,我們這里需要展開說一下它到底是如何運(yùn)行的精盅?

從最直觀的角度來看:

假設(shè)在 MyApp.m 文件中帽哑,我們 #importiAd.h 文件,編譯器解析此文件后叹俏,開始尋找 iAd 包含的內(nèi)容(ADInterstitialAd.h妻枕,ADBannerView.h),及這些內(nèi)容包含的子內(nèi)容(UIKit.h粘驰,UIController.h屡谐,UIView.hUIResponder.h)蝌数,并依次遞歸下去愕掏,最后,你會發(fā)現(xiàn) #import <iAd/iAd.h> 這段代碼變成了對不同 SDK 的頭文件依賴顶伞。

[圖片上傳中...(image-78b7e-1621421730408-56)]

如果你覺得聽起來有點費勁饵撑,或者似懂非懂,我們這里可以舉一個更加詳細(xì)的例子唆貌,不過請記住滑潘,對于 C 語言的預(yù)處理器而言, #import 就是一種特殊的復(fù)制粘貼锨咙。

結(jié)合前面提到的內(nèi)容语卤,在 AppDelegate 中添加 iAd.h

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

然后編譯器會開始查找 iAd/iAd.h 到底是哪個文件且包含何種內(nèi)容,假設(shè)它的內(nèi)容如下:

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

在找到上面的內(nèi)容后蓖租,編譯器將其復(fù)制粘貼到 AppDelegate 中:

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

現(xiàn)在粱侣,編譯器發(fā)現(xiàn)文件里有 3 個 #import 語句 了,那么就需要繼續(xù)尋找這些文件及其相應(yīng)的內(nèi)容蓖宦,假設(shè) ADBannerView.h 的內(nèi)容如下:

/* iAd/ADBannerView.h */@interface ADBannerView : UIView@property (nonatomic, readonly) ADAdType adType;- (id)initWithAdType:(ADAdType)type/* ... */@end

那么編譯器會繼續(xù)將其內(nèi)容復(fù)制粘貼到 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

這樣的操作會一直持續(xù)到整個文件中所有 #import 指向的內(nèi)容被替換掉油猫,這也意味著 .m文件最終將變得極其的冗長稠茂。

雖然這種機(jī)制看起來是可行的,但它有兩個比較明顯的問題:健壯性拓展性情妖。

健壯性

首先這種編譯模型會導(dǎo)致代碼的健壯性變差睬关!

這里我們繼續(xù)采用之前的例子,在 AppDelegate 中定義 readonly0x01毡证,而且這個定義的聲明在 #import 語句之前电爹,那么此時又會發(fā)生什么事情呢?

編譯器同樣會進(jìn)行剛才的那些復(fù)制粘貼操作料睛,但可怕的是丐箩,你會發(fā)現(xiàn)那些在屬性聲明中的 readonly 也變成了 0x01摇邦,而這會觸發(fā)編譯器報錯!

@interface ADBannerView : UIView@property (nonatomic, 0x01) ADAdType adType;- (id)initWithAdType:(ADAdType)type/* ... */@end@implementation AppDelegate//...@end

面對這種錯誤屎勘,你可能會說它是開發(fā)者自己的問題施籍。

確實,通常我們都會在聲明宏的時候帶上固定的前綴來進(jìn)行區(qū)分概漱。但生活里總是有一些意外丑慎,不是么?

假設(shè)某個人沒有遵守這種規(guī)則瓤摧,那么在不同的引入順序下竿裂,你可能會得到不同的結(jié)果,對于這種錯誤的排查照弥,還是挺鬧心的腻异。不過,這還不是最鬧心的产喉,因為還有動態(tài)宏的存在捂掰,心塞 ing。

所以這種靠遵守約定來規(guī)避問題的解決方案曾沈,并不能從根本上解決問題这嚣,這也從側(cè)面反應(yīng)了編譯模型的健壯性是相對較差的。

拓展性

說完了健壯性的問題塞俱,我們來看看拓展性的問題姐帚。

Apple 公司對它們的 Mail App 做過一個分析,下圖是 Mail 這個項目里所有 .m 文件的排序障涯,橫軸是文件編號排序罐旗,縱軸是文件大小。

[圖片上傳中...(image-2a6537-1621421730407-55)]

可以看到這些由業(yè)務(wù)代碼構(gòu)成的文件大小的分布區(qū)間很廣泛,最小可能有幾 kb拄轻,最大的能有 200+ kb请毛,但總的來說,可能 90% 的代碼都在 50kb 這個數(shù)量級之下鼓蜒,甚至更少。

如果我們往該項目的某個核心文件(核心文件是指其他文件可能都需要依賴的文件)里添加了一個對 iAd.h 文件的引用征字,對其他文件意味著什么呢都弹?

這里的核心文件是指其他文件可能都需要依賴的文件。

這意味著其他文件也會把 iAd.h 里包含的東西納入進(jìn)來匙姜,當(dāng)然畅厢,好消息是,iAd 這個 SDK 自身只有 25KB 左右的大小氮昧。

[圖片上傳中...(image-917334-1621421730407-54)]

但你得知道 iAd 還會依賴 UIKit 這樣的組件框杜,這可是個 400KB+ 的大家伙浦楣。

[圖片上傳中...(image-dcb370-1621421730407-53)]

所以,怎么說呢霸琴?在 Mail App 里的所有代碼都需要先涵蓋這將近 425KB 的頭文件內(nèi)容椒振,即使你的代碼只有一行 hello world

如果你認(rèn)為這已經(jīng)讓人很沮喪的話梧乘,那還有更打擊你的消息澎迎,因為 UIKit 相比于 macOS 上的 Cocoa 系列大禮包,真的小太多了选调,Cocoa 系列大禮包可是 UIKit 的 29 倍......

所以如果將這個數(shù)據(jù)放到上面的圖表中夹供,你會發(fā)現(xiàn)真正的業(yè)務(wù)代碼在Ffile Size 軸上的比重真的太微不足道了。

所以這就是拓展性差帶來的問題之一仁堪!

很明顯哮洽,我們不可能用這樣的方式引入代碼,假設(shè)你有 M 個源文件且每個文件會引入 N 個頭文件弦聂,按照剛才的解釋鸟辅,編譯它們的時間就會是 M * N,這是非齿汉可怕的匪凉!

備注:文章里提到的 iAd 組件為 25KB,UIKit 組件約為 400KB捺檬, macOS 的 Cocoa 組件是 UIKit 的 29 倍等數(shù)據(jù)再层,是 WWDC 2013 Session 404 Advances in Objective-C 里公布的數(shù)據(jù),隨著功能的不斷迭代堡纬,以現(xiàn)在的眼光來看聂受,這些數(shù)據(jù)可能已經(jīng)偏小,在 WWDC 2018 Session 415 Behind the Scenes of the Xcode Build Process 中提到了 Foundation 組件烤镐,它包含的頭文件數(shù)量大于 800 個蛋济,大小已經(jīng)超過 9MB。

PCH(PreCompiled Header)是一把雙刃劍

為了優(yōu)化前面提到的問題炮叶,一種折中的技術(shù)方案誕生了瘫俊,它就是 PreCompiled Header。

我們經(jīng)炽擦椋可以看到某些組件的頭文件會頻繁的出現(xiàn),例如 UIKit骂蓖,而這很容易讓人聯(lián)想到一個優(yōu)化點,我們是不是可以通過某種手段茫孔,避免重復(fù)編譯相同的內(nèi)容呢?

而這就是 PCH 為預(yù)編譯流程帶來的改進(jìn)點缰贝!

它的大體原理就是剩晴,在我們編譯任意 .m 文件前, 編譯器會先對 PCH 里的內(nèi)容進(jìn)行預(yù)編譯锣咒,將其變?yōu)橐环N二進(jìn)制的中間格式緩存起來毅整,便于后續(xù)的使用。當(dāng)開始編譯 .m 文件時戏蔑,如果需要 PCH 里已經(jīng)編譯過的內(nèi)容总棵,直接讀取即可,無須再次編譯刃唤。

雖然這種技術(shù)有一定的優(yōu)勢,但實際應(yīng)用起來笼裳,還存在不少的問題。

首先允青,它的維護(hù)是有一定的成本的卵沉,對于大部分歷史包袱沉重的組件來說琼掠,將項目中的引用關(guān)系梳理清楚就十分麻煩,而要在此基礎(chǔ)上梳理出合理的 PCH 內(nèi)容就更加麻煩,同時隨著版本的不斷迭代谤牡,哪些頭文件需要移出 PCH,哪些頭文件需要移進(jìn) PCH 將會變得越來越麻煩套么。

其次,PCH 會引發(fā)命名空間被污染的問題玷室,因為 PCH 引入的頭文件會出現(xiàn)在你代碼中的每一處,而這可能會是多于的操作,比如 iAd 應(yīng)當(dāng)出現(xiàn)在一些與廣告相關(guān)的代碼中身坐,它完全沒必要出現(xiàn)在幫助相關(guān)的代碼中(也就是與廣告無關(guān)的邏輯),可是當(dāng)你把它放到 PCH 中搪花,就意味組件里的所有地方都會引入 iAd 的代碼,包括幫助頁面,這可能并不是我們想要的結(jié)果房蝉!

如果你想更深入的了解 PCH 的黑暗面,建議閱讀 4 Ways Precompiled Headers Cripple Your Code 檀蹋,里面已經(jīng)說得相當(dāng)全面和透徹。

所以 PCH 并不是一個完美的解決方案,它能在某些場景下提升編譯速度坠七,但也有缺陷!

Clang Module 的來臨悉稠!

為了解決前面提到的問題,Clang 提出了 Module 的概念,關(guān)于它的介紹可以在 Clang 官網(wǎng) 上找到岂却。

簡單來說署浩,你可以把它理解為一種對組件的描述,包含了對接口(API)和實現(xiàn)(dylib/a)的描述,同時 Module 的產(chǎn)物是被獨立編譯出來的襟交,不同的 Module 之間是不會影響的。

在實際編譯之時,編譯器會創(chuàng)建一個全新的空間丘侠,用它來存放已經(jīng)編譯過的 Module 產(chǎn)物。如果在編譯的文件中引用到某個 Module 的話,系統(tǒng)將優(yōu)先在這個列表內(nèi)查找是否存在對應(yīng)的中間產(chǎn)物级零,如果能找到,則說明該文件已經(jīng)被編譯過,則直接使用該中間產(chǎn)物发绢,如果沒找到经柴,則把引用到的頭文件進(jìn)行編譯,并將產(chǎn)物添加到相應(yīng)的空間中以備重復(fù)使用韭寸。

在這種編譯模型下赴背,被引用到的 Module 只會被編譯一次,且在運(yùn)行過程中不會相互影響便瑟,這從根本上解決了健壯性和拓展性的問題颁督。

Module 的使用并不麻煩屿讽,同樣是引用 iAd 這個組件,你只需要這樣寫即可衩婚。

@import iAd;

在使用層面上,這將等價于以前的 #import <iAd/iAd.h> 語句护侮,但是會使用 Clang Module 的特性加載整個 iAd 組件。如果只想引入特定文件(比如ADBannerView.h),原先的寫法是 #import <iAd/ADBannerView.h.h>得哆,現(xiàn)在可以寫成:

@import iAd.ADBannerView;

通過這種寫法會將 iAd 這個組件的 API 導(dǎo)入到我們的應(yīng)用中闸餐,同時這種寫法也更符合語義化(semanitc import)近上。

雖然這種引入方式和之前的寫法區(qū)別不大,但它們在本質(zhì)上還是有很大程度的不同,module 不會“復(fù)制粘貼”頭文件里的內(nèi)容拒迅,也不會讓 @import 所暴露的 API 被開發(fā)者本地的上下文篡改硬梁,例如前面提到的 #define readonly 0x01屹电。

此時牧愁,如果你覺得前面關(guān)于 Clang Module 的描述還是太抽象,我們可以再進(jìn)一步去探究它工作原理, 而這就會引入一個新的概念—— modulemap俐填。

不論怎樣歇式,Module 只是一個對組件的抽象描述罷了,而 modulemap 則是這個描述的具體呈現(xiàn),它對框架內(nèi)的所有文件進(jìn)行了結(jié)構(gòu)化的描述,下面是 UIKit 的 modulemap 文件秸弛。

framework module UIKit {  umbrella header "UIKit.h"  module * {export *}  link framework "UIKit"}

這個 Module 定義了組件的 Umbrella Header 文件(UIKit.h)瞳腌,需要導(dǎo)出的子 Module(所有)儿捧,以及需要 Link 的框架名稱(UIKit),正是通過這個文件瞭空,讓編譯器了解到 Module 的邏輯結(jié)構(gòu)與頭文件結(jié)構(gòu)的關(guān)聯(lián)方式吴裤。

可能又有人會好奇钮蛛,為什么我從來沒看到過 @import 的寫法呢?

這是因為 Xcode 的編譯器能夠?qū)⒎夏撤N格式的 #import 語句自動轉(zhuǎn)換成 Module 識別的 @import 語句吱晒,從而避免了開發(fā)者的手動修改叹话。

[圖片上傳中...(image-5a3fd7-1621421730407-52)]

唯一需要開發(fā)者完成的就是開啟相關(guān)的編譯選項。

[圖片上傳中...(image-85887a-1621421730407-51)]

對于上面的編譯選項辅柴,需要開發(fā)者注意的是:

Apple Clang - Language - ModulesEnable Module 選項是指引用系統(tǒng)庫的的時候歪架,是否采用 Module 的形式烹棉。

Packaging 里的 Defines Module 是指開發(fā)者編寫的組件是否采用 Module 的形式。

說了這么多,我想你應(yīng)該對 #importpch聪黎、 @import 有了一定的概念露泊。當(dāng)然砌左,如果我們深究下去屁擅,可能還會有如下的疑問:

  • 對于未開啟 Clang Module 特性的組件产弹,Clang 是通過怎樣的機(jī)制查找到頭文件的呢派歌?在查找系統(tǒng)頭文件和非系統(tǒng)頭文件的過程中,有什么區(qū)別么痰哨?
  • 對于已開啟 Clang Module 特性的組件,Clang 是如何決定編譯當(dāng)下組件的 Module 呢斤斧?另外構(gòu)建的細(xì)節(jié)又是怎樣的早抠,以及如何查找這些 Module 的?還有查找系統(tǒng)的 Module 和非系統(tǒng)的 Module 有什么區(qū)別么撬讽?

為了解答這些問題蕊连,我們不妨先動手實踐一下悬垃,看看上面的理論知識在現(xiàn)實中的樣子。

原來它是這樣的

在前面的章節(jié)中甘苍,我們將重點放在了原理上的介紹尝蠕,而在這個章節(jié)中,我們將動手看看這些預(yù)編譯環(huán)節(jié)的實際樣子载庭。

#import 的樣子

假設(shè)我們的源碼樣式如下:

#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

想要查看代碼預(yù)編譯后的樣子看彼,我們可以在 Navigate to Related Items 按鈕中找到 Preprocess 選項。

[圖片上傳中...(image-ee832f-1621421730407-50)]

既然知道了如何查看預(yù)編譯后的樣子昧捷,我們不妨看看代碼在使用 #import, PCH 和 @import 后闲昭,到底會變成什么樣子?

這里我們假設(shè)被引入的頭文件靡挥,即 ClassA 中的內(nèi)如下:

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

通過 preprocess 可以看到代碼大致如下序矩,這里為了方便展示,將無用代碼進(jìn)行了刪除跋破。這里記得要將 Build Setting 中 Packaging 的 Define Module 設(shè)置為 NO簸淀,因為其默認(rèn)值為 YES,而這會導(dǎo)致我們開啟 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 默認(rèn)創(chuàng)建的組件拧簸,一般都會關(guān)閉 PCH 的相關(guān)功能劲绪,例如筆者創(chuàng)建的 SQPod 組件,它的 Precompile Prefix Header 功能默認(rèn)值為 NO盆赤。

[圖片上傳中...(image-e92d70-1621421730406-49)]

<figcaption data-darkmode-color-16213284794846="rgb(136, 136, 136)" data-darkmode-original-color-16213284794846="#fff|rgb(62, 62, 62)|rgb(0,0,0)|rgb(0,0,0)|rgb(136, 136, 136)" style="margin: 5px 0px 0px; padding: 0px; max-width: 100%; text-align: justify; color: rgb(136, 136, 136); font-size: 14px; box-sizing: border-box !important; word-wrap: break-word !important;">
</figcaption>

為了查看預(yù)編譯的效果贾富,我們將 Precompile Prefix Header 的值改為 YES,并編譯整個項目牺六,通過查看 Build Log颤枪,我們可以發(fā)現(xiàn)相比于 NO 的狀態(tài),在編譯的過程中淑际,增加了一個步驟畏纲,即 Precompile SQPod-Prefix.pch 的步驟。

[圖片上傳中...(image-6aa1ae-1621421730406-48)]

通過查看這個命令的 -o 參數(shù)春缕,我們可以知道其產(chǎn)物是名為 SQPod-Prefix.pch.gch 的文件盗胀。

[圖片上傳中...(image-101e0e-1621421730406-47)]

這個文件就是 PCH 預(yù)編譯后的產(chǎn)物,同時在編譯真正的代碼時锄贼,會通過 -include 參數(shù)將其引入读整。

[圖片上傳中...(image-9e6b95-1621421730406-46)]

又見 Clang Module

在開啟 Define Module 后,系統(tǒng)會為我們自動創(chuàng)建相應(yīng)的 modulemap 文件,這一點可以在 Build Log 中查找到米间。

[圖片上傳中...(image-a721a3-1621421730406-45)]

它的內(nèi)容如下:

framework module SQPod {  umbrella header "SQPod-umbrella.h"  export *  module * { export * }}

當(dāng)然强品,如果系統(tǒng)自動生成的 modulemap 并不能滿足你的訴求,我們也可以使用自己創(chuàng)建的文件屈糊,此時只需要在 Build Setting 的 Module Map File 選項中填寫好文件路徑的榛,相應(yīng)的 Clang 命令參數(shù)是 -fmodule-map-file

[圖片上傳中...(image-f1c89b-1621421730406-44)]

<figcaption data-darkmode-color-16213284794846="rgb(136, 136, 136)" data-darkmode-original-color-16213284794846="#fff|rgb(62, 62, 62)|rgb(0,0,0)|rgb(0,0,0)|rgb(136, 136, 136)" style="margin: 5px 0px 0px; padding: 0px; max-width: 100%; text-align: justify; color: rgb(136, 136, 136); font-size: 14px; box-sizing: border-box !important; word-wrap: break-word !important;">
</figcaption>

最后讓我們看看 Module 編譯后的產(chǎn)物形態(tài)逻锐。

這里我們構(gòu)建一個名為 SQPod 的 Module 夫晌,將它提供給名為 Example 的工程使用,通過查看 -fmodule-cache-path 的參數(shù)昧诱,我們可以找到 Module 的緩存路徑晓淀。

[圖片上傳中...(image-a67db4-1621421730406-43)]

進(jìn)入對應(yīng)的路徑后,我們可以看到如下的文件:

[圖片上傳中...(image-54ed1b-1621421730406-42)]

其中后綴名為 pcm 的文件就是構(gòu)建出來的二進(jìn)制中間產(chǎn)物盏档。

現(xiàn)在凶掰,我們不僅知道了預(yù)編譯的基礎(chǔ)理論知識,也動手查看了預(yù)編譯環(huán)節(jié)在真實環(huán)境下的產(chǎn)物蜈亩,現(xiàn)在我們要開始解答之前提到的兩個問題了懦窘!

打破砂鍋問到底

關(guān)于第一個問題

對于未開啟 Clang Module 特性的組件,Clang 是通過怎樣的機(jī)制查找到頭文件的呢稚配?在查找系統(tǒng)頭文件和非系統(tǒng)頭文件的過程中畅涂,有什么區(qū)別么?

在早期的 Clang 編譯過程中道川,頭文件的查找機(jī)制還是基于 Header Search Path 的午衰,這也是大多數(shù)人所熟知的工作機(jī)制,所以我們不做贅述冒萄,只做一個簡單的回顧苇经。

Header Search Path 是構(gòu)建系統(tǒng)提供給編譯器的一個重要參數(shù),它的作用是在編譯代碼的時候宦言,為編譯器提供了查找相應(yīng)頭文件路徑的信息,通過查閱 Xcode 的 Build System 信息商模,我們可以知道相關(guān)的設(shè)置有三處 Header Search Path奠旺、System Header Search Path、User Header Search Path施流。

[圖片上傳中...(image-ed8916-1621421730406-41)]

它們的區(qū)別也很簡單响疚,System Header Search Path 是針對系統(tǒng)頭文件的設(shè)置,通常代指 <>方式引入的文件瞪醋,User Header Search Path 則是針對非系統(tǒng)頭文件的設(shè)置忿晕,通常代指 "" 方式引入的文件,而 Header Search Path 并不會有任何限制银受,它普適于任何方式的頭文件引用践盼。

聽起來好像很復(fù)雜鸦采,但關(guān)于引入的方式,無非是以下四種形式:

#import <A/A.h>#import "A/A.h"#import <A.h>#import "A.h"

我們可以兩個維度去理解這個問題咕幻,一個是引入的符號形式渔伯,另一個是引入的內(nèi)容形式。

  • 引入的符號形式通常來說肄程,雙引號的引入方式(“A.h” 或者 "A/A.h")是用于查找本地的頭文件锣吼,需要指定相對路徑,尖括號的引入方式(<A.h> 或者 <A/A.h>)是全局的引用蓝厌,其路徑由編譯器提供玄叠,如引用系統(tǒng)的庫,但隨著 Header Search Path 的加入拓提,讓這種區(qū)別已經(jīng)被淡化了读恃。

  • 引入的內(nèi)容形式:對于 X/X.hX.h 這兩種引入的內(nèi)容形式,前者是說在對應(yīng)的 Search Path 中崎苗,找到目錄 A 并在 A 目錄下查找 A.h狐粱,而后者是說在 Search Path 下查找 A.h 文件,而不一定局限在 A 目錄中胆数,至于是否遞歸的尋找則取決于對目錄的選項是否開啟了 recursive 模式肌蜻。

[圖片上傳中...(image-15e13f-1621421730406-40)]

在很多工程中,尤其是基于 CocoaPods 開發(fā)的項目必尼,我們已經(jīng)不會區(qū)分 System Header Search Path 和 User Header Search Path蒋搜,而是一股腦的將所有頭文件路徑添加到 Header Search Path 中,這就導(dǎo)致我們在引用某個頭文件時判莉,不會再局限于前面提到的約定豆挽,甚至在某些情況下,前面提到的四種方式都可以做到引入某個指定頭文件券盅。

Header Maps

隨著項目的迭代和發(fā)展帮哈,原有的頭文件索引機(jī)制還是受到了一些挑戰(zhàn),為此锰镀,Clang 官方也提出了自己的解決方案娘侍。

為了理解這個東西,我們首先要在 Build Setting 中開啟 Use Header Map 選項泳炉。

[圖片上傳中...(image-ce5ba0-1621421730406-39)]

然后在 Build Log 里獲取相應(yīng)組件里對應(yīng)文件的編譯命令憾筏,并在最后加上 -v 參數(shù),來查看其運(yùn)行的秘密:

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

在 console 的輸出內(nèi)容中花鹅,我們會發(fā)現(xiàn)一段有意思的內(nèi)容:

[圖片上傳中...(image-f2a7ce-1621421730406-38)]

通過上面的圖氧腰,我們可以看到編譯器將尋找頭文件的順序和對應(yīng)路徑展示出來了,而在這些路徑中,我們看到了一些陌生的東西古拴,即后綴名為 .hmap 的文件箩帚。

那 hmap 到底這是個什么東西呢?

當(dāng)我們開啟 Build Setting 中的 Use Header Map 選項后斤富,會自動生成的一份頭文件名和頭文件路徑的映射表膏潮,而這個映射表就是 hmap 文件,不過它是一種二進(jìn)制格式的文件满力,也有人叫它為 Header Map焕参。總之油额,它的核心功能就是讓編譯器能夠找到相應(yīng)頭文件的位置叠纷。

為了更好的理解它,我們可以通過 milend 編寫的小工具 hmap 來查其內(nèi)容潦嘶。

在執(zhí)行相關(guān)命令(即hmap print)后涩嚣,我們可以發(fā)現(xiàn)這些 hmap 里保存的信息結(jié)構(gòu)大致如下:

[圖片上傳中...(image-901849-1621421730406-37)]

需要注意,映射表的鍵值并不是簡單的文件名和絕對路徑掂僵,它的內(nèi)容會隨著使用場景產(chǎn)生不同的變化航厚,例如頭文件引用是在 "..." 的形式,還是 <...> 的形式锰蓬,又或是在 Build Phase 里 Header 的配置情況幔睬。

[圖片上傳中...(image-524380-1621421730406-36)]

至此,我想你應(yīng)該明白了芹扭,一旦開啟 Use Header Map 選項后麻顶,Xcode 會優(yōu)先去 hmap 映射表里尋找頭文件的路徑,只有在找不到的情況下舱卡,才會去 Header Search Path 中提供的路徑遍歷搜索辅肾。

當(dāng)然這種技術(shù)也不是一個什么新鮮事兒,在 Facebook 的 buck 工具中也提供了類似的東西轮锥,只不過文件類型變成了 HeaderMap.java 的樣子矫钓。

查找系統(tǒng)庫的頭文件

上面的過程讓我們理解了在 Header Map 技術(shù)下,編譯器是如何尋找相應(yīng)的頭文件的舍杜,那針對系統(tǒng)庫的文件又是如何索引的呢新娜?例如 #import <Foundation/Foundation.h>

回想一下上一節(jié) console 的輸出內(nèi)容蝴簇,它的形式大概如下:

#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 DerivedSourcesBuild/Products/Debug (framework directory)$(SDKROOT)/usr/include $(SDKROOT)/System/Library/Frameworks(framework directory)

我們會發(fā)現(xiàn),這些路徑大部分是用于查找非系統(tǒng)庫文件的匆帚,也就是開發(fā)者自己引入的頭文件熬词,而與系統(tǒng)庫相關(guān)的路徑只有以下兩個:

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

當(dāng)我們查找 Foundation/Foundation.h 這個文件的時候,我們會首先判斷是否存在 Foundation 這個 Framework。

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

接著互拾,我們會進(jìn)入 Framework 的 Headers 文件夾里尋找對應(yīng)的頭文件歪今。

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

如果沒有找到對應(yīng)的文件,索引過程會在此中斷颜矿,并結(jié)束查找寄猩。

以上便是系統(tǒng)庫的頭文件搜索邏輯。

Framework Search Path

到現(xiàn)在為止骑疆,我們已經(jīng)解釋了如何依賴 Header Search Path田篇、hmap 等技術(shù)尋找頭文件的工作機(jī)制,也介紹了尋找系統(tǒng)庫(System Framework)頭文件的工作機(jī)制箍铭。

那這是全部頭文件的搜索機(jī)制么泊柬?答案是否定的,其實我們還有一種頭文件搜索機(jī)制诈火,它是基于 Framework 這種文件結(jié)構(gòu)進(jìn)行的兽赁。

[圖片上傳中...(image-ec3dc6-1621421730406-35)]

對于開發(fā)者自己的 Framework,可能會存在 "private" 頭文件冷守,例如在 podspec 里用 private_header_files 的描述文件刀崖,這些文件在構(gòu)建的時候,會被放在 Framework 文件結(jié)構(gòu)中的 PrivateHeaders 目錄。

所以針對有 PrivateHeaders 目錄的 Framework 而言,Clang 在檢查 Headers 目錄后伟恶,會去 PrivateHeaders 目錄中尋找是否存在匹配的頭文件撼唾,如果這兩個目錄都沒有,才會結(jié)束查找肠阱。

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

不過也正是因為這個工作機(jī)制,會產(chǎn)生一個特別有意思的問題,那就是當(dāng)我們使用 Framework 的方式引入某個帶有 "Private" 頭文件的組件時巡语,我們總是可以以下面的方式引入這個頭文件!

[圖片上傳中...(image-cde498-1621421730406-34)]

怎么樣淮菠,是不是很神奇男公,這個被描述為 "Private" 的頭文件怎么就不私有了?

究其原因合陵,還是由于 Clang 的工作機(jī)制枢赔,那為什么 Clang 要設(shè)計出來這種看似很奇怪的工作機(jī)制呢?

揭開 Public拥知、Private踏拜、Project 的真實面目

其實你也看到,我在上一段的寫作中低剔,將所有 Private 單詞標(biāo)上了雙引號速梗,其實就是在暗示肮塞,我們曲解了 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 是指可以供外界使用的頭文件拷窜,且分別放在最終產(chǎn)物的 Headers 和 PrivateHeaders 目錄中,而 Project 中的頭文件是不對外使用的弓熏,也不會放在最終的產(chǎn)物中。

如果你繼續(xù)翻閱一些資料疚颊,例如 StackOverflow - Xcode: Copy Headers: Public vs. Private vs. Project?StackOverflow - Understanding Xcode's Copy Headers phase,你會發(fā)現(xiàn)在早期 Xcode Help 的 Project Editor 章節(jié)里信认,有一段名為 Setting the Role of a Header File 的段落嫁赏,里面詳細(xì)記載了三個類型的區(qū)別款熬。

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.

至此,我們應(yīng)該徹底了解了 Public殉簸、Private般卑、Project 的區(qū)別。簡而言之叹谁,Public 還是通常意義上的 Public,Private 則代表 In Progress 的含義锅尘,至于 Project 才是通常意義上的 Private 含義。

那么 CocoaPods 中 Podspec 的 Syntax 里還有 public_header_filesprivate_header_files 兩個字段顿乒,它們的真實含義是否和 Xcode 里的概念沖突呢?

這里我們仔細(xì)閱讀一下官方文檔的解釋骨杂,尤其是 private_header_files 字段。

[圖片上傳中...(image-982a6f-1621421730405-33)]

我們可以看到妒潭,private_header_files 在這里的含義是說,它本身是相對于 Public 而言的佑女,這些頭文件本義是不希望暴露給用戶使用的,而且也不會產(chǎn)生相關(guān)文檔嚎花,但是在構(gòu)建的時候啼止,會出現(xiàn)在最終產(chǎn)物中献烦,只有既沒有被 Public 和 Private 標(biāo)注的頭文件,才會被認(rèn)為是真正的私有頭文件即横,且不出現(xiàn)在最終的產(chǎn)物里。

其實這么看來,CocoaPods 對于 Public 和 Private 的理解是和 Xcode 中的描述一致的惕橙,兩處的 Private 并非我們通常理解的 Private,它的本意更應(yīng)該是開發(fā)者準(zhǔn)備對外開放彬坏,但又沒完全 Ready 的頭文件,更像一個 In Progress 的含義幻赚。

所以,如果你真的不想對外暴露某些頭文件佳谦,請不要再使用 Headers 里的 Private 或者 podspec 里的 private_header_files 了啥刻。

至此,我想你應(yīng)該徹底理解了 Search Path 的搜索機(jī)制和略顯奇怪的 Public蘑拯、Private弯蚜、Project 設(shè)定了路鹰!

基于 hmap 優(yōu)化 Search Path 的策略

在查找系統(tǒng)庫的頭文件的章節(jié)中,我們通過 -v 參數(shù)看到了尋找頭文件的搜索順序:

#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 DerivedSourcesBuild/Products/Debug (framework directory)$(SDKROOT)/usr/include $(SDKROOT)/System/Library/Frameworks(framework directory)

假設(shè)雁竞,我們沒有開啟 hmap 的話,所有的搜索都會依賴 Header Search Path 或者 Framework Search Path,那這就會出現(xiàn) 3 種問題:

  • 第一個問題快毛,在一些巨型項目中,假設(shè)依賴的組件有 400+没隘,那此時的索引路徑就會達(dá)到 800+ 個(一份 Public 路徑阀湿,一份 Private 路徑),同時搜索操作可以看做是一種 IO 操作,而我們知道 IO 操作通常也是一種耗時操作劳澄,那么,這種大量的耗時操作必然會導(dǎo)致編譯耗時增加。
  • 第二個問題庵芭,在打包的過程中,如果 Header Search Path 過多過長伊诵,會觸發(fā)命令行過長的錯誤歉提,進(jìn)而導(dǎo)致命令執(zhí)行失敗的情況苔巨。
  • 第三個問題礁芦,在引入系統(tǒng)庫的頭文件時肖方,Clang 會將前面提到的目錄遍歷完才進(jìn)入搜索系統(tǒng)庫的路徑,也就是 $(SDKROOT)/System/Library/Frameworks(framework directory),即前面的 Header Search 路徑越多猜憎,耗時也會越長,這是相當(dāng)不劃算的魁巩。

那如果我們開啟 hmap 后葬馋,是否就能解決掉所有的問題呢?

實際上并不能,而且在基于 CocoaPods 管理項目的狀況下蒋院,又會帶來新的問題。下面是一個基于 CocoaPods 構(gòu)建的全源碼工程項目,它的整體結(jié)構(gòu)如下:

首先称龙,Host 和 Pod 是我們的兩個 Project间驮,Pods 下的 Target 的產(chǎn)物類型為 Static Library。

其次,Host 底下會有一個同名的 Target,而 Pods 目錄下會有 n+1 個 Target谍肤,其中 n 取決于你依賴的組件數(shù)量,而 1 是一個名為 Pods-XXX 的 Target,最后俩滥,Pods-XXX 這個 Target 的產(chǎn)物會被 Host 里的 Target 所依賴儡率。

整個結(jié)構(gòu)看起來如下所示:

[圖片上傳中...(image-51692f-1621421730405-32)]

此時我們將 PodA 里的文件全部放在 Header 的 Project 類型中棱貌。

[圖片上傳中...(image-28c415-1621421730405-31)]

在基于 Framework 的搜索機(jī)制下,我們是無法以任何方式引入到 ClassB 的错森,因為它既不在 Headers 目錄,也不在 PrivateHeader 目錄中瓦阐。

可是如果我們開啟了 Use Header Map 后枷颊,由于 PodA 和 PodB 都在 Pods 這個 Project 下信卡,滿足了 Header 的 Project 定義,通過 Xcode 自動生成的 hmap 文件會帶上這個路徑须妻,所以我們還可以在 PodB 中以 #import "ClassB.h" 的方式引入敛惊。

而這種行為儡湾,我想應(yīng)該是大多數(shù)人并不想要的結(jié)果癌刽,所以一旦開啟了 Use Header Map衡奥,再結(jié)合 CocoaPods 管理工程項目的模式譬淳,我們極有可能會產(chǎn)生一些誤用私有頭文件的情況辰晕,而這個問題的本質(zhì)是 Xcode 和 CocoaPods 在工程和頭文件上的理念沖突造成的校辩。

除此之外惠赫,CocoaPods 在處理頭文件的問題上還有一些讓人迷惑的地方,它在創(chuàng)建頭文件產(chǎn)物這塊的邏輯大致如下:

  • 在構(gòu)建產(chǎn)物為 Framework 的情況下

  • 根據(jù) podspec 里的 public_header_files 字段的內(nèi)容,將相應(yīng)頭文件設(shè)置為 Public 類型,并放在 Headers 中。

  • 根據(jù) podspec 里的 private_header_files 字段的內(nèi)容半醉,將相應(yīng)文件設(shè)置為 Private 類型,并放在 PrivateHeader 中。

  • 將其余未描述的頭文件設(shè)置為 Project 類型,且不放入最終的產(chǎn)物中。

  • 如果 podspec 里未標(biāo)注 Public 和 Private 的時候潜秋,會將所有文件設(shè)置為 Public 類型胎许,并放在 Header 中峻呛。

  • 在構(gòu)建產(chǎn)物為 Static Library 的情況下

  • 不論 podspec 里如何設(shè)置 public_header_filesprivate_header_files,相應(yīng)的頭文件都會被設(shè)置為 Project 類型辜窑。

  • Pods/Headers/Public 中會保存所有被聲明為 public_header_files 的頭文件。

  • Pods/Headers/Private 中會保存所有頭文件穆碎,不論是 public_header_files或者 private_header_files 描述到牙勘,還是那些未被描述的,這個目錄下是當(dāng)前組件的所有頭文件全集所禀。

  • 如果 podspec 里未標(biāo)注 Public 和 Private 的時候方面,Pods/Headers/PublicPods/Headers/Private 的內(nèi)容一樣且會包含所有頭文件。

正是由于這種機(jī)制色徘,還導(dǎo)致了另外一種有意思的問題恭金。

在 Static Library 的狀況下,一旦我們開啟了 Use Header Map贺氓,結(jié)合組件里所有頭文件的類型為 Project 的情況蔚叨,這個 hmap 里只會包含 #import "A.h" 的鍵值引用床蜘,也就是說只有 #import "A.h" 的方式才會命中 hmap 的策略辙培,否則都將通過 Header Search Path 尋找其相關(guān)路徑。

而我們也知道邢锯,在引用其他組件的時候扬蕊,通常都會采用 #import <A/A.h> 的方式引入。至于為什么會用這種方式丹擎,一方面是這種寫法會明確頭文件的由來尾抑,避免問題歇父,另一方面也是這種方式可以讓我們在是否開啟 Clang Module 中隨意切換,當(dāng)然還有一點就是再愈,Apple 在 WWDC 里曾經(jīng)不止一次建議開發(fā)者使用這種方式來引入頭文件榜苫。

接著上面的話題來說,所以說在 Static Library 的情況下且以 #import <A/A.h> 這種標(biāo)準(zhǔn)方式引入頭文件時翎冲,開啟 Use Header Map 并不會提升編譯速度垂睬,而這同樣是 Xcode 和 CocoaPods 在工程和頭文件上的理念沖突造成的。

[圖片上傳中...(image-83d52a-1621421730405-30)]

這樣來看的話抗悍,雖然 hmap 有種種優(yōu)勢驹饺,但是在 CocoaPods 的世界里顯得格格不入,也無法發(fā)揮自身的優(yōu)勢缴渊。

那這就真的沒有辦法解決了么赏壹?

當(dāng)然,問題是有辦法解決的衔沼,我們完全可以自己動手做一個基于 CocoaPods 規(guī)則下的 hmap 文件蝌借。

舉一個簡單的例子,通過遍歷 PODS 目錄里的內(nèi)容去構(gòu)建索引表內(nèi)容指蚁,借助 hmap 工具生成 Header Map 文件骨望,然后將 Cocoapods 在 Header Search Path 中生成的路徑刪除,只添加一條指向我們自己生成的 hmap 文件路徑欣舵,最后關(guān)閉 Xcode 的 Ues Header Map 功能擎鸠,也就是 Xcode 自動生成 hmap 的功能,如此這般缘圈,我們就實現(xiàn)了一個簡單的劣光,基于 CocoaPods 的 Header Map 功能。

同時在這個基礎(chǔ)上糟把,我們還可以借助這個功能實現(xiàn)不少管控手段绢涡,例如:

  • 從根本上杜絕私有文件被暴露的可能性。
  • 統(tǒng)一頭文件的引用形式遣疯。
  • ...

目前雄可,我們已經(jīng)自研了一套基于上述原理的 cocoapods 插件,它的名字叫做 cocoapods-hmap-prebuilt缠犀,是由筆者與同事共同開發(fā)的数苫。

說了這么多,讓我們看看它在實際工程中的使用效果辨液!

經(jīng)過全源碼編譯的測試虐急,我們可以看到該技術(shù)在提速上的收益較為明顯,以美團(tuán)和點評 App 為例滔迈,全鏈路時長能夠提升 45% 以上止吁,其中 Xcode 打包時間能提升 50%被辑。

關(guān)于第二個問題

對于已開啟 Clang Module 特性的組件,Clang 是如何決定編譯當(dāng)下組件的 Module 呢敬惦?另外構(gòu)建的細(xì)節(jié)又是怎樣的盼理,以及如何查找這些 Module 的?還有查找系統(tǒng)的 Module 和非系統(tǒng)的 Module 有什么區(qū)別么俄删?

首先榜揖,我們來明確一個問題, Clang 是如何決定編譯當(dāng)下組件的 Module 呢抗蠢?

#import <Foundation/NSString.h> 為例举哟,當(dāng)我們遇到這個頭文件的時候:

首先會去 Framework 的 Headers 目錄下尋找相應(yīng)的頭文件是否存在,然后就會到 Modules 目錄下查找 modulemap 文件迅矛。

[圖片上傳中...(image-ef4ed1-1621421730405-29)]

此時妨猩,Clang 會去查閱 modulemap 里的內(nèi)容,看看 NSString 是否為 Foundation 這個 Module 里的一部分秽褒。

// Module Map - Foundation.framework/Modules/module.modulemapframework 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 的一部分并進(jìn)行相應(yīng)的編譯工作,此時也就意味著 #import <Foundation/NSString.h> 會從之前的 textual import 變?yōu)?module import蚂踊。

Module 的構(gòu)建細(xì)節(jié)

上面的內(nèi)容解決了是否構(gòu)建 Module约谈,而這一塊我們會詳細(xì)闡述構(gòu)建 Module 的過程!

在構(gòu)建開始前犁钟,Clang 會創(chuàng)建一個完全獨立的空間來構(gòu)建 Module棱诱,在這個空間里會包含 Module 涉及的所有文件,除此之外不會帶入其他任何文件的信息涝动,而這也是 Module 健壯性好的關(guān)鍵因素之一迈勋。

不過,這并不意味著我們無法影響到 Module 的唯一性醋粟,真正能影響到其唯一性的是其構(gòu)建的參數(shù)靡菇,也就是 Clang 命令后面的內(nèi)容,關(guān)于這一點后面還會繼續(xù)展開米愿,這里我們先點到為止厦凤。

當(dāng)我們在構(gòu)建 Foundation 的時候,我們會發(fā)現(xiàn) Foundation 自身要依賴一些組件吗货,這意味著我們也需要構(gòu)建被依賴組件的 Module泳唠。

[圖片上傳中...(image-8afba-1621421730405-28)]

但很明顯的是狈网,我們會發(fā)現(xiàn)這些被依賴組件也有自己的依賴關(guān)系宙搬,在它們的這些依賴關(guān)系中笨腥,極有可能會存在重復(fù)的引用。

[圖片上傳中...(image-4ff1b5-1621421730405-27)]

此時勇垛,Module 的復(fù)用機(jī)制就體現(xiàn)出來優(yōu)勢了脖母,我們可以復(fù)用先前構(gòu)建出來的 Module,而不必一次次的創(chuàng)建或者引用闲孤,例如 Drawin 組件谆级,而保存這些緩存文件的位置就是前面章節(jié)里提到的保存 pcm 類型文件的地方。

先前我們提到了 Clang 命令的參數(shù)會真正影響到 Module 的唯一性讼积,那具體的原理又是怎樣的肥照?

Clang 會將相應(yīng)的編譯參數(shù)進(jìn)行一次 Hash,將獲得的 Hash 值作為 Module 緩存文件夾的名稱勤众,這里需要注意的是舆绎,不同的參數(shù)和值會導(dǎo)致文件夾不同,所以想要盡可能的利用 Module 緩存们颜,就必須保證參數(shù)不發(fā)生變化吕朵。

$ 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

這里我們大概了解了系統(tǒng)組件的 Module 構(gòu)建機(jī)制,這也是開啟 Enable Modules(C and Objective-C) 的核心工作原理窥突。

神秘的 Virtual File System(VFS)

對于系統(tǒng)組件努溃,我們可以在 /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator14.2.sdk/System/Library/Frameworks 目錄里找到它的身影,它的目錄結(jié)構(gòu)大概是這樣的:

[圖片上傳中...(image-c8e37d-1621421730405-26)]

也就是說阻问,對于系統(tǒng)組件而言梧税,構(gòu)建 Module 的整個過程是建立在這樣一個完備的文件結(jié)構(gòu)上,即在 Framework 的 Modules 目錄中查找 modulemap称近,在 Headers 目錄中加載頭文件贡蓖。

那對于用戶自己創(chuàng)建的組件,Clang 又是如何構(gòu)建 Module 的呢煌茬?

通常我們的開發(fā)目錄大概是下面的樣子斥铺,它并沒有 Modules 目錄,也沒有 headers 目錄坛善,更沒有 modulemap 文件晾蜘,看起來和 Framework 的文件結(jié)構(gòu)也有著極大的區(qū)別。

[圖片上傳中...(image-bca11d-1621421730405-25)]

在這種情況下眠屎,Clang 是沒法按照前面所說的機(jī)制去構(gòu)建 Module 的剔交,因為在這種文件結(jié)構(gòu)中,壓根就沒有 Modules 和 Headers 目錄改衩。

為了解決這個問題岖常,Clang 又提出了一個新的解決方案,叫做 Virtual File System(VFS)葫督。

簡單來說竭鞍,通過這個技術(shù)板惑,Clang 可以在現(xiàn)有的文件結(jié)構(gòu)上虛擬出來一個 Framework 文件結(jié)構(gòu),進(jìn)而讓 Clang 遵守前面提到的構(gòu)建準(zhǔn)則偎快,順利完成 Module 的編譯冯乘,同時 VFS 也會記錄文件的真實位置,以便在出現(xiàn)問題的時候晒夹,將文件的真實信息暴露給用戶裆馒。

為了進(jìn)一步了解 VFS,我們還是從 Build Log 中查找一些細(xì)節(jié)丐怯!

[圖片上傳中...(image-cf9d2-1621421730405-24)]

在上面的編譯參數(shù)里喷好,我們可以找到一個 -ivfsoverlay 的參數(shù),查看 Help 說明读跷,可以知道其作用就是向編譯器傳遞一個 VFS 描述文件并覆蓋掉真實的文件結(jié)構(gòu)信息绒窑。

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

順著這個線索,我們?nèi)タ纯催@個參數(shù)指向的文件舔亭,它是一個 yaml 格式的文件些膨,在將內(nèi)容進(jìn)行了一些裁剪后,它的核心內(nèi)容如下:

{  "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"        }      ]    }  ]}

結(jié)合前面提到的內(nèi)容钦铺,我們不難看出它在描述這樣一個文件結(jié)構(gòu):

借用一個真實存在的文件夾來模擬 Framework 里的 Headers 文件夾订雾,在這個 Headers 文件夾里有名為 PodA-umbrella.hClassA.h 等的文件,不過這幾個虛擬文件與 external-contents 指向的真實文件相關(guān)聯(lián)矛洞,同理還有 Modules 文件夾和它里面的 module.modulemap 文件洼哎。

通過這樣的形式,一個虛擬的 Framework 目錄結(jié)構(gòu)誕生了沼本!此時 Clang 終于能按照前面的構(gòu)建機(jī)制為用戶創(chuàng)建 Module 了噩峦!

Swift 來了

沒有頭文件的 Swift

前面的章節(jié),我們聊了很多 C 語言系的預(yù)編譯知識抽兆,在這個體系下识补,文件的編譯是分開的,當(dāng)我們想引用其他文件里的內(nèi)容時辫红,就必須引入相應(yīng)的頭文件凭涂。

[圖片上傳中...(image-2188a1-1621421730405-23)]

而對于 Swift 這門語言來說,它并沒有頭文件的概念贴妻,對于開發(fā)者而言切油,這確實省去了寫頭文件的重復(fù)工作,但這也意味著名惩,編譯器會進(jìn)行額外的操作來查找接口定義并需要持續(xù)關(guān)注接口的變化澎胡!

為了更好的解釋 Swift 和 Objective-C 是如何尋找到彼此的方法聲明的,我們這里引入一個例子,在這個例子由三個部分組成:

  • 第一部分是一個 ViewController 的代碼攻谁,它里面包含了一個 View稚伍,其中 PetViewController 和 PetView 都是 Swift 代碼。
  • 第二部分是一個 App 的代理巢株,它是 Objective-C 代碼槐瑞。
  • 第三個部分是一段單測代碼熙涤,用來測試第一個部分中的 ViewController阁苞,它是 Swift 代碼。
import UIKitclass PetViewController: UIViewController {  var view = PetView(name: "Fido", frame: frame)  …}
#import "PetWall-Swift.h"@implementation AppDelegate…@end
@testable import PetWallclass TestPetViewController: XCTestCase {}

它們的關(guān)系大致如下所示:

[圖片上傳中...(image-d61c1f-1621421730405-22)]

為了能讓這些代碼編譯成功祠挫,編譯器會面對如下 4 個場景:

首先是尋找聲明那槽,這包括尋找當(dāng)前 Target 內(nèi)的方法聲明(PetView),也包括來自 Objective-C 組件里的聲明(UIViewController 或者 PetKit)等舔。

然后是生成接口骚灸,這包括被 Objective-C 使用的接口,也包括被其他 Target (Unit Test)使用的 Swift 接口慌植。

第一步 - 如何尋找 Target 內(nèi)部的 Swift 方法聲明

在編譯 PetViewController.swift 時甚牲,編譯器需要知道 PetView 的初始化構(gòu)造器的類型,才能檢查調(diào)用是否正確蝶柿。

此時丈钙,編譯器會加載 PetView.swift 文件并解析其中的內(nèi)容, 這么做的目的就是確保初始化構(gòu)造器真的存在,并拿到相關(guān)的類型信息交汤,以便 PetViewController.swift 進(jìn)行驗證雏赦。

[圖片上傳中...(image-b23df6-1621421730405-21)]

編譯器并不會對初始化構(gòu)造器的內(nèi)部做檢查,但它仍然會進(jìn)行一些額外的操作芙扎,這是什么意思呢星岗?

與 Clang 編譯器不同的是,Swiftc 編譯的時候戒洼,會將相同 Target 里的其他 Swift 文件進(jìn)行一次解析俏橘,用來檢查其中與被編譯文件關(guān)聯(lián)的接口部分是否符合預(yù)期。

同時我們也知道圈浇,每個文件的編譯是獨立的敷矫,且不同文件的編譯是可以并行開展的,所以這就意味著每編譯一個文件汉额,就需要將當(dāng)前 Target 里的其余文件當(dāng)做接口曹仗,重新編譯一次。等于任意一個文件蠕搜,在整個編譯過程中怎茫,只有 1 次被作為生產(chǎn) .o 產(chǎn)物的輸入,其余時間會被作為接口文件反復(fù)解析。

[圖片上傳中...(image-806590-1621421730405-20)]

不過在 Xcode 10 以后轨蛤,Apple 對這種編譯流程進(jìn)行了優(yōu)化蜜宪。

在盡可能保證并行的同時,將文件進(jìn)行了分組編譯祥山,這樣就避免了 Group 內(nèi)的文件重復(fù)解析圃验,只有不同 Group 之間的文件會有重復(fù)解析文件的情況。

[圖片上傳中...(image-bbe0b6-1621421730405-19)]

而這個分組操作的邏輯缝呕,就是剛才提到的一些額外操作澳窑。

至此,我們應(yīng)該了解了 Target 內(nèi)部是如何尋找 Swift 方法聲明的了供常。

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

回到第一段代碼中摊聋,我們可以看到 PetViewController 是繼承自 UIViewController,而這也意味著我們的代碼會與 Objective-C 代碼進(jìn)行交互栈暇,因為大部分系統(tǒng)庫麻裁,例如 UIKit 等,還是使用 Objective-C 編寫的源祈。

在這個問題上煎源,Swift 采用了和其他語言不一樣的方案!

通常來說香缺,兩種不同的語言在混編時需要提供一個接口映射表手销,例如 JavaScript 和 TypeScript 混編時候的 .d.ts 文件,這樣 TypeScript 就能夠知道 JavaScript 方法在 TS 世界中的樣子赫悄。

然而箭启,Swift 不需要提供這樣的接口映射表, 免去了開發(fā)者為每個 Objective-C API 聲明其在 Swift 世界里樣子梅尤,那它是怎么做到的呢?

很簡單哥蔚,Swift 編譯器將 Clang 的大部分功能包含在其自身的代碼中伞梯,這就使得我們能夠以 Module 的形式,直接引用 Objective-C 的代碼倔撞。

[圖片上傳中...(image-31fb34-1621421730404-18)]

既然是通過 Module 的形式引入 Objective-C讲仰,那么 Framework 的文件結(jié)構(gòu)則是最好的選擇,此時編譯器尋找方法聲明的方式就會有下面三種場景:

  • 對于大部分的 Target 而言痪蝇,當(dāng)導(dǎo)入的是一個 Objective-C 類型的 Framework 時鄙陡,編譯器會通過 modulemap 里的 Header 信息尋找方法聲明。

  • 對于一個既有 Objective-C躏啰,又有 Swift 代碼的 Framework 而言趁矾,編譯器會從當(dāng)前 Framework 的 Umbrella Header 中尋找方法聲明,從而解決自身的編譯問題给僵,這是因為通常情況下 modulemap 會將 Umbrella Header 作為自身的 Header 值毫捣。

  • 對于 App 或者 Unit Test 類型的 Target详拙,開發(fā)者可以通過為 Target 創(chuàng)建 Briding Header 來導(dǎo)入需要的 Objective-C 頭文件,進(jìn)而找到需要的方法聲明蔓同。

不過我們應(yīng)該知道 Swift 編譯器在獲取 Objective-C 代碼過程中饶辙,并不是原原本本的將 Objective-C 的 API 暴露給 Swift,而是會做一些 ”Swift 化” 的改動斑粱,例如下面的 Objective-C API 就會被轉(zhuǎn)換成更簡約的形式弃揽。

[圖片上傳中...(image-f37d14-1621421730404-17)]

這個轉(zhuǎn)換過程并不是什么高深的技術(shù),它只是在編譯器上的硬編碼则北,如果感興趣矿微,可以在 Swift 的開源庫中的找到相應(yīng)的代碼 - PartsOfSpeech.def

當(dāng)然咒锻,編譯器也給與了開發(fā)者自行定義 “API 外貌” 的權(quán)利冷冗,如果你對這一塊感興趣守屉,不妨閱讀筆者的另一篇文章 - WWDC20 10680 - Refine Objective-C frameworks for Swift惑艇,那里面包含了很多重塑 Objective-C API 的技巧。

不過這里還是要提一句拇泛,如果你對生成的接口有困惑滨巴,可以通過下面的方式查看編譯器為 Objective-C 生成的 Swift 接口。

[圖片上傳中...(image-a834-1621421730404-16)]

第三步 - Target 內(nèi)的 Swift 代碼是如何為 Objective-C 提供接口的

前面講了 Swift 代碼是如何引用 Objective-C 的 API俺叭,那么 Objective-C 又是如何引用 Swift 的 API 呢恭取?

從使用層面來說,我們都知道 Swift 編譯器會幫我們自動生成一個頭文件熄守,以便 Objective-C 引入相應(yīng)的代碼蜈垮,就像第二段代碼里引入的 PetWall-Swift.h 文件,這種頭文件通常是編譯器自動生成的裕照,名字的構(gòu)成是 組件名-Swift 的形式攒发。

[圖片上傳中...(image-8406c1-1621421730404-15)]

但它到底是怎么產(chǎn)生的呢?

在 Swift 中晋南,如果某個類繼承了 NSObject 類且 API 被 @objc 關(guān)鍵字標(biāo)注惠猿,就意味著它將暴露給 Objective-C 代碼使用。

不過對于 App 和 Unit Test 類型的 Target 而言负间,這個自動生成的 Header 會包含訪問級別為 Public 和 internal 的 API偶妖,這使得同一 Target 內(nèi)的 Objective-C 代碼也能訪問 Swift 里 internal 類型的 API,這也是所有 Swift 代碼的默認(rèn)訪問級別政溃。

但對于 Framework 類型的 Target 而言趾访,Swift 自動生成的頭文件只會包含 Public 類型的 API,因為這個頭文件會被作為構(gòu)建產(chǎn)物對外使用董虱,所以像 internal 類型的 API 是不會包含在這個文件中扼鞋。

注意,這種機(jī)制會導(dǎo)致在 Framework 類型的 Target 中,如果 Swift 想暴露一些 API 給內(nèi)部的 Objective-C 代碼使用藏鹊,就意味著這些 API 也必須暴露給外界使用润讥,即必須將其訪問級別設(shè)置為 Public。

那么編譯器自動生成的 API 到底是什么樣子盘寡,有什么特點呢楚殿?

[圖片上傳中...(image-81f6-1621421730404-14)]

上面是截取了一段自動生成的頭文件代碼,左側(cè)是原始的 Swift 代碼竿痰,右側(cè)是自動生成的 Objective-C 代碼脆粥,我們可以看到在 Objective-C 的類中,有一個名為 SWIFT_CLASS 的宏影涉,將 Swift 與 Objective-C 中的兩個類進(jìn)行了關(guān)聯(lián)变隔。

如果你稍加注意,就會發(fā)現(xiàn)關(guān)聯(lián)的一段亂碼中還綁定了當(dāng)前的組件名(PetWall)蟹倾,這樣做的目的是避免兩個組件的同名類在運(yùn)行時發(fā)生沖突匣缘。

當(dāng)然,你也可以通過向 @objc(Name) 關(guān)鍵字傳遞一個標(biāo)識符鲜棠,借由這個標(biāo)識符來控制其在 Objective-C 中的名稱肌厨,如果這樣做的話,需要開發(fā)者確保轉(zhuǎn)換后的類名不與其他類名出現(xiàn)沖突豁陆。

[圖片上傳中...(image-df7a73-1621421730404-13)]

這大體上就是 Swift 如何像 Objective-C 暴露接口的機(jī)理了柑爸,如果你想更深入的了解這個文件的由來,就需要看看第四步盒音。

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

Swift 采用了 Clang Module 的理念表鳍,并結(jié)合自身的語言特性進(jìn)行了一系列的改進(jìn)。

在 Swift 中祥诽,Module 是方法聲明的分發(fā)單位譬圣,如果你想引用相應(yīng)的方法,就必須引入對應(yīng)的 Module原押,之前我們也提到了 Swift 的編譯器包含了 Clang 的大部分內(nèi)容胁镐,所以它也是兼容 Clang Module 的。

所以我們可以引入 Objective-C 的 Module诸衔,例如 XCTest盯漂,也可以引入 Swift Target 生成的 Module,例如 PetWall笨农。

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

在引入 Swift 的 Module 后就缆,編譯器會反序列化一個后綴名為 .swiftmodule 的文件,并通過這種文件里的內(nèi)容來了解相關(guān)接口的信息谒亦。

例如竭宰,以下圖為例空郊,在這個單元測試中,編譯器會加載 PetWall 的 Module切揭,并在其中找尋 PetViewController 的方法聲明狞甚,由此確保其創(chuàng)建行為是符合預(yù)期的。

[圖片上傳中...(image-a99a26-1621421730404-12)]

這看起來很像第一步中 Target 尋找內(nèi)部 Swift 方法聲明的樣子廓旬,只不過這里將解析 Swift 文件的步驟哼审,換成了解析 Swiftmodule 文件而已。

不過需要注意的是孕豹,這個 Swfitmodule 文件并不是文本文件涩盾,它是一個二進(jìn)制格式的內(nèi)容,通常我們可以在構(gòu)建產(chǎn)物的 Modules 文件夾里尋找到它的身影励背。

[圖片上傳中...(image-d1f172-1621421730404-11)]

在 Target 的編譯的過程中春霍,面向整個 Target 的 Swiftmodule 文件并不是一下產(chǎn)生的,每一個 Swift 文件都會生成一個 Swiftmodule 文件叶眉,編譯器會將這些文件進(jìn)行匯總址儒,最后再生成一個完整的,代表整個 Target 的 Swiftmodule竟闪,也正是基于這個文件离福,編譯器構(gòu)造出了用于給外部使用的 Objective-C 頭文件杖狼,也就是第三步里提到的頭文件炼蛤。

[圖片上傳中...(image-391cd7-1621421730404-10)]

不過隨著 Swift 的發(fā)展,這一部分的工作機(jī)制也發(fā)生了些許變化蝶涩。

我們前面提到的 Swiftmodule 文件是一種二進(jìn)制格式的文件理朋,而這個文件格式會包含一些編譯器內(nèi)部的數(shù)據(jù)結(jié)構(gòu),不同編譯器產(chǎn)生的 Swiftmodule 文件是互相不兼容的绿聘,這也就導(dǎo)致了不同 Xcode 構(gòu)建出的產(chǎn)物是無法通用的嗽上,如果對這方面的細(xì)節(jié)感興趣,可以閱讀 Swift 社區(qū)里的兩篇官方 Blog:Evolving Swift On Apple Platforms After ABI StabilityABI Stability and More熄攘,這里就不展開討論了兽愤。

為了解決這一問題,Apple 在 Xcode 11 的 Build Setting 中提供了一個新的編譯參數(shù) Build Libraries for Distribution挪圾,正如這個編譯參數(shù)的名稱一樣浅萧,當(dāng)我們開啟它后,構(gòu)建出來的產(chǎn)物不會再受編譯器版本的影響哲思,那它是怎么做到這一點的呢洼畅?

為了解決這種對編譯器的版本依賴,Xcode 在構(gòu)建產(chǎn)物上提供了一個新的產(chǎn)物棚赔,Swiftinterface 文件帝簇。

[圖片上傳中...(image-a808f7-1621421730404-9)]

<figcaption data-darkmode-color-16213284794846="rgb(136, 136, 136)" data-darkmode-original-color-16213284794846="#fff|rgb(62, 62, 62)|rgb(0,0,0)|rgb(0,0,0)|rgb(136, 136, 136)" style="margin: 5px 0px 0px; padding: 0px; max-width: 100%; text-align: justify; color: rgb(136, 136, 136); font-size: 14px; box-sizing: border-box !important; word-wrap: break-word !important;">這個文件里的內(nèi)容和 Swiftmodule 很相似徘郭,都是當(dāng)前 Module 里的 API 信息,不過 Swiftinterface 是以文本的方式記錄丧肴,而非 Swiftmodule 的二進(jìn)制方式残揉。
</figcaption>

這就使得 Swiftinterface 的行為和源代碼一樣,后續(xù)版本的 Swift 編譯器也能導(dǎo)入之前編譯器創(chuàng)建的 Swiftinterface 文件芋浮,像使用源碼的方式一樣使用它冲甘。

為了更進(jìn)一步了解它,我們來看看 Swiftinterface 的真實樣子途样,下面是一個 .swift 文件和 .swiftinterface 文件的比對圖江醇。

[圖片上傳中...(image-90278e-1621421730404-8)]

在 Swiftinterface 文件中,有以下點需要注意:

  • 文件會包含一些元信息何暇,例如文件格式版本陶夜,編譯器信息,和 Swift 編譯器將其作為模塊導(dǎo)入所需的命令行子集裆站。
  • 文件只會包含 Public 的接口条辟,而不會包含 Private 的接口,例如 currentLocation宏胯。
  • 文件只會包含方法聲明羽嫡,而不會包含方法實現(xiàn),例如 Spacesship 的 init肩袍、fly 等方法杭棵。
  • 文件會包含所有隱式聲明的方法,例如 Spacesship 的 deinit 方法 氛赐,Speed 的 Hashable 協(xié)議魂爪。

總的來說,Swiftinterface 文件會在編譯器的各個版本中保持穩(wěn)定艰管,主要原因就是這個接口文件會包含接口層面的一切信息滓侍,不需要編譯器再做任何的推斷或者假設(shè)。

好了牲芋,至此我們應(yīng)該了解了 Swift Target 是如何生成供外部 Swift 使用的接口了撩笆。

這四步意味著什么?

此 Module 非彼 Module

通過上面的例子缸浦,我想大家應(yīng)該能清楚的感受到 Swift Module 和 Clang Module 不完全是一個東西夕冲,雖然它們有很多相似的地方。

Clang Module 是面向 C 語言家族的一種技術(shù)餐济,通過 modulemap 文件來組織 .h 文件中的接口信息耘擂,中間產(chǎn)物是二進(jìn)制格式的 pcm 文件。

Swift Module 是面向 Swift 語言的一種技術(shù)絮姆,通過 Swiftinterface 文件來組織 .swift 文件中的接口信息醉冤,中間產(chǎn)物二進(jìn)制格式的 Swiftmodule 文件秩霍。

[圖片上傳中...(image-152a85-1621421730404-7)]

所以說理清楚這些概念和關(guān)系后,我們在構(gòu)建 Swift 組件的產(chǎn)物時蚁阳,就會知道哪些文件和參數(shù)不是必須的了铃绒。

例如當(dāng)你的 Swift 組件不想暴露自身的 API 給外部的 Objective-C 代碼使用的話,可以將 Build Setting 中 Swift Compiler - General 里的 Install Objective-C Compatiblity Header 參數(shù)設(shè)置為 NO螺捐,其編譯參數(shù)為 SWIFT_INSTALL_OBJC_HEADER颠悬,此時不會生成 <ProductModuleName>-Swift.h 類型的文件,也就意味著外部組件無法以 Objective-C 的方式引用組件內(nèi) Swift 代碼的 API定血。

[圖片上傳中...(image-b8c247-1621421730404-6)]

而當(dāng)你的組件里如果壓根就沒有 Objective-C 代碼的時候赔癌,你可以將 Build Setting 中 Packaging 里 Defines Module 參數(shù)設(shè)置為 NO,它的編譯參數(shù)為 DEFINES_MODULE, 此時不會生成 <ProductModuleName>.modulemap 類型的文件澜沟。

[圖片上傳中...(image-698037-1621421730404-5)]

Swift 和 Objective-C 混編的三個“套路”

基于剛才的例子灾票,我們應(yīng)該理解了 Swift 在編譯時是如何找到其他 API 的,以及它又是如何暴露自身 API 的茫虽,而這些知識就是解決混編過程中的基礎(chǔ)知識刊苍,為了加深影響,我們可以將其繪制成 3 個流程圖濒析。

當(dāng) Swift 和 Objective-C 文件同時在一個 App 或者 Unit Test 類型的 Target 中正什,不同類型文件的 API 尋找機(jī)制如下:

[圖片上傳中...(image-499c95-1621421730404-4)]

當(dāng) Swift 和 Objective-C 文件在不同 Target 中,例如不同 Framework 中号杏,不同類型文件的 API 尋找機(jī)制如下:

[圖片上傳中...(image-3be9f9-1621421730404-3)]

當(dāng) Swift 和 Objective-C 文件同時在一個 Target 中婴氮,例如同一 Framework 中,不同類型文件的 API 尋找機(jī)制如下:

[圖片上傳中...(image-d2c0d5-1621421730404-2)]

對于第三個流程圖馒索,需要做以下補(bǔ)充說明:

  • 由于 Swiftc莹妒,也就是 Swift 的編譯器,包含了大部分的 Clang 功能绰上,其中就包含了 Clang Module,借由組件內(nèi)已有的 modulemap 文件渠驼,Swift 編譯器就可以輕松找到相應(yīng)的 Objective-C 代碼蜈块。
  • 相比于第二個流程而言,第三個流程中的 modulemap 是組件內(nèi)部的迷扇,而第二個流程中百揭,如果想引用其他組件里的 Objective-C 代碼,需要引入其他組件里的 modulemap 文件才可以蜓席。
  • 所以基于這個考慮器一,并未在流程 3 中標(biāo)注 modulemap。

構(gòu)建 Swift 產(chǎn)物的新思路

在前面的章節(jié)里厨内,我們提到了 Swift 找尋 Objective-C 的方式渺贤,其中提到了,除了 App 或者 Unit Test 類型的 Target 外,其余的情況下都是通過 Framework 的 Module Map 來尋找 Objective-C 的 API,那么如果我們不想使用 Framework 的形式呢?

目前來看,這個在 Xcode 中是無法直接實現(xiàn)的,原因很簡單送爸,Build Setting 中 Search Path 選項里并沒有 modulemap 的 Search Path 配置參數(shù)球匕。

[圖片上傳中...(image-f199ba-1621421730404-1)]

為什么一定需要 modulemap 的 Search Path 呢?

基于前面了解到的內(nèi)容,Swiftc 包含了 Clang 的大部分邏輯,在預(yù)編譯方面,Swiftc 只包含了 Clang Module 的模式,而沒有其他模式,所以 Objective-C 想要暴露自己的 API 就必須通過 modulemap 來完成。

而對于 Framework 這種標(biāo)準(zhǔn)的文件夾結(jié)構(gòu),modulemap 文件的相對路徑是固定的,它就在 Modules 目錄中,所以 Xcode 基于這種標(biāo)準(zhǔn)結(jié)構(gòu),直接內(nèi)置了相關(guān)的邏輯萎馅,而不需要將這些配置再暴露出來。

從組件的開發(fā)者角度來看徐绑,他只需要關(guān)心 modulemap 的內(nèi)容是否符合預(yù)期沮榜,以及路徑是否符合規(guī)范型酥。

從組件的使用者角度來看由境,他只需要正確的引入相應(yīng)的 Framework 就可以使用到相應(yīng)的 API勒虾。

這種只需要配置 Framework 的方式低零,避免了配置 Header Search Path雄妥,也避免了配置 Static Library Path枝秤,可以說是一種很友好的方式庆械,如果再將 modulemap 的配置開放出來,反而顯得多此一舉。

那如果我們拋開 Xcode铣口,拋開 Framework 的限制他炊,還有別的辦法構(gòu)建 Swift 產(chǎn)物么凿叠?

答案是肯定有的,這就需要借助前面所說的 VFS 技術(shù)片吊!

假設(shè)我們的文件結(jié)構(gòu)如下所示:

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

其中 LaunchPoint.swift 引用了 UsefulClass.h 中的一個公開 API,并產(chǎn)生了依賴關(guān)系毕骡。

另外密末,vfs-overlay.yaml 文件重新映射了現(xiàn)有的文件目錄結(jié)構(gòu)教硫,其內(nèi)容如下:

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

那這意味著什么呢涵叮?

這就意味著惭蹂,只提供相應(yīng)的 .h 文件和 .modulemap 文件就可以完成 Swift 二進(jìn)制產(chǎn)物的構(gòu)建,而不再依賴 Framework 的實體割粮。同時盾碗,對于 CI 系統(tǒng)來說,在構(gòu)建產(chǎn)物時舀瓢,可以避免下載無用的二進(jìn)制產(chǎn)物(.a文件)廷雅,這從某種程度上會提升編譯效率。

如果你沒太理解上面的意思京髓,我們可以展開說說航缀。

例如,對于 PodA 組件而言堰怨,它自身依賴 PodB 組件芥玉,在使用原先的構(gòu)建方式時,我們需要拉取 PodB 組件的完整 Framework 產(chǎn)物备图,這會包含 Headers 目錄灿巧,Modules 目錄里的必要內(nèi)容,當(dāng)然還會包含一個二進(jìn)制文件(PodB)诬烹,但在實際編譯 PodA 組件的過程中砸烦,我們并不需要 B 組件里的二進(jìn)制文件,而這讓拉取完整的 Framework 文件顯得多余了绞吁。

[圖片上傳中...(image-cfcf8a-1621421730404-0)]

而借助 VFS 技術(shù)幢痘,我們就能避免拉取多余的二進(jìn)制文件,進(jìn)一步提升 CI 系統(tǒng)的編譯效率家破。

總結(jié)

感謝你的耐心閱讀颜说。至此,整篇文章終于結(jié)束了汰聋,通過這篇文章门粪,我想你應(yīng)該:

  • 理解 Objective-C 的三種預(yù)編譯的工作機(jī)制,其中 Clang Module 做到了真正意義上的語義引入烹困,提升了編譯的健壯性和擴(kuò)展性玄妈。

  • 在 Xcode 的 Search Path 的各種技術(shù)細(xì)節(jié)使用到了 hmap 技術(shù),通過加載映射表的方式避免了大量重復(fù)的 IO 操作髓梅,可以提升編譯效率拟蜻。

  • 在處理 Framework 的頭文件索引時,總是會先搜索 Headers 目錄枯饿,再搜索 PrivateHeader 目錄酝锅。

  • 理解 Xcode Phases 構(gòu)建系統(tǒng)中,Public 代表公開頭文件奢方,Private 代表不需要使用者感知搔扁,但物理存在的文件爸舒, 而 Project 代表不應(yīng)讓使用者感知,且物理不存在的文件稿蹲。

  • 不使用 Framework 的情況下且以 #import <A/A.h> 這種標(biāo)準(zhǔn)方式引入頭文件時扭勉,在 CocoaPods 上使用 hmap 并不會提升編譯速度。

  • 通過 cocoapods-hmap-built 插件场绿,可以將大型項目的全鏈路時長節(jié)省 45% 以上剖效,Xcode 打包環(huán)節(jié)的時長節(jié)省 50% 以上。

  • Clang Module 的構(gòu)建機(jī)制確保了其不受上下文影響(獨立編譯空間)焰盗,復(fù)用效率高(依賴決議),唯一性(參數(shù)哈现淞郑化)熬拒。

  • 系統(tǒng)組件通過已有的 Framework 文件結(jié)構(gòu)實現(xiàn)了構(gòu)建 Module 的基本條件 ,而非系統(tǒng)組件通過 VFS 虛擬出相似的 Framework 文件 結(jié)構(gòu)垫竞,進(jìn)而具備了編譯的條件澎粟。

  • 可以粗淺的將 Clang Module 里的 .h/m.moduelmap欢瞪、.pch 的概念對應(yīng)為 Swift Module 里的 .swift活烙、.swiftinterface.swiftmodule 的概念遣鼓。

  • 理解三種具有普適性的 Swift 與 Objective-C 混編方法啸盏。

  • 同一 Target 內(nèi)(App 或者 Unit 類型),基于 <PorductModuleName>-Swift.h<PorductModuleName>-Bridging-Swift.h

  • 同一 Target 內(nèi)骑祟,基于 <PorductModuleName>-Swift.h 和 Clang 自身的能力

  • 不同 Target 內(nèi)回懦,基于 <PorductModuleName>-Swift.hmodule.modulemap

  • 利用 VFS 機(jī)制構(gòu)建,可以在構(gòu)建 Swift 產(chǎn)物的過程中避免下載無用的二進(jìn)制產(chǎn)物次企,進(jìn)一步提升編譯的效率怯晕。

參考文檔

作者簡介

思琦,筆名 SketchK缸棵,美團(tuán)點評 iOS 工程師舟茶,目前負(fù)責(zé)移動端 CI/CD 方面的工作及平臺內(nèi) Swift 技術(shù)相關(guān)的事宜。

旭陶堵第,美團(tuán) iOS 工程師吧凉,目前負(fù)責(zé) iOS 端開發(fā)提效相關(guān)事宜。

霜葉型诚,2015 年加入美團(tuán)客燕,先后從事過 Hybrid 容器、iOS 基礎(chǔ)組件狰贯、iOS 開發(fā)工具鏈和客戶端持續(xù)集成門戶系統(tǒng)等工作也搓。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末赏廓,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子傍妒,更是在濱河造成了極大的恐慌幔摸,老刑警劉巖,帶你破解...
    沈念sama閱讀 221,198評論 6 514
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件颤练,死亡現(xiàn)場離奇詭異既忆,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)嗦玖,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,334評論 3 398
  • 文/潘曉璐 我一進(jìn)店門患雇,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人宇挫,你說我怎么就攤上這事苛吱。” “怎么了器瘪?”我有些...
    開封第一講書人閱讀 167,643評論 0 360
  • 文/不壞的土叔 我叫張陵翠储,是天一觀的道長。 經(jīng)常有香客問我橡疼,道長援所,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 59,495評論 1 296
  • 正文 為了忘掉前任欣除,我火速辦了婚禮住拭,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘耻涛。我一直安慰自己废酷,他們只是感情好,可當(dāng)我...
    茶點故事閱讀 68,502評論 6 397
  • 文/花漫 我一把揭開白布抹缕。 她就那樣靜靜地躺著澈蟆,像睡著了一般。 火紅的嫁衣襯著肌膚如雪卓研。 梳的紋絲不亂的頭發(fā)上趴俘,一...
    開封第一講書人閱讀 52,156評論 1 308
  • 那天,我揣著相機(jī)與錄音奏赘,去河邊找鬼寥闪。 笑死,一個胖子當(dāng)著我的面吹牛磨淌,可吹牛的內(nèi)容都是我干的疲憋。 我是一名探鬼主播,決...
    沈念sama閱讀 40,743評論 3 421
  • 文/蒼蘭香墨 我猛地睜開眼梁只,長吁一口氣:“原來是場噩夢啊……” “哼缚柳!你這毒婦竟也來了埃脏?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 39,659評論 0 276
  • 序言:老撾萬榮一對情侶失蹤秋忙,失蹤者是張志新(化名)和其女友劉穎彩掐,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體灰追,經(jīng)...
    沈念sama閱讀 46,200評論 1 319
  • 正文 獨居荒郊野嶺守林人離奇死亡堵幽,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 38,282評論 3 340
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了弹澎。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片朴下。...
    茶點故事閱讀 40,424評論 1 352
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖裁奇,靈堂內(nèi)的尸體忽然破棺而出桐猬,到底是詐尸還是另有隱情,我是刑警寧澤刽肠,帶...
    沈念sama閱讀 36,107評論 5 349
  • 正文 年R本政府宣布,位于F島的核電站免胃,受9級特大地震影響音五,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜羔沙,卻給世界環(huán)境...
    茶點故事閱讀 41,789評論 3 333
  • 文/蒙蒙 一躺涝、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧扼雏,春花似錦坚嗜、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,264評論 0 23
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至蝴蜓,卻和暖如春碟绑,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背茎匠。 一陣腳步聲響...
    開封第一講書人閱讀 33,390評論 1 271
  • 我被黑心中介騙來泰國打工格仲, 沒想到剛下飛機(jī)就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人诵冒。 一個月前我還...
    沈念sama閱讀 48,798評論 3 376
  • 正文 我出身青樓凯肋,卻偏偏與公主長得像,于是被迫代替她去往敵國和親汽馋。 傳聞我的和親對象是個殘疾皇子侮东,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 45,435評論 2 359

推薦閱讀更多精彩內(nèi)容