如何將 iOS 工程打包速度提升十倍以上

bestswifter發表於2017-08-27

[TOC]

過慢的編譯速度有非常明顯的副作用。一方面,程式設計師在等待打包的過程中可能會分心,比如刷刷朋友圈,看條新聞等等。這種認知上下文的切換會帶來很多隱形的時間浪費。另一方面,大部分 app 都有自己的持續整合工具,如果打包速度太慢, 會影響整個團隊的開發進度。

因此,本文會分別討論日常開發和持續整合這兩種場景,分析打包速度慢的瓶頸所在,以及對應的解決方案。利用這些方案,筆者成功的把公司 app 的持續整合時間從 45 min 成功的減少到 9 min,效率提升高達 80%,理論上打包速度可以提升 10 倍以上。如果用一句話總結就是:

在絕對的實力(硬體)面前,一切技巧(軟體)都是浮雲

日常開發

其實日常開發的優化空間並不大,因為預設情況下 Xcode 會使用上次編譯時留下的快取,也就是所謂的增量編譯。因此,日常開發的主要耗時由三部分構成:

總耗時 = 增量編譯 + 連結 + 生成除錯資訊(dSYM)

這裡的增量編譯耗時比較短,即使是在我 14 年高配的 MacBook Pro(4核心,8 執行緒,2.5GHz i7 4870HQ,下文簡稱 MBP) 上,也僅僅耗時十秒上下。我們的應用程式碼量大約一百多萬行,業內超過這個量級的應用應該不多。連結和生成除錯資訊各花費不到 20s,因此一次增量的編譯的時間開銷在半分鐘到一分鐘左右,我們逐個分析:

  1. 增量編譯: 因為耗時較短(大概十幾秒或者更少),幾乎不存在優化的空間,但是非常容易惡化。因為只有標頭檔案不變的編譯單元才能被快取,如果某個檔案被 N 個檔案引用,且這個檔案的標頭檔案發生了變化,那麼這 N 個檔案都會重編譯。APP 的分層架構一般都會做,但一個典型的誤區是在基礎庫的標頭檔案中使用巨集定義,比如定義一些全域性都可以讀取的常量,比如是否開啟除錯,伺服器的地址等等。這些常量一旦改變(比如為了除錯或者切換到某些分支)就會導致應用重編譯。
  2. 連結:連結沒有快取,而且只能用單核進行,因此它的耗時主要取決於單核效能和磁碟讀寫速度。考慮到我們的目標檔案一般都比較小,因此 4K 隨機讀寫的效能應該會更重要一些。
  3. 除錯資訊:日常開發時,並不需要生成 dSYM 檔案,這個檔案主要用於崩潰時查詢呼叫棧,方便線上應用進行除錯,而開發過程中的崩潰可以直接在 Xcode 中看到,關閉這個功能 不會對開發產生任何負面影響

日常開發的優化空間不大,即使是龐大的專案,落後的機器效能,關閉 dSYM 以後也就耗時 30s 左右。相比之下,打包速度可以優化和討論的地方就比較多了。

持續整合

在利用 Jenkins 等工具進行持續整合時,快取不推薦被使用。這是因為蘋果的快取不夠穩定,在某些情況下還存在 bug。比如明明本地已經修復了 bug,可以編譯通過,但上次的編譯快取沒有被正確清理,導致在打包機器上依然無法編譯通過。或者本地明明寫出了 bug,但同樣由於快取問題,打包機器依然可以編譯通過。

因此,無論是手動刪除 Derived Data 資料夾,還是呼叫 xcodebuild clean 命令,都會把快取清空。或者直接使用 xcodebuild archive,會自動忽略快取。每次都要全部重編譯是導致打包速度慢的根本原因。以我們的專案為例,總計 45min 的打包時間中,有 40min 都在執行 xcodebuild 這一行命令。

使用 CCache 快取

最自然的想法就是使用快取了,既然蘋果的快取不靠譜,那麼就找一個靠譜的快取,比如 CCache。它是基於編譯器層面的快取,根據目前反饋的情況看,並不存在快取不一致的問題。根據筆者的實驗,使用 CCache 確實能夠較大幅度的提升打包速度,刪除快取並使用 CCache 重編譯後,耗時只有十幾分鍾。

然而,CCache 最致命的問題是不支援 PCH 檔案和 Clang modules。PCH 的本意是優化編譯時間,我們假設有一個標頭檔案 A 依賴了 M 個標頭檔案,其中每個被依賴的標頭檔案又依賴了 N 個 標頭檔案,如下圖所示:

