一款可以讓大型iOS工程編譯速度提升50%的工具

cocoapods-hmap-prebuilt 是什麼?

cocoapods-hmap-prebuilt 是美團平臺迭代組自研的一款 cocoapods 插件,以 Header Map 技術 爲基礎,進一步提升代碼的編譯速度,完善頭文件的搜索機制。

雖然以二進制組件的方式構建 App 是 HPX (美團移動端統一持續集成/交付平臺)的主流解決方案,但在某些場景下(Profile、Address/Thread/UB/Coverage Sanitizer、App 級別靜態檢查、ObjC 方法調用兼容性檢查等等),我們的構建工作還是需要以全源碼編譯的方式進行;而且在實際開發過程中,大多是以源碼的方式進行開發,所以我們將實驗對象設置爲基於全源碼編譯的流程。

廢話不多說,我們來看看它的實際使用效果!

總的來說,以美團和大衆點評的全源碼編譯流程爲實驗對象的前提下,cocoapods-hmap-prebuilt 插件能將總鏈路提升 45% 以上的速度,在 Xcode 打包環節上能提升 50% 以上的速度,是不是有點動心了?

爲了更好的理解這個插件的價值和功能,我們不妨先看一下當前的工程中存在的問題。

爲什麼現有的項目不夠好?

目前,美團內的 App 都是基於 CocoaPods 做包管理方面的工作,所以在實際的開發過程中,CocoaPods 會在 Pods/Header/ 目錄下添加組件名目錄和頭文件軟鏈,類似於下面的形式:

/Users/sketchk/Desktop/MyApp/Pods
└── Headers
    ├── Private
    │   └── AFNetworking
    │       ├── AFHTTPRequestOperation.h -> ./XXX/AFHTTPRequestOperation.h
    │       ├── AFHTTPRequestOperationManager.h -> ./XXX/AFHTTPRequestOperationManager.h
    │       ├── ...
    │       └── UIRefreshControl+AFNetworking.h -> ./XXX/UIRefreshControl+AFNetworking.h
    └── Public
        └── AFNetworking
            ├── AFHTTPRequestOperation.h -> ./XXX/AFHTTPRequestOperation.h
            ├── AFHTTPRequestOperationManager.h -> ./XXX/AFHTTPRequestOperationManager.h
            ├── ...
            └── UIRefreshControl+AFNetworking.h -> ./XXX/UIRefreshControl+AFNetworking.h

也正是通過這樣的目錄結構和軟鏈,CocoaPods 得以在 Header Search Path 中添加如下的參數,使得預編譯環節順利進行。

$(inherited)
${PODS_ROOT}/Headers/Private
${PODS_ROOT}/Headers/Private/AFNetworking
${PODS_ROOT}/Headers/Public
${PODS_ROOT}/Headers/Public/AFNetworking

雖然這種構建 Search Path 的方式解決了預編譯的問題,但在某些項目中,例如多達 400+ 組件的巨型項目中,會造成以下幾點問題:

  1. 大量的 Header Search Path 路徑,會造成編譯參數中的 -I 選項極速膨脹,在達到一定長度後,甚至會造成無法編譯的情況
  2. 目前美團的工程中,已經有近 5W 個頭文件,這意味着不論是頭文件的搜索過程,還是軟鏈的創建過程,都會引起大量的文件 IO 操作,進而會產生一些耗時操作。
  3. 編譯時間會隨着組件數量急劇增長,以美團和大衆點評有 400+ 個組件的體量爲參考,全源碼打包耗時均在 1 小時以上。
  4. 基於路徑順序查找頭文件的方式有潛在的風險,例如重名頭文件的情況,排在後面的頭文件永遠無法參與編譯。
  5. 由於 ${PODS_ROOT}/Headers/Private 路徑的存在,讓引用其他組件的私有頭文件變爲了可能。

想解決上述的問題,好一點的情況下,可能會浪費 1 個小時,而不好的情況,就是讓有風險的代碼上線了,你說工程師會不會因此而感到頭疼?

Header Map 是個啥?

還好 cocoapods-hmap-prebuilt 的出現,讓這些問題變成了歷史,不過要想理解它爲什麼能解決這些問題,我們得先理解一下什麼是 Header Map。

Header Map 其實是一組頭文件信息映射表!

爲了更直觀的理解 Header Map,我們可以在 Build Setting 中開啓 Use Header Map 選項,真實的體驗一下它。

然後在 Build Log 裏獲取相應組件裏對應文件的編譯命令,並在最後加上 -v 參數,來查看其運行的祕密:

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

在 console 的輸出內容中,我們會發現一段有意思的內容:

通過上面的圖,我們可以看到編譯器將尋找頭文件的順序和對應路徑展示出來了,而在這些路徑中,我們看到了一些陌生的東西,即後綴名爲 .hmap 的文件,後面還有個括號寫着 headermap。

沒錯!它就是 Header Map 的實體。

