從預編譯的角度理解 Swift 與 Objective-C 及混編機制

寫在前面

本文涉及面較廣,篇幅較長,閱讀完需要耗費一定的時間與精力,如果你帶有較爲明確的閱讀目的,可以參考以下建議完成閱讀:

  • 如果你對預編譯的理論知識已經瞭解,可以直接從【原來它是這樣的】的章節開始進行閱讀,這會讓你對預編譯有一個更直觀的瞭解。
  • 如果你對 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 StabilityABI 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 產物的過程中避免下載無用的二進制產物,進一步提升編譯效率。

參考文檔

作者簡介

  • 思琦,筆名 SketchK,美團點評 iOS 工程師,目前負責移動端 CI/CD 方面的工作及平臺內 Swift 技術相關的事宜。
  • 旭陶,美團 iOS 工程師,目前負責 iOS 端開發提效相關事宜。
  • 霜葉,2015 年加入美團,先後從事過 Hybrid 容器、iOS 基礎組件、iOS 開發工具鏈和客戶端持續集成門戶系統等工作。

| 想閱讀更多技術文章,請關注美團技術團隊(meituantech)官方微信公衆號。

| 在公衆號菜單欄回覆【2020年貨】、【2019年貨】、【2018年貨】、【2017年貨】、【算法】等關鍵詞,可查看美團技術團隊歷年技術文章合集。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章