由於 #import 的本質就是把被依賴標頭檔案的內容拷貝到自己的標頭檔案中來,因此標頭檔案 A 中實際上包含了 M N 個標頭檔案的內容,也就需要 M N 次檔案 IO 和相關處理。當專案中每增加一個依賴標頭檔案 A 的檔案,就會重複一次上述的 M * N 複雜度的過程。

PCH 檔案的好處是,這個檔案中的標頭檔案只會被編譯一次並快取下來,然後新增到專案中 所有 的標頭檔案中去。上述問題倒是解決了,但很智障的一點是,所有檔案都會隱式的依賴所有 PCH 中的檔案,而真正需要被全域性依賴的檔案其實非常少。因此實際開發中,更多的人會把 PCH 當成一種快速 import 的手段,而非編譯效能的優化。前文解釋過,PCH 檔案一旦發生修改,會導致徹徹底底,完完整整的專案重編譯,從而降低編譯速度。正是因為 PCH 的副作用甚至抵消了它帶來的優化,蘋果已經預設不使用 PCH 檔案了。

用來取代 PCH 的就是 Clang modules 技術,對於開啟了這一選項的專案,我們可以用 @import 來替代過去的 #import,比如:

@import UIKit;複製程式碼

等價於

#import <UIKit/UIKit.h>複製程式碼

拋開自動連結 framework 這些小特性不談,Clang modules 可以理解為模組化的 PCH,它具備了 PCH 可以快取標頭檔案的優點,同時提供了更細粒度的引用。

說回到 CCache,由於它不支援 PCH 和 Clang modules,導致無法在我們的專案中應用。即使可以用,也會拖累專案的技術升級,以這種代價來換取快取,只怕是得不償失。

distcc

distcc 是一種分散式編譯工具,可以把需要被編譯的檔案傳送到其他機器上編譯,然後接收編譯產物。然而,經過貼吧、貝聊、手Q 等應用的多方實驗,發現並不適合 iOS 應用。它的原理是多個客戶端共同編譯,但是絕大多數檔案其實編譯時間非常短,並不值得通過網路來回傳送,這種方案應該只適合單個檔案體量非常大的專案。在我們的專案中,使用 distcc 大幅度 增加了打包時間,大約耗時 1 小時左右。

定位瓶頸

在尋求外部工具無果後,筆者開始嘗試著對編譯時間直接做優化。為了搞清楚這 40min 究竟是如何花費的,我首先對 xcodebuild 的輸出結果進行詳細分析。

使用過 xcodebuild 命令的人都會知道,它的輸出結果對開發者並不友好,幾乎沒有可讀性,好在還有 xcpretty 這個工具可以格式化它:

gem install xcpretty複製程式碼

通過 gem 安裝後,只要把 xcodebuild 的輸出結果通過管道傳給 xcpretty 即可:

xcodebuild -scheme Release ... | xcpretty複製程式碼

下面是官方文件中的 Demo:

我只對其中的編譯部分感興趣,所以簡單的做下過濾,我們就可以得到格式高度統一的輸出:

Compiling A.m
Compiling B.m
Compiling ...
Compiling N.m複製程式碼

到了這一步,終於可以做最關鍵的計算了,我們可以通過設定定時器,計算相鄰兩行輸出之間的間隔,這個間隔就是檔案的編譯時間。當然,也有類似的輔助工具做好了這個邏輯:

npm install gnomon複製程式碼

簡單的做一下排序,就可以看到最耗時的前 200 個檔案了,還可以針對檔案字尾作區分,計算總耗時等等。經過排查,我們發現一半的編譯時間都花在了編譯 protobuf 檔案上。

工程設定

除了針對超長耗時的檔案進行 case-by-case 的分析外,另一種方案是調整工程設定。一般來說,我們的持續整合工具主要是用來給產品經理或者測試人員使用,用來體驗功能或者驗證 Bug,除非是需要上架 App Store,否則並不需要關心執行時效能。然而在手機上使用的 Release 模式,預設會開啟各種優化,這些優化都是犧牲編譯效能,換取執行時速度,對於上架的包而言無可厚非,但對於那些 Daily Build 包來說,就顯得得不償失了。

因此,加速打包的思路和優化的思路是完全互逆的,我們要做的就是關閉一切可能的優化。這裡推薦一篇文章:關於Xcode編譯效能優化的研究工作總結,可以說相當全面了。