此時 Clang 已經在剛纔提到的 hmap 文件裏塞入了一份頭文件名和頭文件路徑的映射表,不過它是一種二進制格式的文件,爲了驗證這個的說法,我們可以通過 milend 編寫的hmap 工具來查其內容。

在執行相關命令(即 hmap print)後,我們可以發現這些 hmap 裏保存的信息結構大致如下, 類似於一個 Key-Value 的形式,Key 值是頭文件的名稱,Value 是頭文件的實際物理路徑:

需要注意,映射表的鍵值內容會隨着使用場景產生不同的變化,例如頭文件引用是在 "..." 的形式下,還是 <...> 的形式下,又或是在 Build Phase 裏 Header 的配置情況。例如,你將頭文件設置爲 Public 的時候,在某些 hmap 中,它的 Key 值就爲 PodA/ClassA,而將其設置爲 project 的時候,它的 Key 值可能就是 ClassA,而配置這些信息的地方,如下圖所示:

至此我想你應該瞭解到 Header Map 到底是個什麼東西了。

當然這種技術也不是一個什麼新鮮事兒,在 Facebook 的 buck 工具中也提供了類似的東西,只不過文件類型變成了 HeaderMap.java 的樣子。

此時,我估計你可能並不會對 buck 產生太多的興趣,而是開始思考上一張圖中 Headers 的 Public、Private、Project 到底代表着什麼意思,好像很多同學從來沒怎麼關注過,以及爲什麼它會影響 hmap 裏的內容?

Public,Private,Project 是個啥?

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

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

總的來說,我們可以知道一點,就是 Build Phases - Headers 中提到 Public 和 Private 是指可以供外界使用的頭文件,而 Project 中的頭文件是不對外使用的,也不會放在最終的產物中。

如果你繼續翻閱一些資料,例如 StackOverflow - Xcode: Copy Headers: Public vs. Private vs. Project?StackOverflow - Understanding Xcode's Copy Headers phase,你會發現在早期 Xcode Help 的 Project Editor 章節裏,有一段名爲 Setting the Role of a Header File 的段落,裏面詳細記載了三個類型的區別。

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

至此,我們應該能夠徹底瞭解了 Public、Private、Project 的區別。簡而言之,Public 還是通常意義上的 Public,Private 則代表 In Progress 的含義,至於 Project 纔是通常意義上的 Private 含義。

此時,你會不會聯想到 CocoaPods 中 Podspec 的 Syntax 裏還有 public_header_filesprivate_header_files 兩個字段,它們的真實含義是否和 Xcode 裏的概念衝突呢?

這裏我們仔細閱讀一下官方文檔的解釋,尤其是 private_header_files 字段。

我們可以看到,private_header_files 在這裏的含義是說,它本身是相對於 Public 而言的,這些頭文件本義是不希望暴露給用戶使用的,而且也不會產生相關文檔,但是在構建的時候,會出現在最終產物中,只有既沒有被 Public 和 Private 標註的頭文件,纔會被認爲是真正的私有頭文件,且不出現在最終的產物裏。

看起來,CocoaPods 對於 Public 和 Private 的官方解釋是和 Xcode 中的描述一致的,兩處的 Private 並非我們通常理解的 Private,它的本意更應該是開發者準備對外開放,但又沒完全 Ready 的頭文件,更像一個 In Progress 的含義。

這一塊是不是讓你有點大跌眼鏡?那麼,在現實世界中,我們是否正確的使用了它們呢?

爲什麼用原生的 hmap 不能改善編譯速度?

前面我們介紹了 hmap 是什麼,以及怎麼開啓它(啓用 Build Setting 中的 Use Header Map 選項),也介紹了一些影響生成 hmap 的因素(Public、Private、Project)。

那是不是我只要開啓 Xcode 提供的 Use Header Map 就可以提升編譯速度了呢?

很可惜,答案是否定的!

至於原因,我們就從下面的例子開始說起,假設我們有一個基於 CocoaPods 構建的全源碼工程項目,它的整體結構如下:

  • 首先,Host 和 Pod 是我們的兩個 Project,Pods 下的 Target 的產物類型爲 Static Library。
  • 其次,Host 底下會有一個同名的 Target,而 Pods 目錄下會有 n+1 個 Target,其中 n 取決於你依賴的組件數量,而 1 是一個名爲 Pods-XXX 的 Target,最後,Pods-XXX 這個 Target 的產物會被 Host 裏的 Target 所依賴。

整個結構看起來如下所示:

當構建的產物類型爲 Static Library 的時候,CocoaPods 在創建頭文件產物過程中,它的邏輯大致如下:

  • 不論 podspec 裏如何設置 public_header_filesprivate_header_files,相應的頭文件都會被設置爲 Project 類型。
  • Pods/Headers/Public 中會保存所有被聲明爲 public_header_files 的頭文件。
  • Pods/Headers/Private 中會保存所有頭文件,不論是 public_header_files 或者 private_header_files 描述到,還是那些未被描述的,這個目錄下是當前組件的所有頭文件全集。
  • 如果 podspec 裏未標註 Public 和 Private 的時候,Pods/Headers/PublicPods/Headers/Private 的內容一樣且會包含所有頭文件。