經過對其中各個引數的查詢資料和嘗試關閉,按照提升速度的降序排列,簡單整理幾個:

  1. 僅支援 armv7 指令集。手機上的指令集都屬於 ARM 系列,從老到新依次是 armv7、armv7s 和 arm64。新的指令集可以相容舊的機型,但舊的機型不能相容新的指令集。預設情況下我們打出來的包會有 armv7 和 arm64 兩種指令集, 前者負責兜底,而對於支援 arm64 指令集的機型來說,使用最新的指令集可以獲得更好的效能。當然代價就是生成兩種指令集花費了更多時間。所以在急速打包模式下,我們只生成 armv7 這種最老的指令集,犧牲了執行時效能換取編譯速度。
  2. 關閉編譯優化。優化的基本原理是犧牲編譯時效能,追求執行時效能。常見的優化有編譯時刪除無用程式碼,保留除錯資訊,函式內聯等等。因此提升打包速度的祕訣就是反其道而行之,犧牲執行時效能來換取編譯時效能。筆者做的兩個最主要的優化是把 Optimize level 改成 O0,表示不做任何優化。
  3. 使用虛擬磁碟。編譯過程中需要大量的磁碟 IO,這主要發生在 Derived Data 目錄下,因此如果記憶體足夠,可以考慮劃出 4G 左右的記憶體,建一個虛擬磁碟,這樣將會把磁碟 IO 優化為 記憶體 IO,從而提高速度。由於打包機器每次都會重編譯,因此並不需要擔心重啟機器後快取丟失的問題。
  4. 不生成 dYSM 檔案,前文已經介紹過。
  5. 一些其他的選項,參考前面推薦的文章。

在以上幾個操作中,精簡指令集的作用最大,大約可以把編譯時間從 45 min 減少到 30min 以內,配合關閉編譯優化,可以進一步把打包時間減少到 20min。虛擬磁碟大約可以減少兩三分鐘的編譯時間,dSYM 耗時大約二十秒,其它選項的優化程度更低,大約在幾秒左右,沒有精確測算。

因此,一般來說 只要精簡指令集並關閉優化即可,有條件的機器可以使用虛擬磁碟,不建議再做其它修改。

二進位制化

二進位制化主要指的是利靜態庫代替原始碼,避免編譯。前文已經介紹過如何分析檔案的耗時,因此二進位制化的收益非常容易計算出來。由於團隊分工問題,筆者沒有什麼二進位制化的經驗,一般來說這個優化比較適合基礎架構組去實施。

硬體加速

以上主要是通過修改軟體的方式來加速打包,自從公司申請了 2013 年款 Mac Pro(Xeon-E5 1630 6 核 12 執行緒,16G 記憶體,256G SSD 標配,下文簡稱 Mac Pro)後,不需要修改任何配置,僅僅是簡單的遷移打包機器,就可以把打包時間降低到 15 min,配和上一節中的前三條優化,最終的打包時間大概在 10min 以內。

在我的黑蘋果(i7 7820x 8 核 16 執行緒,16G 記憶體,三星 PM 961 512G SSD,下文簡稱黑蘋果)上,即使不開啟任何優化,從零開始編譯也僅需 5min。如果將 protobuf 檔案二進位制化,再配合一些工程設定的優化,我不敢想象需要花多長時間,預計在 4min 左右吧,速度提升了大概 11 倍。

編譯是一個考驗多核效能的操作,在我的黑蘋果上,編譯時可以看到 8 個 CPU 的負載都達到了 100%,因此在一定範圍內(比如 10 核以內),提升 CPU 核數遠比提升單核主頻對編譯速度的影響大。至於某些 20 核以上、單核效能較低的 CPU 編譯效能如何,希望有經驗的讀者給予反饋。

優化點總結

下表總結了文章中提到的各種優化手段帶來的速度提升,參考原始時間均為 45 min(打包機器:13 寸 MacBook Pro):

方案序號 優化方案 優化後耗時 (min) 時間減少百分比
1 不常修改的檔案二進位制化 25 44.4%
2 精簡指令集 27 40%
3 關閉編譯優化 38 15.6%
4 使用 Mac Pro 15 66.7%
5 虛擬磁碟 42 6.7%
6 公司現行方案(2+3+4+5) 9 80%
7 黑蘋果 5 88.9%
8 終極方案(1+2+3+5+7) 4(預計) 91.1%(預計)

嚴格意義上講,文章有點標題黨了,因為一句話來說就是:

能用硬體解決的問題,就不要用軟體解決。

相關文章