正是由於這種機制,會導致一些有意思的問題發生。

  • 首先,由於所有頭文件都被當做最終產物保留下來,在結合 Header Search Path 裏 Pods/Headers/Private 路徑的存在,我們完全可以引用到其他組件裏的私有頭文件,例如我只要使用 #import <SomePod/Private_Header.h> 的方式,就會命中私有文件的匹配路徑。
  • 其次,就是在 Static Library 的狀況下,一旦我們開啓了 Use Header Map,結合組件裏所有頭文件的類型爲 Project 的情況,這個 hmap 裏只會包含 #import "ClassA.h" 的鍵值引用,也就是說只有 #import "ClassA.h" 的方式纔會命中 hmap 的策略,否則都將通過 Header Search Path 尋找其相關路徑,例如下圖中的 PodB,在其 build 的過程中,Xcode 會爲 PodB 生成 5 個 hmap 文件,也就是說這 5 個文件只會在編譯 PodB 中使用,其中 PodB 會依賴 PodA 的一些頭文件,但由於 PodA 中的頭文件都是 Project 類型的,所以其在 hmap 裏的 Key 全部爲 ClassA.h ,也就是說我們只能以 #import "ClassA.h" 的方式引入。

而我們也知道,在引用其他組件的時候,通常都會採用 #import <A/A.h> 的方式引入。至於爲什麼會用這種方式,一方面是這種寫法會明確頭文件的由來,避免問題,另一方面也是這種方式可以讓我們在是否開啓 clang module 中隨意切換。當然,還有一點就是Apple 在 WWDC 裏曾經不止一次建議開發者使用這種方式來引入頭文件。

接着上面的話題來說,所以說在 Static Library 的情況下且以 #import <A/A.h> 這種標準方式引入頭文件時,開啓 Use Header Map 選項並不會幫我們提升編譯速度。

但真的就沒有辦法使用 Header Map 了麼?

cocoapods-hmap-prebuilt 誕生了

當然,總是有辦法解決的,我們完全可以自己動手做一個基於 CocoaPods 規則下的 hmap 文件,正是基於這個想法,美團自研的 cocoapods-hmap-prebuilt 插件誕生了!

它的核心功能並不多,大概有以下幾點:

  • 藉助 CocodPods 處理 Header Search Path 和創建頭文件 soft link 的時機,構建了頭文件索引表並以此生成 n+1 個 hmap 文件(n 是每個組件自己的 Private Header 信息,1 是所有組件公共的 Public Header 信息)。
  • 重寫 xcconfig 文件裏的 Header Search Path 到對應的 hmap 文件上,一條指向組件自己的 private hmap,一條指向所有組件共用的 public hmap。
  • 針對 public hmap 裏的重名頭文件進行了特殊處理,只允許保存組件名/頭文件名方式的 Key-Value,排查重名頭文件帶來的異常行爲。
  • 將組件自身的 Ues Header Map 功能關閉,減少不必要的文件創建和讀取。

聽起來可能有點繞,內容也有點多,不過這些你都不用關心,你只需要通過以下 2 個步驟就能將其使用起來:

  1. 在 Gemfile 裏聲明插件。
  2. 在 Podfile 裏使用插件。
// this is part of Gemfile
source 'http://sakgems.sankuai.com/' do
  gem 'cocoapods-hmap-prebuilt'
  gem 'XXX'
  ...
end

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

除此之外,爲了拓展其實用性,我們還提供了頭文件補丁(解決重名頭文件的定向選取)和環境變量注入(無侵入的在其他系統中使用)的能力,便於其在不同場景下的使用。

總結

至此,關於 cocoapods-hmap-prebuilt 的介紹就要結束了。

回看整個故事的開始,Header Map 是我在研究 Swift 和 Objective-C 混編過程中發現的一個很小的知識點,而且 Xcode 自身就實現了一套基於 Header Map 的功能,在實際的使用過程中,它的表現並不理想。

但幸運的是,在後續的探索的過程中,我們發現了爲什麼 Xcode 的 Header Map 沒有生效,以及爲什麼它與 CocoaPods 出現了不兼容的情況,雖然它的原理並不複雜,核心點就是將文件查找和讀取等 IO 操作編變成了內存讀取操作,但結合實際的業務場景,我們發現它的收益是十分可觀的。

或許這是在提醒我們,要永遠對技術保持一顆好奇的心!

其實,利用 Clang Module 技術也可以解決本文一開始提到的幾個問題,但它並不在這篇文章的討論範圍中,如果你對 Clang Module 或者對 Swift 與 Objective-C 混編感興趣,歡迎閱讀參考文檔中的 《從預編譯的角度理解 Swift 與 Objective-C 及混編機制》一文,以瞭解更多的詳細信息。

參考文檔

作者

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

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

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

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